diff --git a/classes/Database/DatabaseConnector.php b/classes/Database/DatabaseConnector.php index d55a6a3..4d75216 100644 --- a/classes/Database/DatabaseConnector.php +++ b/classes/Database/DatabaseConnector.php @@ -37,6 +37,7 @@ abstract class DatabaseConnector { protected static $names = [ 'domain' => 'DomainMapper', + 'host' => 'HostMapper', 'report' => 'ReportMapper', 'report-log' => 'ReportLogMapper', 'setting' => 'SettingMapper', diff --git a/classes/Database/HostMapperInterface.php b/classes/Database/HostMapperInterface.php new file mode 100644 index 0000000..a299519 --- /dev/null +++ b/classes/Database/HostMapperInterface.php @@ -0,0 +1,44 @@ +. + * + * ========================= + * + * This file contains the HostMapperInterface + * + * @category API + * @package DmarcSrg + * @author Aleksey Andreev (liuch) + * @license https://www.gnu.org/licenses/gpl-3.0.html GNU/GPLv3 + */ + +namespace Liuch\DmarcSrg\Database; + +interface HostMapperInterface +{ + /** + * Return an array with statistics + * + * @param array $data Array with filter parameters + * + * @return array + */ + public function statistics(array &$data, int $user_id): array; +} diff --git a/classes/Database/Mariadb/HostMapper.php b/classes/Database/Mariadb/HostMapper.php new file mode 100644 index 0000000..053f0f9 --- /dev/null +++ b/classes/Database/Mariadb/HostMapper.php @@ -0,0 +1,110 @@ +. + * + * ========================= + * + * This file contains the HostMapper + * + * @category API + * @package DmarcSrg + * @author Aleksey Andreev (liuch) + * @license https://www.gnu.org/licenses/gpl-3.0.html GNU/GPLv3 + */ + +namespace Liuch\DmarcSrg\Database\Mariadb; + +use Liuch\DmarcSrg\DateTime; +use Liuch\DmarcSrg\Database\HostMapperInterface; +use Liuch\DmarcSrg\Exception\DatabaseFatalException; + +/** + * HostMapper class implementation for MariaDB + */ +class HostMapper implements HostMapperInterface +{ + /** @var \Liuch\DmarcSrg\Database\DatabaseConnector */ + private $connector = null; + + /** + * The constructor + * + * @param \Liuch\DmarcSrg\Database\DatabaseConnector $connector DatabaseConnector instance of the current database + */ + public function __construct(object $connector) + { + $this->connector = $connector; + } + + /** + * Return an array with statistics + * + * @param array $data Array with filter parameters + * @param int $user_id User ID + * + * @return array + */ + public function statistics(array &$data, int $user_id): array + { + $res = []; + if (!$user_id) { + $user_domains1 = ''; + $user_domains2 = ''; + } else { + $user_domains1 = ' INNER JOIN `' . $this->connector->tablePrefix('userdomains') + . '`AS `ud` ON `rp`.`domain_id` = `ud`.`domain_id`'; + $user_domains2 = ' AND `user_id` = ' . $user_id; + } + $db = $this->connector->dbh(); + $db->beginTransaction(); + try { + $st = $db->prepare( + 'SELECT COUNT(*), SUM(`rc`) FROM (SELECT COUNT(`report_id`), SUM(`rcount`) AS `rc` FROM `' + . $this->connector->tablePrefix('rptrecords') . '` AS `rr`' + . ' INNER JOIN `reports` AS `rp` ON `rr`.`report_id` = `rp`.`id`' . $user_domains1 + . ' WHERE `rr`.`ip` = ?' . $user_domains2 . ' GROUP BY `report_id`) as t' + ); + $st->bindValue(1, inet_pton($data['ip']), \PDO::PARAM_STR); + $st->execute(); + $row = $st->fetch(\PDO::FETCH_NUM); + $res['reports'] = intval($row[0]); + $res['messages'] = intval($row[1]); + $st->closeCursor(); + $st = $db->prepare( + 'SELECT `rp`.`id`, `begin_time` FROM `' . $this->connector->tablePrefix('rptrecords') . '` AS `rr`' + . ' INNER JOIN `reports` AS `rp` ON `rr`.`report_id` = `rp`.`id`' . $user_domains1 + . ' WHERE `rr`.`ip` = ?' . $user_domains2 . ' GROUP BY `report_id` ORDER BY `begin_time` DESC LIMIT 2' + ); + $st->bindValue(1, inet_pton($data['ip']), \PDO::PARAM_STR); + $st->execute(); + $last_report = []; + while ($row = $st->fetch(\PDO::FETCH_NUM)) { + $last_report[] = new DateTime($row[1]); + } + $res['last_report'] = $last_report; + $st->closeCursor(); + $db->commit(); + } catch (\PDOException $e) { + $db->rollBack(); + throw new DatabaseFatalException('Failed to get host data', -1, $e); + } + return $res; + } +} diff --git a/classes/Hosts/Host.php b/classes/Hosts/Host.php new file mode 100644 index 0000000..a27976f --- /dev/null +++ b/classes/Hosts/Host.php @@ -0,0 +1,144 @@ +. + * + * ========================= + * + * This file contains the class Host + * + * @category API + * @package DmarcSrg + * @author Aleksey Andreev (liuch) + * @license https://www.gnu.org/licenses/gpl-3.0.html GNU/GPLv3 + */ + +namespace Liuch\DmarcSrg\Hosts; + +use Liuch\DmarcSrg\Core; +use Liuch\DmarcSrg\Exception\SoftException; +use Liuch\DmarcSrg\Exception\LogicException; +use Liuch\DmarcSrg\Exception\RuntimeException; + +/** + * This class is designed for storing and manipulating hosts data and utilites. + */ +class Host +{ + private $db = null; + private $rip = null; + private $rdns = null; + private $data = [ + 'ip' => null + ]; + + /** + * The constructor + * + * @param string $ip IP address + * @param \Liuch\DmarcSrg\Database\DatabaseController $db The database controller + * + * @return void + */ + public function __construct(string $ip, $db = null) + { + $this->db = $db ?? Core::instance()->database(); + if (gettype($ip) != 'string' || empty($ip)) { + throw new LogicException('Incorrect host data'); + } + if (!filter_var( + $ip, + FILTER_VALIDATE_IP, + FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6 | FILTER_FLAG_NO_RES_RANGE | FILTER_FLAG_NO_PRIV_RANGE + )) { + throw new SoftException('Incorrect IP address'); + } + $this->data['ip'] = $ip; + } + + /** + * Returns the reverse DNS hostname or an empty string if lookup fails + * + * @return string + */ + public function rdnsName(): string + { + if (is_null($this->rdns)) { + $rdns = gethostbyaddr($this->data['ip']); + if ($rdns === false) { + throw new RuntimeException('Failed to get the reverse DNS hostname'); + } + if ($rdns === $this->data['ip']) { + $rdns = ''; + } + $this->rdns = $rdns; + } + return $this->rdns; + } + + /** + * Checks if the IP address resolved from the reverse DNS hostname matches the source IP address + * + * @return bool + */ + public function checkReverseIP(): bool + { + if (!is_null($this->rip)) { + return $this->rip; + } + + $rname = $this->rdnsName(); + if (empty($rname)) { + return false; + } + + if (filter_var($this->data['ip'], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + $type = DNS_A; + } else { + $type = DNS_AAAA; + } + $ip = inet_ntop(inet_pton($this->data['ip'])); + $ip_res = dns_get_record($rname, $type); + if ($ip_res === false) { + throw new RuntimeException('Failed to resolve IP address'); + } + $this->rip = false; + foreach ($ip_res as &$rec) { + $r = ($type === DNS_A) ? $rec['ip'] : $rec['ipv6']; + if ($ip === $r) { + $this->rip = true; + break; + } + } + unset($rec); + return $this->rip; + } + + /* + * Returns an array with statistics of the host + * + * @param \Liuch\DmarcSrg\Users\User $user User for whom to get statistic + * + * @return array + */ + public function statistics($user): array + { + return $this->db->getMapper('host')->statistics($this->data, $user->id()); + } +} diff --git a/public/css/main.css b/public/css/main.css index 9819b11..a3604ee 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -654,6 +654,11 @@ p.wait-message, p.error-message { cursor: auto; } .report-record > .header { + position: relative; + display: flex; + flex-flow: row wrap; + gap: .5em; + justify-content: center; padding: 0.4em 0; border-bottom: 1px solid var(--color-br-default); border-radius: 7px 7px 0 0; @@ -723,7 +728,7 @@ p.wait-message, p.error-message { text-align: left; margin: auto 0; } -.table-cell.fqdn, .table-cell.orgname, .table-cell.report-id, .table-cell.setting-value { +.table-cell.fqdn, .table-cell.orgname, .table-cell.report-id, .table-cell.setting-value, .hint-content li>span:nth-child(2) { max-width: 15em; overflow: hidden; text-overflow: ellipsis; diff --git a/public/css/widgets.css b/public/css/widgets.css index 8dc4eaf..ce14105 100644 --- a/public/css/widgets.css +++ b/public/css/widgets.css @@ -134,12 +134,17 @@ border-color: var(--color-br-strong) transparent; } .hint-content h4 { - text-align: center; - font-size: 85%; margin-bottom: 1em; padding-bottom: 1em; border-bottom: 1px dotted var(--color-br-strong); } +.hint-content h5 { + margin: .5em 0; +} +.hint-content h4, .hint-content h5 { + font-size: 85%; + text-align: center; +} .hint-content ul { display: flex; flex-direction: column; @@ -396,3 +401,38 @@ multi-select[disabled] .multiselect-tags *, multi-select[disabled] .multiselect- .multiselect-options li[aria-selected="true"].focused { background-color: var(--color-bg-deleted); } +.spinner { + display: inline-block; +} +.spinner > div { + display: inline-block; + background-color: currentColor; + width: 4px; + height: 1em; + margin: 0 2px; + animation: line-scale 1.1s infinite ease; + text-align: center; +} +.spinner > div:nth-child(1) { + animation-delay: -1.2s; +} +.spinner > div:nth-child(2) { + animation-delay: -1.1s; +} +.spinner > div:nth-child(3) { + animation-delay: -1s; +} +.spinner > div:nth-child(4) { + animation-delay: -.9s; +} +.spinner > div:nth-child(5) { + animation-delay: -.8s; +} +@keyframes line-scale { + 0%, 40%, 100% { + transform: scaleY(.4); + } + 20% { + transform: scaleY(1); + } +} diff --git a/public/hosts.php b/public/hosts.php new file mode 100644 index 0000000..8c1a9cc --- /dev/null +++ b/public/hosts.php @@ -0,0 +1,89 @@ +. + * + * ========================= + * + * This script returns information about a host by its IP address + * + * HTTP GET query: + * when the header 'Accept' is 'application/json': + * It returns the host information defined by the passed parameters: + * `host` string Host IP address + * `fields` string Comma-separated list of fields to get information for. + * Possible value are `main`, `stats`. + * otherwise: + * It returns an error. + * Other HTTP methods: + * It returns an error. + * + * @category Web + * @package DmarcSrg + * @author Aleksey Andreev (liuch) + * @license https://www.gnu.org/licenses/gpl-3.0.html GNU/GPLv3 + */ + +namespace Liuch\DmarcSrg; + +use Liuch\DmarcSrg\Hosts\Host; +use Liuch\DmarcSrg\Users\User; +use Liuch\DmarcSrg\Exception\SoftException; +use Liuch\DmarcSrg\Exception\RuntimeException; + +require realpath(__DIR__ . '/..') . '/init.php'; + +if (Core::isJson()) { + try { + $core = Core::instance(); + + if (Core::method() == 'GET') { + $core->auth()->isAllowed(User::LEVEL_USER); + + if (isset($_GET['host']) && isset($_GET['fields'])) { + $host = new Host($_GET['host']); + $fields = explode(',', trim($_GET['fields'])); + if (count(array_diff($fields, [ 'main', 'stats' ]))) { + throw new SoftException('Incorrect field list'); + } + + $res = []; + if (in_array('main', $fields)) { + $main = [ 'rdns' => $host->rdnsName() ]; + if (!empty($main['rdns'])) { + $main['rip'] = $host->checkReverseIp(); + } + $res['main'] = $main; + } + if (in_array('stats', $fields)) { + $res['stats'] = $host->statistics($core->user()); + } + Core::sendJson($res); + return; + } + + Core::sendJson([ 'error_code' => -1, 'message' => 'Bad request' ]); + } + } catch (RuntimeException $e) { + Core::sendJson(ErrorHandler::exceptionResult($e)); + } + return; +} + +Core::sendBad(); diff --git a/public/js/main.js b/public/js/main.js index 6fc2a6e..d089213 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -25,6 +25,7 @@ Router.start = function() { Router._initial_header = document.querySelector("h1").textContent; document.getElementsByTagName("body")[0].addEventListener("keydown", function(event) { + if (event.defaultPrevented) return; if (event.code == "Escape" && !event.shiftKey && !event.ctrlKey && !event.altKey) { let cbs = document.querySelectorAll(".close-btn.active"); for (let i = cbs.length - 1; i >= 0; --i) { diff --git a/public/js/report.js b/public/js/report.js index 2b8b7ca..119bbd5 100644 --- a/public/js/report.js +++ b/public/js/report.js @@ -312,23 +312,28 @@ class Report { hd.appendChild(document.createTextNode("Records")); hd.appendChild(document.createElement("span")); rs.appendChild(hd); - this._data.records.forEach(function(rec) { - let tl = document.createElement("div"); - tl.setAttribute("class", "report-record round-border"); - let hd = document.createElement("div"); - hd.setAttribute("class", "header"); - hd.appendChild(this._create_data_fragment("IP-address", Common.makeIpElement(rec.ip))); - tl.appendChild(hd); - tl.appendChild(this._create_data_item("Message count", rec.count)); - tl.appendChild(this._create_data_item("Policy evaluated", this._create_ev_policy_fragment(rec))); + this._data.records.forEach(rec => { + const tl = rs.appendChild(document.createElement("div")); + tl.classList.add("report-record", "round-border"); + const hd = tl.appendChild(document.createElement("div")); + hd.classList.add("header"); + hd.append( + this._create_data_fragment("IP-address", rec.ip), + (new HintButton({ data: rec.ip, content: this._make_ip_info_element.bind(this) })).element() + ); + tl.append( + this._create_data_item("Message count", rec.count), + this._create_data_item("Policy evaluated", this._create_ev_policy_fragment(rec)) + ); if (rec.reason) - tl.appendChild(this._create_data_item("Evaluated reason", this._create_reason_fragment(rec.reason))); - tl.appendChild(this._create_data_item("Identifiers", this._create_identifiers_fragment(rec))); - tl.appendChild(this._create_data_item("DKIM auth", this._create_dkim_auth_fragment(rec.dkim_auth))); - tl.appendChild(this._create_data_item("SPF auth", this._create_spf_auth_fragment(rec.spf_auth))); - rs.appendChild(tl); + tl.append(this._create_data_item("Evaluated reason", this._create_reason_fragment(rec.reason))); + tl.append( + this._create_data_item("Identifiers", this._create_identifiers_fragment(rec)), + this._create_data_item("DKIM auth", this._create_dkim_auth_fragment(rec.dkim_auth)), + this._create_data_item("SPF auth", this._create_spf_auth_fragment(rec.spf_auth)) + ); - }, this); + }); let nd = document.createElement("div"); nd.classList.add("nodata", "hidden"); nd.textContent = "There are no records to display. Try changing the filter options."; @@ -545,6 +550,143 @@ class Report { } return res; } + + _make_ip_info_element(ip) { + function makeList(items, name) { + const ul = document.createElement("ul"); + ul.dataset.name = name; + items.forEach(it => { + const li = ul.appendChild(document.createElement("li")); + li.appendChild(document.createElement("span")).textContent = it[1] + ": "; + const val = li.appendChild(document.createElement("span")); + val.dataset.id = it[0]; + val.textContet = "-"; + }); + return ul; + } + const el = document.createElement("div"); + el.appendChild(document.createElement("h4")).textContent = "Host information"; + el.append(makeList([ [ "ip", "IP address" ], [ "rdns", "rDNS name" ], [ "rip", "Reverse IP" ] ], "main")); + el.appendChild(document.createElement("h5")).textContent = "Statistics"; + el.append(makeList([ [ "reports", "Total reports" ], [ "messages", "Total messages" ], [ "last_report", "Last report" ] ], "stats")); + this._update_ip_info(ip, el); + return el; + } + + _update_ip_info(ip, el) { + const that = this; + function setWait(el, fin) { + for (const e of el.querySelectorAll("li>span[data-id]")) { + if (fin) { + e.textContent = "-"; + e.removeAttribute("data-id"); + } else { + const we = e.appendChild(document.createElement("div")); + we.ariaLabel = "waiting"; + we.classList.add("spinner"); + we.innerHTML = '
'; + } + } + } + function setData(data, el) { + for (const ge of el.querySelectorAll("ul[data-name]")) { + const gdata = data[ge.dataset.name]; + if (!gdata) continue; + + const map = Array.from(el.querySelectorAll("li>span[data-id]")).reduce((m, e) => { + m.set(e.dataset.id, e); + return m; + }, new Map()); + for (const id in gdata) { + const e = map.get(id); + if (!e) continue; + + let d = gdata[id]; + switch(id) { + case "rdns": + if (d) e.title = d; + break; + case "rip": + if (d) { + d = "match"; + e.classList.add("report-result-pass"); + } else { + d = "not match"; + e.classList.add("report-result-fail"); + } + break; + case "ip": + break; + case "reports": + case "messages": + d = d.toLocaleString(); + break; + case "last_report": + if (d[0]) { + const rt = new Date(that._data.date.begin); + let lt = new Date(d[0]); + if (lt.getTime() === rt.getTime() && d[1]) { + lt = new Date(d[1]); + } + d = lt.toUIDateString(true); + } else { + d = null; + } + break; + default: + continue; + } + if (d) { + e.replaceChildren(d); + e.removeAttribute("data-id"); + } + } + } + } + + const excl_fields = {}; + const s = window.sessionStorage && window.sessionStorage.getItem("ReportView.Cache.ip-" + ip); + if (s) { + try { + const d = JSON.parse(s); + const t = new Date(d.time); + t.setHours(t.getHours() + 24); + if (t > new Date()) { + excl_fields.main = true; + setData(d, el); + } + } catch (err) { + } + } + + setData({ main: { ip: Common.makeIpElement(ip) } }, el); + setWait(el, false); + + const url = new URL("hosts.php", document.location); + url.searchParams.set("host", ip); + url.searchParams.set("fields", [ "main", "stats" ].filter(it => !excl_fields[it]).join(",")); + return window.fetch(url, { + method: "GET", + cache: "no-store", + headers: HTTP_HEADERS, + credentials: "same-origin" + }).then(resp => { + if (!resp.ok) throw new Error("Failed to fetch host information"); + return resp.json(); + }).then(data => { + Common.checkResult(data); + setData(data, el); + if (data.main !== undefined) { + const d = { main: { rdns: data.main.rdns, rip: data.main.rip }, time: Date.now() }; + window.sessionStorage && window.sessionStorage.setItem("ReportView.Cache.ip-" + ip, JSON.stringify(d)); + } + }).catch(err => { + Common.displayError(err); + Notification.add({ type: "error", text: err.message }); + }).finally(() => { + setWait(el, true); + }); + } } class ReportViewFilterDialog extends ReportFilterDialog { diff --git a/public/js/widgets.js b/public/js/widgets.js index d711f55..f2d8cfb 100644 --- a/public/js/widgets.js +++ b/public/js/widgets.js @@ -1693,7 +1693,7 @@ class HintButton { if (!this._content) { switch (typeof(this._params.content)) { case "function": - this._content = this._params.content(); + this._content = this._params.content(this._params.data); break; case "object": case "string":