/* Pi-hole: A black hole for Internet advertisements
* (c) 2023 Pi-hole, LLC (https://pi-hole.net)
* Network-wide ad blocking via your own hardware.
*
* This file is copyright under the latest version of the EUPL.
* Please see LICENSE file for your rights under this license. */
/* global utils:false, setConfigValues: false, apiFailure: false */
"use strict";
// Get the manually entered upstream servers from the textarea
function getManualDNSupstreams() {
return $("#DNSupstreamsTextfield")
.val()
.split(/\r?\n/u)
.map(line => line.trim())
.filter(Boolean);
}
// Get the upstream servers selected in the pre-filled provider tables
function getSelectedDNSupstreams() {
const selectedUpstreams = [];
$("#DNSupstreamTabs table input:checked").each(function () {
const title = $(this).closest("td").attr("title");
if (title && !selectedUpstreams.includes(title)) {
selectedUpstreams.push(title);
}
});
return selectedUpstreams;
}
// Check if a manual upstream server is already covered by a selected Plain upstream server
function isCoveredBySelectedPlainServer(upstream, selectedUpstreamsSet) {
return upstream.endsWith("#53") && selectedUpstreamsSet.has(upstream.slice(0, -3));
}
// Show how many upstreams are selected on each Plain/DoT/DoH tab, so it's not
// hidden behind whichever tab happens to be active
function updateUpstreamTabBadges() {
for (const protocol of ["plain", "dot", "doh"]) {
const count = $("#DNSupstreamsTable-" + protocol + " input:checked").length;
$("#upstreams-count-" + protocol)
.text(count)
.toggle(count > 0)
.attr("title", "Selected servers: " + count);
}
}
// Fill the upstreams tables with the given servers, and initialize the manual upstreams textarea with the given value.
function fillDNSupstreams(value, servers) {
let disabledStr = "";
if (value.flags.env_var === true) {
$("#DNSupstreamsTextfield").prop("disabled", true);
disabledStr = 'disabled="Disabled"';
}
let i = 0;
// Build a single checkbox
for the given address, tracking selection
// state and consuming a custom-server slot when it matches. Plain v4/v6
// addresses also match the default-port-suffixed form; the pinned DoT/DoH
// URIs are matched verbatim only. Returns an empty cell when there is no
// address for this slot (only used within the Plain table, since a vendor
// may only publish one of the two plain addresses in a family).
function checkboxCell(address, portSuffix) {
if (!address) {
return "
`;
}
for (const element of servers) {
// Plain IPv4/IPv6 addresses always get a row, even if a vendor only
// publishes one address per family
let plainRow = "
";
$("#DNSupstreamsTable-plain").append(plainRow);
// DoT/DoH only get a row when the vendor actually publishes a pinned URI
// for at least one address family, so those tabs never fill up with rows
// that are entirely empty
if (element.dot && (element.dot.v4 || element.dot.v6)) {
let dotRow = "
";
$("#DNSupstreamsTable-doh").append(dohRow);
}
}
// Add event listener to checkboxes (shared across the Plain/DoT/DoH tabs)
$("input[id^='DNSupstreams-']").on("change", () => {
updateCustomDNSserversTitle(getManualDNSupstreams().length);
updateUpstreamTabBadges();
});
// Keep only truly manual entries in the custom textfield
const selectedUpstreamsSet = new Set(getSelectedDNSupstreams());
const manualUpstreams = value.value.filter(
upstream =>
!selectedUpstreamsSet.has(upstream) &&
!isCoveredBySelectedPlainServer(upstream, selectedUpstreamsSet)
);
// Initialize textfield
updateDNSserversTextfield(manualUpstreams);
updateUpstreamTabBadges();
// Expand the box if there are custom servers
if (manualUpstreams.length > 0) {
const customBox = document.getElementById("custom-servers-box");
utils.toggleBoxCollapse(customBox, true);
}
// Hide the loading animation
$("#dns-upstreams-overlay").hide();
// Keep custom server count in sync while the user edits the manual list
$("#DNSupstreamsTextfield").on("input", () => {
updateCustomDNSserversTitle(getManualDNSupstreams().length);
});
}
function setInterfaceName(name) {
// If dns.interface is empty in pihole.toml, we use the first interface
// (same default value used by FTL)
if (name === "") {
$.ajax({
url: document.body.dataset.apiurl + "/network/gateway",
async: false,
})
.done(data => {
name = data.gateway[0].interface;
})
.fail(data => {
apiFailure(data);
name = "not found";
});
}
$("#interface-name-1").text(name);
$("#interface-name-2").text(name);
}
// Update the textfield with manual upstream servers only
function updateDNSserversTextfield(manualUpstreams) {
$("#DNSupstreamsTextfield").val(manualUpstreams.join("\n"));
updateCustomDNSserversTitle(manualUpstreams.length);
}
function updateCustomDNSserversTitle(customServerCount) {
$("#custom-servers-title").text(
"(" + customServerCount + " custom server" + (customServerCount === 1 ? "" : "s") + " enabled)"
);
}
function getMergedDNSupstreams(manualUpstreams) {
// Start with all upstreams selected in the pre-filled provider tables.
const mergedUpstreams = getSelectedDNSupstreams();
const selectedUpstreamsSet = new Set(mergedUpstreams);
// Append manual entries unless already covered by a selected table upstream.
// For plain DNS, selected "IP" also covers manual "IP#53".
for (const line of manualUpstreams) {
const upstream = line.trim();
if (!upstream) {
continue;
}
if (
selectedUpstreamsSet.has(upstream) ||
isCoveredBySelectedPlainServer(upstream, selectedUpstreamsSet)
) {
continue;
}
// Preserve user-entered order while preventing duplicates.
if (!mergedUpstreams.includes(upstream)) {
mergedUpstreams.push(upstream);
}
}
return mergedUpstreams;
}
globalThis.getMergedDNSupstreams = getMergedDNSupstreams;
function getRevServerLines() {
// Return the lines from the textarea (array of lines)
return $(".revServers")
.val()
.split(/\r?\n/u)
.filter(line => line.trim() !== "");
}
// Return an array of objects containing the current values from the textarea
function getRevServerArray() {
const items = [];
const lines = getRevServerLines();
for (const line of lines) {
const cols = line.split(",").map(s => s.trim());
items.push({
enabled: cols[0] ?? "",
network: cols[1] ?? "",
ip: cols[2] ?? "",
domain: cols[3] ?? "",
});
}
return items;
}
function createRevServerTable() {
// Get the data
const tableRows = getRevServerArray();
$("#revServers-table").DataTable({
data: tableRows,
autoWidth: false,
columns: [
{ data: "enabled", width: "76px", className: "revserver-chkbox text-center" },
{ data: "network", className: "revserver-network" },
{ data: "ip", className: "revserver-ip" },
{ data: "domain", className: "revserver-domain" },
{ data: null, width: "82px", className: "actions" },
],
ordering: false,
columnDefs: [
{
targets: 0,
// eslint-disable-next-line no-unused-vars
createdCell(td, cellData, rowData, row, col) {
$(td).attr("data-initial-value", cellData);
},
render(data, type, row, meta) {
const name = "enabled_" + meta.row;
const ckbox =
`";
return ckbox;
},
},
{
targets: [1, 2, 3],
// eslint-disable-next-line no-unused-vars
createdCell(td, cellData, rowData, row, col) {
$(td).attr("contenteditable", "true").attr("data-initial-value", cellData);
},
},
],
drawCallback() {
$(".deleteRevServers").on("click", deleteRecord);
$("tbody .saveRevServers").on("click", saveRecord);
$(".cancelRevServers").on("click", restoreRecord);
},
rowCallback(row, data, displayNum, displayIndex, dataIndex) {
$(row).attr("data-index", dataIndex);
const bt = '';
const btDel = $(bt)
.addClass("btn-danger deleteRevServers")
.attr("title", "Delete")
.append('');
const btSave = $(bt)
.addClass("btn-success saveRevServers")
.attr("title", "Confirm changes")
.append('');
const btCancel = $(bt)
.addClass("btn-warning cancelRevServers")
.attr("title", "Undo changes")
.append('');
$("td:eq(4)", row).html(btSave).append(" ", btCancel, " ", btDel);
},
dom:
"<'row'<'col-sm-12 text-right'l>>" +
"<'row'<'col-sm-12'<'table-responsive'tr>>><'row'<'col-sm-12'i>>",
lengthMenu: [
[10, 25, 50, 100, -1],
[10, 25, 50, 100, "All"],
],
language: {
emptyTable: "No reverse DNS servers defined.",
},
stateSave: true,
stateDuration: 0,
processing: true,
stateSaveCallback(settings, data) {
utils.stateSaveCallback("revServers-records-table", data);
},
stateLoadCallback() {
const data = utils.stateLoadCallback("revServers-records-table");
// Return if not available
if (data === null) {
return null;
}
// Apply loaded state to table
return data;
},
});
}
function addRevServer() {
const values = [];
values[0] = $("#enabled-revServers input").prop("checked") ? "true" : "false";
values[1] = $("#network-revServers").text();
values[2] = $("#ip-revServers").text();
values[3] = $("#domain-revServers").text();
// Reject empty network range and server IP
if (values[1] === "" || values[2] === "") {
// Show error message
utils.showAlert("error", "fa fa-ban", "Network Range and Server IP are required", "");
return;
}
// Domain is optional: if empty, remove it from the array
if (values[3] === "") {
values.pop();
}
// Add the new values to the textarea
$(".revServers").val($(".revServers").val() + "\n" + values.join(","));
// Clear the table footer fields
$("#revServers-table tfoot [contenteditable]").text("");
$("#revServers-table tfoot input[type=checkbox]").prop("checked", false);
// Save changes with message
saveRevServerData("Added values: " + values.join(", "));
}
// Button to add a new reverse server
$("#btnAddRevServers").on("click", addRevServer);
function saveRecord() {
// Find the row and its index number
const row = $(this).closest("tr");
const index = row.attr("data-index");
// Get the edited values from each field
const values = [];
values[0] = $(".revserver-chkbox input", row).prop("checked") ? "true" : "false";
values[1] = $(".revserver-network", row).text();
values[2] = $(".revserver-ip", row).text();
values[3] = $(".revserver-domain", row).text();
// Remove "editing" class from the row. Buttons will be shown/hidden via CSS
row.removeClass("editing");
// Reject empty network range and server IP
if (values[1] === "" || values[2] === "") {
// Show error message
utils.showAlert("error", "fa fa-ban", "Network Range and Server IP are required", "");
return;
}
// Domain is optional: if empty, remove it from the array
if (values[3] === "") {
values.pop();
}
// Get the values from the textarea
const lines = getRevServerLines();
// Update the edited line on the textarea
lines[index] = values.join(",");
$(".revServers").val(lines.join("\n"));
// Save changes with message
saveRevServerData("Updated values: " + values.join(", "));
}
function restoreRecord() {
// Find the row and its index number
const row = $(this).closest("tr");
// Reset values using "data-initial-value"
$(".revserver-chkbox input", row).prop(
"checked",
$(".revserver-chkbox input", row).attr("data-initial-value")
);
$('[contenteditable="true"]', row).text(function () {
return $(this).attr("data-initial-value");
});
// Show cancellation message
utils.showAlert("info", "fas fa-undo", "Canceled", "Original values restored");
// Make sure all highlighted cells are restored
// Remove "editing" class from the row. The buttons will be shown/hidden via CSS
row.find(".table-danger").removeClass("table-danger");
row.removeClass("editing");
}
function deleteRecord() {
// Find the row index (this is also the index of the deleted row)
const index = $(this).closest("tr").attr("data-index");
// Get the current lines from the textarea
const lines = getRevServerLines();
// Remove the deleted line and update the textearea
lines.splice(index, 1);
$(".revServers").val(lines.join("\n"));
// Save changes with message
saveRevServerData("Line successfully deleted");
}
function processDNSConfig() {
$.ajax({
url: document.body.dataset.apiurl + "/config/dns?detailed=true", // We need the detailed output to get the DNS server list
})
.done(data => {
// Initialize the DNS upstreams
fillDNSupstreams(data.config.dns.upstreams, data.dns_servers);
setInterfaceName(data.config.dns.interface.value);
setConfigValues("dns", "dns", data.config.dns);
})
.done(() => {
// This will be executed only after the done block above is executed
// If Conditional Forwarding option is set via ENV VAR, the textarea will be disabled and no values can be edited
if ($(".revServers").prop("disabled")) {
// In this case, we hide the table and show the textarea
$("#revServers-table").hide();
$(".revServers").show().attr("title", "Disabled: Set by environment variable");
} else {
// We only populate the table when the textarea is enabled
createRevServerTable();
}
})
.fail(data => {
apiFailure(data);
});
}
$(() => {
processDNSConfig();
});
// Save the Reverse Servers data via API and recreate the table with updated values
function saveRevServerData(msg) {
// Get the data from the textarea
const data = getRevServerLines();
// Call the API to save only the dns.revServers option
$.ajax({
url: document.body.dataset.apiurl + "/config",
method: "PATCH",
dataType: "json",
processData: false,
data: JSON.stringify({ config: { dns: { revServers: data } } }),
contentType: "application/json; charset=utf-8",
})
.done(() => {
utils.enableAll();
utils.showAlert(
"success",
"fa-solid fa-fw fa-floppy-disk",
"Conditional Forwarding settings successfully saved",
msg
);
// Show loading overlay (without reloading the page)
utils.loadingOverlay(false);
// Reset the table to show the updated data
// Remove all rows from the table, then create rows with the updated data and finally redraw the table
const table = $("#revServers-table").DataTable();
table.clear().rows.add(getRevServerArray()).draw();
})
.fail((response, exception) => {
utils.enableAll();
utils.showAlert("error", "", "Error while applying settings", response.responseText);
console.log(exception); // eslint-disable-line no-console
apiFailure(response);
});
}
// Mark the row with "editing" class when an editable cell or checkbox is focused/edited
// This will use CSS rules to show/hide buttons
$(document).on("focus input", "#revServers-table [contenteditable]", function () {
$(this).closest("tr").addClass("editing");
// Make sure the placeholder text is shown when a contenteditable cell is empty (or only contains spaces)
if ($(this).text().trim() === "") {
$(this).empty();
}
});
$(document).on("change", "#revServers-table .revserver-chkbox input", function () {
$(this).closest("tr").addClass("editing");
});
// Validate data entered on the table
// If a cell contains an invalid value, it will be highlighted and the save button will be disabled
$(document).on("input blur paste", ".revserver-network", function () {
const val = $(this).text().trim();
if (val && !(utils.validateIPv4(val) || utils.validateIPv6(val))) {
$(this).addClass("table-danger");
$(this).attr("title", "Invalid network range");
$(this).siblings(".actions").find(".saveRevServers").prop("disabled", true);
} else {
$(this).removeClass("table-danger");
$(this).attr("title", "");
$(this).siblings(".actions").find(".saveRevServers").prop("disabled", false);
}
});
$(document).on("input blur paste", ".revserver-ip", function () {
const val = $(this).text().trim();
if (val && !(utils.validateIPv4WithPort(val) || utils.validateIPv6WithPort(val))) {
$(this).addClass("table-danger");
$(this).attr("title", "Invalid server IP");
$(this).siblings(".actions").find(".saveRevServers").prop("disabled", true);
} else {
$(this).removeClass("table-danger");
$(this).attr("title", "");
$(this).siblings(".actions").find(".saveRevServers").prop("disabled", false);
}
});
$(document).on("input blur paste", ".revserver-domain", function () {
const val = $(this).text().trim();
if (val && !utils.validateHostnameStrict(val)) {
$(this).addClass("table-danger");
$(this).attr("title", "Invalid domain");
$(this).siblings(".actions").find(".saveRevServers").prop("disabled", true);
} else {
$(this).removeClass("table-danger");
$(this).attr("title", "");
$(this).siblings(".actions").find(".saveRevServers").prop("disabled", false);
}
});