};
};
};
+ dmarc_reports = mkOption {
+ description = "DMARC reports configuration";
+ type = submodule {
+ options = {
+ mysql = mkMysqlOptions "DMARC" {};
+ };
+ };
+ };
etherpad-lite = mkOption {
description = "Etherpad configuration";
type = submodule {
webhooks = pkgs.callPackage ./webhooks.nix {
env = config.myEnv.tools.webhooks;
};
+ dmarc-reports = pkgs.callPackage ./dmarc_reports.nix {
+ env = config.myEnv.tools.dmarc_reports;
+ };
landing = pkgs.callPackage ./landing.nix {};
++ ttrss.keys
++ wallabag.keys
++ yourls.keys
+ ++ dmarc-reports.keys
++ webhooks.keys;
services.duplyBackup.profiles = {
++ rompr.apache.modules
++ shaarli.apache.modules
++ dokuwiki.apache.modules
+ ++ dmarc-reports.apache.modules
++ phpbb.apache.modules
++ ldap.apache.modules
++ kanboard.apache.modules;
(kanboard.apache.vhostConf pcfg.kanboard.socket)
(grocy.apache.vhostConf pcfg.grocy.socket)
(phpbb.apache.vhostConf pcfg.phpbb.socket)
+ (dmarc-reports.apache.vhostConf pcfg.dmarc-reports.socket)
''
Alias /paste /var/lib/fiche
<Directory "/var/lib/fiche">
group = "wwwrun";
settings = shaarli.phpFpm.pool;
};
+ dmarc-reports = {
+ user = "wwwrun";
+ group = "wwwrun";
+ settings = dmarc-reports.phpFpm.pool;
+ phpEnv = dmarc-reports.phpFpm.phpEnv;
+ phpOptions = config.services.phpfpm.phpOptions + ''
+ extension=${pkgs.php}/lib/php/extensions/mysqli.so
+ '';
+ };
dokuwiki = {
user = "wwwrun";
group = "wwwrun";
services.websites.webappDirs = {
_adminer = adminer.webRoot;
+ "${dmarc-reports.apache.webappName}" = dmarc-reports.webRoot;
"${dokuwiki.apache.webappName}" = dokuwiki.webRoot;
"${phpbb.apache.webappName}" = phpbb.webRoot;
"${ldap.apache.webappName}" = "${ldap.webRoot}/htdocs";
--- /dev/null
+{ env }:
+rec {
+ keys = [{
+ dest = "webapps/tools-dmarc-reports.php";
+ user = "wwwrun";
+ group = "wwwrun";
+ permissions = "0400";
+ text = ''
+ <?php
+ $dbhost = "${env.mysql.host}";
+ $dbname = "${env.mysql.database}";
+ $dbuser = "${env.mysql.user}";
+ $dbpass = "${env.mysql.password}";
+ $dbport = "${env.mysql.port}";
+ ?>
+ '';
+ }];
+ webRoot = ./dmarc_reports;
+ apache = rec {
+ user = "wwwrun";
+ group = "wwwrun";
+ modules = [ "proxy_fcgi" ];
+ webappName = "tools_dmarc_reports";
+ root = "/run/current-system/webapps/${webappName}";
+ vhostConf = socket: ''
+ Alias /dmarc-reports "${root}"
+ <Directory "${root}">
+ DirectoryIndex index.html
+ <FilesMatch "\.php$">
+ SetHandler "proxy:unix:${socket}|fcgi://localhost"
+ </FilesMatch>
+
+ AllowOverride None
+ Options +FollowSymlinks
+ Require all granted
+ </Directory>
+ '';
+ };
+ phpFpm = rec {
+ basedir = builtins.concatStringsSep ":"
+ [ webRoot "/var/secrets/webapps/tools-dmarc-reports.php" ];
+ pool = {
+ "listen.owner" = apache.user;
+ "listen.group" = apache.group;
+ "pm" = "ondemand";
+ "pm.max_children" = "60";
+ "pm.process_idle_timeout" = "60";
+
+ # Needed to avoid clashes in browser cookies (same domain)
+ "php_admin_value[open_basedir]" = "${basedir}:/tmp";
+ };
+ phpEnv = {
+ SECRETS_FILE = "/var/secrets/webapps/tools-dmarc-reports.php";
+ };
+ };
+}
--- /dev/null
+<?php
+
+require(getenv("SECRETS_FILE"));
+
+$response = array(
+ "status" => "ok",
+);
+$mysqli = new mysqli($dbhost, $dbuser, $dbpass, $dbname, $dbport);
+
+function error_die($text, $number) {
+ http_response_code("500");
+ $message = array(
+ "status" => "error",
+ "message" => $text,
+ "code" => $number
+ );
+
+ die(json_encode($message));
+}
+
+if ($mysqli->connect_errno) {
+ error_die($mysqli->connect_error, $mysqli->connect_errno);
+}
+
+if (!isset($_GET['serial'])) {
+ $response["domains"] = array();
+ $query = $mysqli->query("SELECT DISTINCT domain FROM `report` ORDER BY domain");
+ if ($mysqli->error) { error_die($mysqli->error, $mysqli->errno); }
+ while($row = $query->fetch_assoc()) {
+ $response["domains"][] = $row['domain'];
+ }
+
+ $response["orgs"] = array();
+ $query = $mysqli->query("SELECT DISTINCT org FROM `report` ORDER BY org");
+ if ($mysqli->error) { error_die($mysqli->error, $mysqli->errno); }
+ while($row = $query->fetch_assoc()) {
+ $response["orgs"][] = $row['org'];
+ }
+
+ $response["dates"] = array();
+ $query = $mysqli->query("SELECT DISTINCT DISTINCT year(mindate) as year, month(mindate) as month FROM `report` ORDER BY year DESC,month DESC");
+ if ($mysqli->error) { error_die($mysqli->error, $mysqli->errno); }
+ while($row = $query->fetch_assoc()) {
+ $response["dates"][] = sprintf( "%'.04d-%'.02d", $row['year'], $row['month'] );
+ }
+
+ $response["summaries"] = array();
+ if (isset($_GET['errors_only'])) {
+ $where = " WHERE (spfresult != 'pass' or dkimresult != 'pass')";
+ } else {
+ $where = "";
+ }
+
+ $sql = "SELECT report.* , sum(rptrecord.rcount) AS rcount, MIN(rptrecord.dkimresult) AS dkimresult, MIN(rptrecord.spfresult) AS spfresult FROM report LEFT JOIN (SELECT rcount, COALESCE(dkimresult, 'neutral') AS dkimresult, COALESCE(spfresult, 'neutral') AS spfresult, serial FROM rptrecord) AS rptrecord ON report.serial = rptrecord.serial$where GROUP BY serial ORDER BY mindate ASC, maxdate ASC, org";
+ $query = $mysqli->query($sql);
+ if ($mysqli->error) { error_die($mysqli->error, $mysqli->errno); }
+ while($row = $query->fetch_assoc()) {
+ unset($row["raw_xml"]);
+ $response["summaries"][] = $row;
+ }
+} else {
+ $response["rptrecord"] = [];
+ $sql = $mysqli->prepare("SELECT * FROM rptrecord where serial = ?");
+ $sql->bind_param("s", $_GET["serial"]);
+ $sql->execute();
+ $query = $sql->get_result();
+ if ($mysqli->error) { error_die($mysqli->error, $mysqli->errno); }
+ while($row = $query->fetch_assoc()) {
+ if ($row['ip']) {
+ $ip = long2ip($row['ip']);
+ $host = gethostbyaddr($ip);
+ } elseif ( $row['ip6'] ) {
+ $ip = inet_ntop($row['ip6']);
+ $host = gethostbyaddr($ip);
+ } else {
+ $ip = "-";
+ $host = "-";
+ }
+ $row['ip'] = $ip;
+ $row['host'] = $host;
+ unset($row['ip6']);
+ $response["rptrecord"][] = $row;
+ }
+}
+
+echo json_encode($response, JSON_PRETTY_PRINT);
+?>
--- /dev/null
+const app = new Vue({
+ el: '#app',
+ data: {
+ info: null,
+ summaries: [],
+ selectedSummary: null,
+ filterGreen: true,
+ filterDomain: null,
+ filterOrg: null,
+ //filterDate: (new Date()).toISOString().substring(0, 7),
+ filterDate: null,
+ reverse: true,
+ },
+ created: async function () {
+ let that = this;
+
+ try {
+ this.info = await this.getInfo();
+ this.summaries = this.info.summaries;
+ } catch (error) {}
+ },
+ methods: {
+ filtered: function () {
+ let that = this;
+ let filtered = this.summaries.filter(function (summary) {
+ return (!that.filterGreen || that.getColor(summary) !== "lime")
+ && (!that.filterDomain || summary.domain === that.filterDomain)
+ && (!that.filterOrg || summary.org === that.filterOrg)
+ && (!that.filterDate || that.inDates(summary));
+ });
+ if (this.reverse) {
+ return filtered.reverse();
+ } else {
+ return filtered;
+ }
+ },
+ toggle: async function(summary) {
+ if (this.selectedSummary && this.selectedSummary.serial === summary.serial) {
+ this.selectedSummary = null;
+ } else {
+ if (!summary.details) {
+ summary.details = await this.getDetails(summary.serial);
+ }
+ this.selectedSummary = summary;
+ }
+ },
+ inDates: function(summary) {
+ if (!this.filterDate) { return true; }
+
+ let mindate = (new Date(summary.mindate)).toISOString().substring(0, 7);
+ let maxdate = (new Date(summary.maxdate)).toISOString().substring(0, 7);
+
+ return mindate === this.filterDate || maxdate === this.filterDate;
+ },
+ printDate: function (date) {
+ return (new Date(date)).toISOString();
+ },
+ getColor: function (element) {
+ if (element.dkimresult === "fail" && element.spfresult === "fail") {
+ return "red";
+ } else if (element.dkimresult === "fail" || element.spfresult === "fail") {
+ return "orange";
+ } else if (element.dkimresult === "pass" && element.spfresult === "pass") {
+ return "lime";
+ } else {
+ return "yellow";
+ }
+ },
+ getInfo: function (event) {
+ return fetch('api.php').then(function (response) {
+ if (response.status != 200) { return; }
+ return response.text().then(function (body) {
+ return JSON.parse(body);
+ });
+ });
+ },
+ getDetails: function (serial) {
+ return fetch(`api.php?serial=${serial}`).then(function (response) {
+ if (response.status != 200) { return; }
+ return response.text().then(function (body) {
+ return JSON.parse(body);
+ });
+ });
+ }
+ }
+});
--- /dev/null
+h1 {\r
+ text-align: center;\r
+}\r
+\r
+table.reportlist {\r
+ margin: 2em auto 2em auto;\r
+ border-collapse: collapse;\r
+ clear: both;\r
+}\r
+\r
+table.reportlist td, table.reportlist th {\r
+ padding:3px;\r
+}\r
+\r
+table.reportlist thead {\r
+ border-top: 1px solid grey;\r
+ border-bottom: 1px solid grey;\r
+\r
+}\r
+table.reportlist tbody tr:first-child td {\r
+ padding-top: 10px;\r
+}\r
+table.reportlist tr.sum {\r
+ border-top: 1px solid grey;\r
+}\r
+table.reportlist tr.selected {\r
+ background-color: lightgrey;\r
+}\r
+.reportdesc {\r
+ font-weight: bold;\r
+ width: 90%;\r
+ margin-left: auto;\r
+ margin-right: auto;\r
+}\r
+\r
+tr.summaryrow {\r
+ cursor: pointer;\r
+}\r
+\r
+tr.summaryrow:hover, tr.summaryrow.selected {\r
+ background-color: lightgray;\r
+ border-left: 1px solid lightgray;\r
+}\r
+\r
+td.reportcell {\r
+ border-bottom: 1px solid lightgray;\r
+ border-left: 1px solid lightgray;\r
+ border-right: 1px solid lightgray;\r
+}\r
+\r
+table.reportdata {\r
+ margin: 0px auto 0px auto;\r
+ border-collapse: separate;\r
+ border-spacing: 2px;\r
+}\r
+\r
+table.reportdata tr th, table.reportdata tr td {\r
+ text-align: center;\r
+ padding: 3px;\r
+}\r
+\r
+table.reportdata tr.red {\r
+ background-color: #FF0000;\r
+}\r
+\r
+table.reportdata tr.orange {\r
+ background-color: #FFA500;\r
+}\r
+\r
+table.reportdata tr.lime {\r
+ background-color: #00FF00;\r
+}\r
+\r
+table.reportdata tr.yellow {\r
+ background-color: #FFFF00;\r
+}\r
+\r
+.optionblock {\r
+ background: lightgrey;\r
+ padding: 0.4em;\r
+ float: right;\r
+ margin: auto 2em 1em auto;\r
+ white-space: nowrap;\r
+}\r
+\r
+.optionlabel {\r
+ font-weight: bold;\r
+ float: left; clear: left; \r
+ margin-right: 1em;\r
+}\r
+\r
+.options {\r
+ font-size: 70%;\r
+ text-align: right;\r
+ border: none;\r
+ width: 97%;\r
+ padding: 0.4em;\r
+}\r
+\r
+.center {\r
+ text-align:center;\r
+}\r
+\r
+.circle_lime:before {\r
+ content: ' \25CF';\r
+ font-size: 25px;\r
+ color: #00FF00;\r
+}\r
+\r
+.circle_red:before {\r
+ content: ' \25CF';\r
+ font-size: 25px;\r
+ color: #FF0000;\r
+}\r
+\r
+.circle_yellow:before {\r
+ content: ' \25CF';\r
+ font-size: 25px;\r
+ color: #FFFF00;\r
+}\r
+\r
+.circle_orange:before {\r
+ content: ' \25CF';\r
+ font-size: 25px;\r
+ color: #FFA500;\r
+}\r
--- /dev/null
+<!DOCTYPE html>
+<html>
+
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <meta name="robots" content="noindex">
+ <title>Dmarc reports</title>
+ <link rel="stylesheet" href="default.css">
+</head>
+
+<body>
+ <div id="app" style="width: 100%">
+ <div class="optionblock" v-if="info">
+ <div class='options'>
+ <span class='optionlabel'>Hide all-green lines:</span>
+ <label><input type="radio" :value="false" v-model="filterGreen"> no</label>
+ <label><input type="radio" :value="true" v-model="filterGreen"> yes</label>
+ </div>
+ <div class='options'>
+ <span class='optionlabel'>Sort order:</span>
+ <label><input type="radio" :value="false" v-model="reverse"> ascending</label>
+ <label><input type="radio" :value="true" v-model="reverse"> descending</label>
+ </div>
+ <div class='options'>
+ <span class='optionlabel'>Domain(s):</span>
+ <select v-model="filterDomain">
+ <option selected="selected" :value="null">[all]</option>
+ <option v-for="domain in info.domains" :value="domain">{{ domain }}</option>
+ </select>
+ </div>
+ <div class='options'>
+ <span class='optionlabel'>Organisation(s):</span>
+ <select v-model="filterOrg">
+ <option selected="selected" :value="null">[all]</option>
+ <option v-for="org in info.orgs" :value="org">{{ org }}</option>
+ </select>
+ </div>
+ <div class='options'>
+ <span class='optionlabel'>Time:</span>
+ <select v-model="filterDate">
+ <option selected="selected" :value="null">[all]</option>
+ <option v-for="date in info.dates" :value="date">{{ date }}</option>
+ </select>
+ </div>
+ </div>
+
+ <h1 class='main'>DMARC Reports</h1>
+ <table class='reportlist' v-if="summaries">
+ <thead>
+ <tr>
+ <th></th>
+ <th>Start Date</th>
+ <th>End Date</th>
+ <th>Domain</th>
+ <th>Reporting Organization</th>
+ <th>Report ID</th>
+ <th>Messages</th>
+ </tr>
+ </thead>
+ <tbody>
+ <template v-for="summary in filtered()">
+ <tr v-on:click="toggle(summary)" class="summaryrow"
+ v-bind:class="[{ selected: selectedSummary && summary.serial === selectedSummary.serial }]">
+ <td class='right'><span :class="'circle_' + getColor(summary)"></span></td>
+ <td class='right'>{{ printDate(summary.mindate) }}</td>
+ <td class='right'>{{ printDate(summary.maxdate) }}</td>
+ <td class='center'>{{ summary.domain }}</td>
+ <td class='center'>{{ summary.org }}</td>
+ <td class='center'>{{ summary.reportid }}</td>
+ <td class='center'>{{ summary.rcount }}</td>
+ </tr>
+ <tr v-if="selectedSummary && summary.serial === selectedSummary.serial">
+ <td colspan="6" class="reportcell">
+ <div class='center reportdesc'>
+ <p>Policies: adkim={{ summary.policy_adkim }}, aspf={{ summary.policy_aspf }}, p={{ summary.policy_none }}, sp={{ summary.policy_sp }}, pct={{ summary.policy_pct }}</p>
+ </div>
+ <table v-if="summary.details" class='reportdata'>
+ <thead>
+ <tr>
+ <th>IP Address</th>
+ <th>Host Name</th>
+ <th>Message Count</th>
+ <th>Disposition</th>
+ <th>Reason</th>
+ <th>DKIM Domain</th>
+ <th>Raw DKIM Result</th>
+ <th>SPF Domain</th>
+ <th>Raw SPF Result</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="record in summary.details.rptrecord" :class='getColor(record)'>
+ <td>{{ record.ip }}</td>
+ <td>{{ record.host }}</td>
+ <td>{{ record.rcount }}</td>
+ <td>{{ record.disposition }}</td>
+ <td>{{ record.reason }}</td>
+ <td>{{ record.dkimdomain }}</td>
+ <td>{{ record.dkimresult }}</td>
+ <td>{{ record.spfdomain }}</td>
+ <td>{{ record.spfresult }}</td>
+ </tr>
+ </tbody>
+ </table>
+ </td>
+ <td></td>
+ </tr>
+ </template>
+ </tbody>
+ </table>
+ </div>
+
+ <script src="https://cdn.jsdelivr.net/npm/vue@2.6.11"></script>
+ <script src="app.js"></script>
+</body>
+
+</html>
icon: "fas fa-desktop"
url: "https://status.immae.eu"
target: '_blank' # optionnal html a tag target attribute
+ - name: "DMARC status"
+ url: "https://tools.immae.eu/dmarc-reports"
+ target: '_blank'
- name: "Change password"
url: "https://tools.immae.eu/ldap_password.php"
target: '_blank'