718 lines
22 KiB
JavaScript

/* Pi-hole: A black hole for Internet advertisements
* (c) 2017 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 moment:false, utils:false, REFRESH_INTERVAL:false, i18n */
"use strict";
const beginningOfTime = 1_262_304_000; // Jan 01 2010, 00:00 in seconds
const endOfTime = 2_147_483_647; // Jan 19, 2038, 03:14 in seconds
let from = beginningOfTime;
let until = endOfTime;
const dateformat = "MMM Do YYYY, HH:mm";
let table = null;
let cursor = null;
const filters = [
"client_ip",
"client_name",
"domain",
"upstream",
"type",
"status",
"reply",
"dnssec",
];
function initDateRangePicker() {
$("#querytime").daterangepicker(
{
timePicker: true,
timePickerIncrement: 5,
timePicker24Hour: true,
locale: { format: dateformat },
startDate: moment(from * 1000), // convert to milliseconds since epoch
endDate: moment(until * 1000), // convert to milliseconds since epoch
ranges: {
[i18n.t("queries.last_10_minutes")]: [moment().subtract(10, "minutes"), moment()],
[i18n.t("queries.last_hour")]: [moment().subtract(1, "hours"), moment()],
Today: [moment().startOf("day"), moment().endOf("day")],
Yesterday: [
moment().subtract(1, "days").startOf("day"),
moment().subtract(1, "days").endOf("day"),
],
[i18n.t("queries.last_7_days")]: [moment().subtract(6, "days"), moment().endOf("day")],
[i18n.t("queries.last_30_days")]: [moment().subtract(29, "days"), moment().endOf("day")],
[i18n.t("queries.this_month")]: [moment().startOf("month"), moment().endOf("month")],
[i18n.t("queries.last_month")]: [
moment().subtract(1, "month").startOf("month"),
moment().subtract(1, "month").endOf("month"),
],
[i18n.t("queries.this_year")]: [moment().startOf("year"), moment().endOf("year")],
[i18n.t("queries.all_time")]: [moment(beginningOfTime * 1000), moment(endOfTime * 1000)], // convert to milliseconds since epoch
},
opens: "center",
showDropdowns: true,
autoUpdateInput: true,
},
(startt, endt) => {
// Update global variables
// Convert milliseconds (JS) to seconds (API)
from = moment(startt).utc().valueOf() / 1000;
until = moment(endt).utc().valueOf() / 1000;
}
);
}
function handleAjaxError(xhr, textStatus) {
if (textStatus === "timeout") {
alert(i18n.t("queries.error_server_took_too_long"));
} else {
alert(i18n.t("queries.error_unknown") + "\n" + xhr.responseText);
}
$("#all-queries_processing").hide();
table.clear();
table.draw();
}
function parseQueryStatus(data) {
// Parse query status
let fieldtext;
let buttontext;
let icon = null;
let colorClass = false;
let blocked = false;
let isCNAME = false;
switch (data.status) {
case "GRAVITY":
colorClass = "text-red";
icon = "fa-solid fa-ban";
fieldtext = i18n.t("queries.blocked_gravity");
buttontext = `<button type="button" class="btn btn-default btn-sm text-green btn-whitelist"><i class="fas fa-check"></i> ${i18n.t("shared.allow")}</button>`;
blocked = true;
break;
case "FORWARDED":
colorClass = "text-green";
icon = "fa-solid fa-cloud-download-alt";
fieldtext = i18n.tf(
data.reply.type !== "UNKNOWN" ? "queries.forwarded_reply" : "queries.forwarded_to",
data.upstream
);
buttontext = `<button type="button" class="btn btn-default btn-sm text-red btn-blacklist"><i class="fa fa-ban"></i> ${i18n.t("shared.deny")}</button>`;
break;
case "CACHE":
colorClass = "text-green";
icon = "fa-solid fa-database";
fieldtext = i18n.t("queries.cache_served");
buttontext = `<button type="button" class="btn btn-default btn-sm text-red btn-blacklist"><i class="fa fa-ban"></i> ${i18n.t("shared.deny")}</button>`;
break;
case "REGEX":
colorClass = "text-red";
icon = "fa-solid fa-ban";
fieldtext = i18n.t("queries.blocked_regex");
buttontext = `<button type="button" class="btn btn-default btn-sm text-green btn-whitelist"><i class="fas fa-check"></i> ${i18n.t("shared.allow")}</button>`;
blocked = true;
break;
case "DENYLIST":
colorClass = "text-red";
icon = "fa-solid fa-ban";
fieldtext = i18n.t("queries.blocked_exact");
buttontext = `<button type="button" class="btn btn-default btn-sm text-green btn-whitelist"><i class="fas fa-check"></i> ${i18n.t("shared.allow")}</button>`;
blocked = true;
break;
case "EXTERNAL_BLOCKED_IP":
colorClass = "text-red";
icon = "fa-solid fa-ban";
fieldtext = i18n.t("queries.blocked_external_ip");
buttontext = "";
blocked = true;
break;
case "EXTERNAL_BLOCKED_NULL":
colorClass = "text-red";
icon = "fa-solid fa-ban";
fieldtext = i18n.t("queries.blocked_external_null");
buttontext = "";
blocked = true;
break;
case "EXTERNAL_BLOCKED_NXRA":
colorClass = "text-red";
icon = "fa-solid fa-ban";
fieldtext = i18n.t("queries.blocked_external_nxra");
buttontext = "";
blocked = true;
break;
case "QUERY_EXTERNAL_BLOCKED_EDE15":
colorClass = "text-red";
icon = "fa-solid fa-ban";
fieldtext = i18n.t("queries.blocked_external_ede15");
buttontext = "";
blocked = true;
break;
case "GRAVITY_CNAME":
colorClass = "text-red";
icon = "fa-solid fa-ban";
fieldtext = i18n.t("queries.blocked_gravity_cname");
buttontext = `<button type="button" class="btn btn-default btn-sm text-green btn-whitelist"><i class="fas fa-check"></i> ${i18n.t("shared.allow")}</button>`;
isCNAME = true;
blocked = true;
break;
case "REGEX_CNAME":
colorClass = "text-red";
icon = "fa-solid fa-ban";
fieldtext = i18n.t("queries.blocked_regex_cname");
buttontext = `<button type="button" class="btn btn-default btn-sm text-green btn-whitelist"><i class="fas fa-check"></i> ${i18n.t("shared.allow")}</button>`;
isCNAME = true;
blocked = true;
break;
case "DENYLIST_CNAME":
colorClass = "text-red";
icon = "fa-solid fa-ban";
fieldtext = i18n.t("queries.blocked_exact_cname");
buttontext = `<button type="button" class="btn btn-default btn-sm text-green btn-whitelist"><i class="fas fa-check"></i> ${i18n.t("shared.allow")}</button>`;
isCNAME = true;
blocked = true;
break;
case "RETRIED":
colorClass = "text-green";
icon = "fa-solid fa-redo"; // fa-repeat
fieldtext = i18n.t("queries.retried");
buttontext = "";
break;
case "RETRIED_DNSSEC":
colorClass = "text-green";
icon = "fa-solid fa-redo"; // fa-repeat
fieldtext = i18n.t("queries.retried_ignored");
buttontext = "";
break;
case "IN_PROGRESS":
colorClass = "text-green";
icon = "fa-solid fa-hourglass-half";
fieldtext = i18n.t("queries.forwarded_waiting");
buttontext = `<button type="button" class="btn btn-default btn-sm text-red btn-blacklist"><i class="fa fa-ban"></i> ${i18n.t("shared.deny")}</button>`;
break;
case "CACHE_STALE":
colorClass = "text-green";
icon = "fa-solid fa-infinity";
fieldtext = i18n.t("queries.cache_optimized");
buttontext = `<button type="button" class="btn btn-default btn-sm text-red btn-blacklist"><i class="fa fa-ban"></i> ${i18n.t("shared.deny")}</button>`;
break;
case "SPECIAL_DOMAIN":
colorClass = "text-red";
icon = "fa-solid fa-ban";
fieldtext = data.status;
buttontext = "";
blocked = true;
break;
default:
colorClass = "text-orange";
icon = "fa-solid fa-question";
fieldtext = data.status;
buttontext = "";
}
const matchText =
colorClass === "text-green" ? "allowed" : colorClass === "text-red" ? "blocked" : "matched";
return {
fieldtext,
buttontext,
colorClass,
icon,
isCNAME,
matchText,
blocked,
};
}
function formatReplyTime(replyTime, type) {
if (type === "display") {
// Units:
// - seconds if replytime >= 1 second
// - milliseconds if reply time >= 100 µs
// - microseconds otherwise
return replyTime < 1e-4
? (1e6 * replyTime).toFixed(1) + " µs"
: replyTime < 1
? (1e3 * replyTime).toFixed(1) + " ms"
: replyTime.toFixed(1) + " s";
}
// else: return the number itself (for sorting and searching)
return replyTime;
}
// Parse DNSSEC status
function parseDNSSEC(data) {
let icon = ""; // Icon to display
let color = ""; // Class to apply to text
let text = data.dnssec; // Text to display
switch (text) {
case "SECURE":
icon = "fa-solid fa-lock";
color = "text-green";
break;
case "INSECURE":
icon = "fa-solid fa-lock-open";
color = "text-orange";
break;
case "BOGUS":
icon = "fa-solid fa-exclamation-triangle";
color = "text-red";
break;
case "ABANDONED":
icon = "fa-solid fa-exclamation-triangle";
color = "text-red";
break;
default:
// No DNSSEC or UNKNOWN
text = "N/A";
color = "";
icon = "";
}
return { text, icon, color };
}
function formatInfo(data) {
// Parse Query Status
const dnssec = parseDNSSEC(data);
const queryStatus = parseQueryStatus(data);
const divStart = '<div class="col-xl-2 col-lg-4 col-md-6 col-12 overflow-wrap">';
let statusInfo = "";
if (queryStatus.colorClass !== false) {
statusInfo =
divStart +
i18n.t("queries.status") +
":&nbsp;&nbsp;" +
'<strong><span class="' +
queryStatus.colorClass +
'">' +
queryStatus.fieldtext +
"</span></strong></div>";
}
let listInfo = "";
if (data.list_id !== null && data.list_id !== -1) {
// Some list matched - add link to search page
const searchLink =
data.domain !== "hidden"
? `, <a href="search?domain=${encodeURIComponent(queryStatus.isCNAME ? data.cname : data.domain)}&lang=${i18n.lang}" target="_blank">${i18n.t("queries.search_lists")}</a>`
: "";
listInfo = `${divStart}${i18n.tf("queries.query_was", queryStatus.matchText, searchLink)}</div>`;
}
let cnameInfo = "";
if (queryStatus.isCNAME) {
cnameInfo = divStart + i18n.tf("queries.query_was_blocked", data.cname) + "</div>";
}
// Show TTL if applicable
let ttlInfo = "";
if (data.ttl > 0) {
ttlInfo =
divStart +
i18n.t("queries.ttl") +
":&nbsp;&nbsp;" +
moment.duration(data.ttl, "s").humanize() +
" (" +
data.ttl +
"s)</div>";
}
// Show client information, show hostname only if available
const ipInfo =
data.client.name !== null && data.client.name.length > 0
? utils.escapeHtml(data.client.name) + " (" + data.client.ip + ")"
: data.client.ip;
const clientInfo =
divStart + i18n.t("shared.client") + ":&nbsp;&nbsp;<strong>" + ipInfo + "</strong></div>";
// Show DNSSEC status if applicable
let dnssecInfo = "";
if (dnssec.color !== "") {
dnssecInfo =
divStart +
i18n.t("queries.dnssec_status") +
':&nbsp&nbsp;<strong><span class="' +
dnssec.color +
'">' +
dnssec.text +
"</span></strong></div>";
}
// Show long-term database information if applicable
let dbInfo = "";
if (data.dbid !== false) {
dbInfo = divStart + i18n.t("queries.database_id") + ":&nbsp;&nbsp;" + data.id + "</div>";
}
// Always show reply info, add reply delay if applicable
let replyInfo = "";
replyInfo =
data.reply.type !== "UNKNOWN"
? divStart + i18n.t("queries.reply") + ":&nbsp&nbsp;" + data.reply.type + "</div>"
: divStart + `${i18n.t("queries.reply")}:&nbsp;&nbsp;${i18n.t("queries.no_reply")}</div>`;
// Show extended DNS error if applicable
let edeInfo = "";
if (data.ede !== null && data.ede.text !== null) {
edeInfo = divStart + i18n.t("queries.error_extended_dns") + ":&nbsp;&nbsp;<strong";
if (dnssec.color !== "") {
edeInfo += ' class="' + dnssec.color + '"';
}
edeInfo += ">" + data.ede.text + "</strong></div>";
}
// Compile extra info for displaying
return (
'<div class="row">' +
divStart +
i18n.t("queries.received_on") +
":&nbsp;&nbsp;" +
moment.unix(data.time).format("Y-MM-DD HH:mm:ss.SSS z") +
"</div>" +
clientInfo +
dnssecInfo +
edeInfo +
statusInfo +
cnameInfo +
listInfo +
ttlInfo +
replyInfo +
dbInfo +
"</div>"
);
}
function addSelectSuggestion(name, dict, data) {
const obj = $("#" + name + "_filter");
let value = "";
obj.empty();
// In order for the placeholder value to appear, we have to have a blank
// <option> as the first option in our <select> control. This is because
// the browser tries to select the first option by default. If our first
// option were non-empty, the browser would display this instead of the
// placeholder.
obj.append($("<option />"));
// Add GET parameter as first suggestion (if present and not already included)
if (name in dict) {
value = decodeURIComponent(dict[name]);
if (!data.includes(value)) data.unshift(value);
}
// Add data obtained from API
for (const text of Object.values(data)) {
obj.append($("<option />").val(text).text(text));
}
// Select GET parameter (if present)
if (name in dict) {
obj.val(value);
}
}
function getSuggestions(dict) {
$.get(
document.body.dataset.apiurl + "/queries/suggestions",
data => {
for (const filter of Object.values(filters)) {
addSelectSuggestion(filter, dict, data.suggestions[filter]);
}
},
"json"
);
}
function parseFilters() {
return Object.fromEntries(filters.map(filter => [filter, $(`#${filter}_filter`).val()]));
}
function filterOn(param, dict) {
const typ = typeof dict[param];
return param in dict && (typ === "number" || (typ === "string" && dict[param].length > 0));
}
function getAPIURL(filters) {
let apiurl = document.body.dataset.apiurl + "/queries?";
for (const [key, filter] of Object.entries(filters)) {
if (filterOn(key, filters)) {
if (!apiurl.endsWith("?")) apiurl += "&";
apiurl += `${key}=${encodeURIComponent(filter)}`;
}
}
// Omit from/until filtering if we cannot reach these times. This will speed
// up the database lookups notably on slow devices. The API accepts timestamps
// in seconds since epoch
if (from > beginningOfTime) apiurl += "&from=" + from;
if (until > beginningOfTime && until < endOfTime) apiurl += "&until=" + until;
if ($("#disk").prop("checked")) apiurl += "&disk=true";
return encodeURI(apiurl);
}
let liveMode = false;
$("#live").prop("checked", liveMode);
$("#live").on("click", function () {
liveMode = $(this).prop("checked");
liveUpdate();
});
function liveUpdate() {
if (liveMode) {
refreshTable();
}
}
i18n.waitForTranslations(() => {
// Do we want to filter queries?
const GETDict = utils.parseQueryString();
for (const [sel, element] of Object.entries(filters)) {
$(`#${element}_filter`).select2({
width: "100%",
tags: sel < 4, // Only the first four (client(IP/name), domain, upstream) are allowed to freely specify input
placeholder: i18n.t("shared.select"),
allowClear: true,
});
}
getSuggestions(GETDict);
const apiURL = getAPIURL(GETDict);
if ("from" in GETDict) {
from = GETDict.from;
}
if ("until" in GETDict) {
until = GETDict.until;
}
initDateRangePicker();
table = $("#all-queries").DataTable({
ajax: {
url: apiURL,
error: handleAjaxError,
dataSrc: "queries",
data(d) {
if (cursor !== null) d.cursor = cursor;
},
dataFilter(d) {
const json = JSON.parse(d);
cursor = json.cursor; // Extract cursor from original data
if (liveMode) {
utils.setTimer(liveUpdate, REFRESH_INTERVAL.query_log);
}
return d;
},
},
serverSide: true,
dom:
"<'row'<'col-sm-4'l><'col-sm-8'p>>" +
"<'row'<'col-sm-12'<'table-responsive'tr>>>" +
"<'row'<'col-sm-5'i><'col-sm-7'p>>",
autoWidth: false,
processing: false,
order: [[0, "desc"]],
columns: [
{
data: "time",
width: "10%",
render(data, type) {
if (type === "display") {
return moment.unix(data).format("Y-MM-DD [<br class='hidden-lg'>]HH:mm:ss z");
}
return data;
},
searchable: false,
},
{ data: "status", width: "1%", searchable: false },
{ data: "type", width: "1%", searchable: false },
{ data: "domain", width: "45%" },
{ data: "client.ip", width: "29%", type: "ip-address" },
{ data: "reply.time", width: "4%", render: formatReplyTime, searchable: false },
{ data: null, width: "10%", sortable: false, searchable: false },
],
lengthMenu: [
[10, 25, 50, 100, 500, 1000],
[10, 25, 50, 100, 500, 1000],
],
stateSave: true,
stateDuration: 0,
stateSaveCallback(settings, data) {
utils.stateSaveCallback("query_log_table", data);
},
stateLoadCallback() {
return utils.stateLoadCallback("query_log_table");
},
rowCallback(row, data) {
const querystatus = parseQueryStatus(data);
const dnssec = parseDNSSEC(data);
if (querystatus.icon !== false) {
$("td:eq(1)", row).html(
"<i class='fa fa-fw " +
querystatus.icon +
" " +
querystatus.colorClass +
"' title='" +
utils.escapeHtml(querystatus.fieldtext) +
"'></i>"
);
} else if (querystatus.colorClass !== false) {
$(row).addClass(querystatus.colorClass);
}
// Define row background color
$(row).addClass(querystatus.blocked === true ? "blocked-row" : "allowed-row");
// Substitute domain by "." if empty
let domain = data.domain === 0 ? "." : data.domain;
// Prefix colored DNSSEC icon to domain text
let dnssecIcon = "";
dnssecIcon =
'<i class="mr-2 fa fa-fw ' +
dnssec.icon +
" " +
dnssec.color +
`" title="${i18n.t("queries.dnssec")}: ` +
dnssec.text +
'"></i>';
// Escape HTML in domain
domain = dnssecIcon + utils.escapeHtml(domain);
if (querystatus.isCNAME) {
// Add domain in CNAME chain causing the query to have been blocked
data.cname = utils.escapeHtml(data.cname);
$("td:eq(3)", row).html(domain + "\n(" + i18n.tf("queries.blocked", data.cname) + ")");
} else {
$("td:eq(3)", row).html(domain);
}
// Show hostname instead of IP if available
if (data.client.name !== null && data.client.name !== "") {
$("td:eq(4)", row).text(data.client.name);
} else {
$("td:eq(4)", row).text(data.client.ip);
}
// Show X-icon instead of reply time if no reply was received
if (data.reply.type === "UNKNOWN") {
$("td:eq(5)", row).html('<i class="fa fa-times"></i>');
}
if (querystatus.buttontext !== false) {
$("td:eq(6)", row).html(querystatus.buttontext);
}
},
initComplete() {
this.api()
.columns()
.every(function () {
// Skip columns that are not searchable
const colIdx = this.index();
const bSearchable = this.context[0].aoColumns[colIdx].bSearchable;
if (!bSearchable) {
return null;
}
// Replace footer text with input field for searchable columns
const input = document.createElement("input");
input.placeholder = this.footer().textContent;
this.footer().replaceChildren(input);
// Event listener for user input
input.addEventListener("keyup", () => {
if (this.search() !== this.value) {
this.search(input.value).draw();
}
});
return null;
});
},
});
// Add event listener for adding domains to the allow-/blocklist
$("#all-queries tbody").on("click", "button", function (event) {
const button = $(this);
const tr = button.parents("tr");
const allowButton = button[0].classList.contains("text-green");
const denyButton = button[0].classList.contains("text-red");
const data = table.row(tr).data();
if (denyButton) {
utils.addFromQueryLog(data.domain, "deny");
} else if (allowButton) {
utils.addFromQueryLog(data.domain, "allow");
}
// else: no (colorful) button, so nothing to do
// Prevent tr click even tto be triggered for row from opening/closing
event.stopPropagation();
});
// Add event listener for opening and closing details, except on rows with "details-row" class
$("#all-queries tbody").on("click", "tr:not(.details-row)", function () {
const tr = $(this);
const row = table.row(tr);
if (globalThis.getSelection().toString().length > 0) {
// This event was triggered by a selection, so don't open the row
return;
}
if (row.child.isShown()) {
// This row is already open - close it
row.child.hide();
tr.removeClass("shown");
} else {
// Open this row. Add a class to the row
row.child(formatInfo(row.data()), "details-row").show();
tr.addClass("shown");
}
});
$("#refresh").on("click", refreshTable);
// Disable live mode when #disk is checked
$("#disk").on("click", function () {
if ($(this).prop("checked")) {
$("#live").prop("checked", false);
$("#live").prop("disabled", true);
liveMode = false;
} else {
$("#live").prop("disabled", false);
}
});
});
function refreshTable() {
// Set cursor to NULL so we pick up newer queries
cursor = null;
// Clear table
table.clear();
// Source data from API
const filters = parseFilters();
filters.from = from;
filters.until = until;
const apiUrl = getAPIURL(filters);
table.ajax.url(apiUrl).draw();
}