mirror of
https://github.com/domainaware/parsedmarc.git
synced 2026-11-06 16:59:23 +00:00
* feat: add grafana loki output Push aggregate, failure and SMTP TLS reports as JSON log lines to the Loki push API. Static labels and labels taken from report fields are configurable, host defaults to the FQDN. The timestamp is either the ingest time or the report begin time. Supports basic auth, bearer tokens, X-Scope-OrgID and batching. Push errors are reported as output errors so messages are not archived. Signed-off-by: Sebastian Schubert <basti@schubert.digital> * feat(dashboards): add grafana dashboard for the loki output Signed-off-by: Sebastian Schubert <basti@schubert.digital> * Address Copilot review on the Loki output Responds to Copilot's review of #926: - Treat any push status other than 200/204 as a failure. Loki returns block_ingestion_status_code (260 by default) when block_ingestion_until blocks ingestion, and the logs are discarded (Loki configuration reference); the old >= 300 check counted that as a successful save, so the CLI could archive mailbox messages whose reports were never stored. - Read aggregate begin_date as UTC for timestamp = report_begin. Aggregate CSV rows carry the record's interval_begin, which the parser builds as naive UTC wall-clock time (to_utc=True); reading it as local time shifted entries by the host's UTC offset (+4h on America/New_York). - Group the Published Policies dashboard dedup by org_name as well as report_id: report IDs are only unique per reporter (the PostgreSQL key is UNIQUE (org_name, report_id, ...)). - Drop the legend sum from SMTP TLS Sessions Over Time: its [1d] lookback with the automatic step yields overlapping rolling totals, so the sum counted the same sessions repeatedly. Adds regression tests for each, including a non-UTC timezone test against a real sample report. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Address second Copilot review on the Loki output Responds to Copilot's second review of #926 (previously-missed findings): - Strip each [loki] label_fields entry. _str_to_list() only lstrips, so "report_type , domain" produced "report_type " and LokiClient refused to start with "Invalid Loki label name 'report_type '". - Wipe Loki on RESEED=1 even when Elasticsearch is empty. The wipe sat inside the ES-has-docs guard, so after the ES container was recreated a reseed appended a second copy of the samples to Loki, which does not deduplicate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Address third Copilot review on the Loki output Responds to Copilot's third review of #926 (previously-missed findings): - Keep a base URL's query string when appending the push path. The path was string-concatenated, so https://gw.example.org?route=logs became ...?route=logs/loki/api/v1/push, hitting the root path with a corrupted query. The path is now set on the parsed httpx.URL. - Push each report's entries in timestamp order. With timestamp = report_begin, a normalized multi-day aggregate report yields rows source-major (every day of one source, then day 1 of the next), all in one stream. Loki rejects entries older than the newest line minus half of max_chunk_age (1h by default) with too_far_behind (Loki configuration docs, "Accept out-of-order writes"), so later sources were partly rejected. The whole entry list is now sorted before batching. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(loki): do not follow redirects and drop forensic alias httpx re-sends a 301/302 POST as GET, so a redirected push could return 200 without storing anything, and the mailbox messages were archived. Redirects are no longer followed, so a 3xx raises LokiError. Remove save_forensic_report_to_loki: the module is new, so no caller needs the legacy alias. Signed-off-by: Sebastian Schubert <basti@schubert.digital> * fix(dashboards): use the existing Loki datasource uid in the dev bootstrap When a datasource named Loki already exists with another uid, the dashboard import still bound every panel to dmarc-loki. Read the uid from the lookup response instead. Signed-off-by: Sebastian Schubert <basti@schubert.digital> --------- Signed-off-by: Sebastian Schubert <basti@schubert.digital> Co-authored-by: Sean Whalen <44679+seanthegeek@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
684 lines
30 KiB
Bash
Executable File
684 lines
30 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Bring up docker-compose.dashboard-dev.yml (works with Docker or Podman),
|
|
# import the latest parsedmarc dashboards into each viz system, and seed each
|
|
# backend with sample data so the dashboards have something to render.
|
|
# Idempotent — safe to re-run.
|
|
|
|
set -euo pipefail
|
|
|
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
cd "$REPO_ROOT"
|
|
|
|
usage() {
|
|
cat <<EOF
|
|
Usage: $(basename "${BASH_SOURCE[0]}") [-b|--backend docker|podman] [-h|--help]
|
|
|
|
Bring up the dashboard-dev stack, import dashboards, and seed sample data.
|
|
|
|
Options:
|
|
-b, --backend docker|podman Container backend to use (auto-detects when
|
|
omitted; Docker is preferred if both work).
|
|
-h, --help Show this help and exit.
|
|
|
|
Environment variables:
|
|
CONTAINER_BACKEND Same as --backend; the flag takes precedence.
|
|
RESEED=1 Wipe and re-seed sample data.
|
|
OSD_TENANT OpenSearch Dashboards tenant for the import (default: global).
|
|
WAIT_TIMEOUT Per-service readiness timeout in seconds (default: 180).
|
|
PARSEDMARC_BIN Explicit parsedmarc CLI to use for seeding.
|
|
EOF
|
|
}
|
|
|
|
# A leftover docker CLI with no running daemon must not shadow a working
|
|
# podman: require both the binary and a live `info` call, since `podman info`
|
|
# works daemonless while `docker info` fails without a running daemon.
|
|
engine_works() {
|
|
local engine="$1"
|
|
command -v "$engine" >/dev/null 2>&1 && "$engine" info >/dev/null 2>&1
|
|
}
|
|
|
|
# `podman compose` is a thin wrapper that delegates to an external provider
|
|
# (docker-compose or podman-compose), so the version probe correctly fails
|
|
# when no provider is installed and we fall back to the standalone binary —
|
|
# itself probed with `version` so a broken install is rejected here rather
|
|
# than partway through the run.
|
|
resolve_compose() {
|
|
local engine="$1"
|
|
if "$engine" compose version >/dev/null 2>&1; then
|
|
COMPOSE_CMD=("$engine" compose)
|
|
elif command -v "${engine}-compose" >/dev/null 2>&1 &&
|
|
"${engine}-compose" version >/dev/null 2>&1; then
|
|
COMPOSE_CMD=("${engine}-compose")
|
|
else
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
BACKEND="${CONTAINER_BACKEND:-}"
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
-b|--backend)
|
|
if [ $# -lt 2 ] || [ -z "$2" ]; then
|
|
echo "ERROR: $1 requires a value (docker or podman)" >&2
|
|
usage >&2
|
|
exit 2
|
|
fi
|
|
BACKEND="$2"
|
|
shift 2
|
|
;;
|
|
--backend=*)
|
|
BACKEND="${1#*=}"
|
|
if [ -z "$BACKEND" ]; then
|
|
echo "ERROR: --backend requires a value (docker or podman)" >&2
|
|
usage >&2
|
|
exit 2
|
|
fi
|
|
shift
|
|
;;
|
|
-h|--help)
|
|
usage
|
|
exit 0
|
|
;;
|
|
*)
|
|
echo "ERROR: unknown argument: $1" >&2
|
|
usage >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
done
|
|
|
|
case "$BACKEND" in
|
|
docker|podman)
|
|
if ! engine_works "$BACKEND"; then
|
|
echo "ERROR: backend '$BACKEND' was requested but is not usable (missing, or its 'info' check failed — is it installed and running?)" >&2
|
|
exit 1
|
|
fi
|
|
if ! resolve_compose "$BACKEND"; then
|
|
echo "ERROR: no compose implementation found for '$BACKEND' (looked for '$BACKEND compose' and '${BACKEND}-compose')" >&2
|
|
exit 1
|
|
fi
|
|
;;
|
|
"")
|
|
if engine_works docker && resolve_compose docker; then
|
|
BACKEND=docker
|
|
elif engine_works podman && resolve_compose podman; then
|
|
BACKEND=podman
|
|
else
|
|
echo "ERROR: no working container engine found. Install Docker or Podman with a Compose implementation, or pass --backend." >&2
|
|
exit 1
|
|
fi
|
|
;;
|
|
*)
|
|
echo "ERROR: invalid backend '$BACKEND' from --backend or CONTAINER_BACKEND (expected docker or podman)" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
COMPOSE=("${COMPOSE_CMD[@]}" -f docker-compose.dashboard-dev.yml --env-file .env)
|
|
echo "container backend: ${BACKEND} (${COMPOSE_CMD[*]})"
|
|
|
|
# Load .env so this script can use the same secrets compose injects.
|
|
set -a
|
|
# shellcheck disable=SC1091
|
|
. .env
|
|
set +a
|
|
|
|
GRAFANA_USER="${GRAFANA_USER:-admin}"
|
|
GRAFANA_PASSWORD="${GRAFANA_PASSWORD:-admin}"
|
|
|
|
# PostgreSQL dev credentials. Defaults match docker-compose.dashboard-dev.yml's
|
|
# ${POSTGRESQL_*:-parsedmarc} fallbacks; override all four in lockstep via .env.
|
|
PG_USER="${POSTGRESQL_USER:-parsedmarc}"
|
|
PG_PASSWORD="${POSTGRESQL_PASSWORD:-parsedmarc}"
|
|
PG_DB="${POSTGRESQL_DB:-parsedmarc}"
|
|
|
|
log() { printf '\n\033[1;36m== %s\033[0m\n' "$*"; }
|
|
|
|
wait_for() {
|
|
local name="$1"; shift
|
|
local max="${WAIT_TIMEOUT:-180}"
|
|
local i=0
|
|
printf 'Waiting for %s' "$name"
|
|
while ! "$@" >/dev/null 2>&1; do
|
|
printf '.'
|
|
i=$((i + 1))
|
|
if [ "$i" -ge "$max" ]; then
|
|
printf '\n'
|
|
echo "ERROR: $name not ready after ${max}s" >&2
|
|
return 1
|
|
fi
|
|
sleep 1
|
|
done
|
|
printf ' ready\n'
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Bring up the stack
|
|
# ---------------------------------------------------------------------------
|
|
log "Starting dashboard-dev stack (${COMPOSE_CMD[*]})"
|
|
"${COMPOSE[@]}" up -d
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Wait for each service
|
|
# ---------------------------------------------------------------------------
|
|
log "Waiting for backends and UIs"
|
|
wait_for "Elasticsearch" \
|
|
curl -sf 'http://localhost:9200/_cluster/health?wait_for_status=yellow&timeout=1s'
|
|
wait_for "OpenSearch" \
|
|
curl -ksf -u "admin:${OPENSEARCH_INITIAL_ADMIN_PASSWORD}" \
|
|
'https://localhost:9201/_cluster/health?wait_for_status=yellow&timeout=1s'
|
|
wait_for "Kibana" curl -sf http://localhost:5601/api/status
|
|
wait_for "OpenSearch Dashboards" \
|
|
curl -ksf -u "admin:${OPENSEARCH_INITIAL_ADMIN_PASSWORD}" \
|
|
http://localhost:5602/api/status
|
|
wait_for "Grafana" curl -sf http://localhost:3000/api/health
|
|
wait_for "PostgreSQL" \
|
|
"${COMPOSE[@]}" exec -T postgresql pg_isready -U "$PG_USER" -d "$PG_DB"
|
|
wait_for "Loki" curl -sf http://localhost:3100/ready
|
|
# Splunk's HEC port is healthy once management API is up too.
|
|
wait_for "Splunk HEC" curl -ksf https://localhost:8088/services/collector/health
|
|
# Splunkd management API (used for dashboard imports) lives inside the container.
|
|
wait_for "Splunk management API" \
|
|
"${COMPOSE[@]}" exec -T splunk \
|
|
curl -ksf -u "admin:${SPLUNK_PASSWORD}" https://localhost:8089/services/server/info
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Provision Splunk: index, app, HEC token allow-list
|
|
# Must run before sample-data ingestion. The Splunk image auto-creates a
|
|
# HEC token from SPLUNK_HEC_TOKEN, but with `indexes=[]` and
|
|
# `index=default` — writes to the parsedmarc-dev.ini `email` index will
|
|
# silently drop until both the index exists and the token allows it.
|
|
# ---------------------------------------------------------------------------
|
|
log "Provisioning Splunk index, app, and HEC token"
|
|
splunk_curl() {
|
|
"${COMPOSE[@]}" exec -T splunk \
|
|
curl -ksS -u "admin:${SPLUNK_PASSWORD}" "$@"
|
|
}
|
|
splunk_exists() {
|
|
# 200 if the named entity exists, 404 if not.
|
|
local code
|
|
code=$(splunk_curl -o /dev/null -w "%{http_code}" -X GET "$1") || true
|
|
[ "$code" = "200" ]
|
|
}
|
|
|
|
if splunk_exists https://localhost:8089/services/data/indexes/email; then
|
|
echo " index 'email' already exists — skipping"
|
|
else
|
|
splunk_curl -X POST https://localhost:8089/services/data/indexes \
|
|
-d name=email -d datatype=event >/dev/null
|
|
echo " created index 'email'"
|
|
fi
|
|
|
|
if splunk_exists https://localhost:8089/services/apps/local/DMARC; then
|
|
echo " app 'DMARC' already exists — skipping"
|
|
else
|
|
splunk_curl -X POST https://localhost:8089/services/apps/local \
|
|
-d name=DMARC -d label=DMARC -d visible=true >/dev/null
|
|
echo " created app 'DMARC'"
|
|
fi
|
|
|
|
# The auto-created HEC token is named "splunk_hec_token". Allow the email
|
|
# index and set it as the token default so parsedmarc-dev.ini's `index = email`
|
|
# is honoured. Skip the rewrite if the token already allows email and
|
|
# defaults to it.
|
|
HEC_STATE=$(splunk_curl -X GET \
|
|
"https://localhost:8089/servicesNS/admin/splunk_httpinput/data/inputs/http/splunk_hec_token?output_mode=json" \
|
|
2>/dev/null \
|
|
| python3 -c '
|
|
import json, sys
|
|
e = json.load(sys.stdin)["entry"][0]["content"]
|
|
indexes = e.get("indexes") or []
|
|
disabled = "1" if e.get("disabled") else "0"
|
|
print("|".join([e.get("index") or "", disabled, ",".join(indexes)]))
|
|
' 2>/dev/null || echo "||")
|
|
HEC_DEFAULT_INDEX="${HEC_STATE%%|*}"
|
|
HEC_REST="${HEC_STATE#*|}"
|
|
HEC_DISABLED="${HEC_REST%%|*}"
|
|
HEC_INDEXES=",${HEC_REST#*|},"
|
|
if [ "$HEC_DEFAULT_INDEX" = "email" ] && [ "$HEC_DISABLED" = "0" ] && [[ "$HEC_INDEXES" == *,email,* ]]; then
|
|
echo " HEC token 'splunk_hec_token' already configured — skipping"
|
|
else
|
|
splunk_curl -X POST \
|
|
"https://localhost:8089/servicesNS/admin/splunk_httpinput/data/inputs/http/splunk_hec_token" \
|
|
-d "indexes=email,main" \
|
|
-d "index=email" \
|
|
-d "disabled=0" \
|
|
>/dev/null
|
|
echo " reconfigured HEC token 'splunk_hec_token' (index=email, indexes=email,main)"
|
|
fi
|
|
|
|
# Make sure the HEC listener itself is enabled. Splunk treats this as a no-op
|
|
# if it's already enabled, so just send it once each run — no point checking.
|
|
splunk_curl -X POST \
|
|
"https://localhost:8089/servicesNS/admin/splunk_httpinput/data/inputs/http/http" \
|
|
-d "disabled=0" \
|
|
>/dev/null 2>&1 || true
|
|
|
|
# Splunk ships an in-product announcement view ("Scheduled export is now
|
|
# available for Dashboard Studio") in the search app with sharing=global, so
|
|
# it appears in the dashboards list of every app — including DMARC. Views
|
|
# don't support a `disabled` flag, but narrowing the sharing from `global` to
|
|
# `app` keeps it scoped to the search app only.
|
|
SCHED_SHARING=$(splunk_curl -X GET \
|
|
"https://localhost:8089/servicesNS/-/search/data/ui/views/scheduled_export_dashboard?output_mode=json" \
|
|
2>/dev/null \
|
|
| python3 -c '
|
|
import json, sys
|
|
print(json.load(sys.stdin)["entry"][0]["acl"].get("sharing", ""))
|
|
' 2>/dev/null || echo "")
|
|
if [ "$SCHED_SHARING" = "global" ]; then
|
|
splunk_curl -X POST \
|
|
"https://localhost:8089/servicesNS/nobody/search/data/ui/views/scheduled_export_dashboard/acl" \
|
|
-d "sharing=app" -d "owner=nobody" \
|
|
>/dev/null
|
|
echo " scoped 'scheduled_export_dashboard' to search app (was global)"
|
|
elif [ -n "$SCHED_SHARING" ]; then
|
|
echo " 'scheduled_export_dashboard' already scoped (sharing=${SCHED_SHARING}) — skipping"
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. Seed sample data via parsedmarc -> ES, OS, Splunk HEC, PostgreSQL, Loki
|
|
# Skipped when ES already has aggregate docs from a prior run. Set
|
|
# RESEED=1 to wipe parsedmarc data from every backend first and re-seed
|
|
# (Loki is wiped on RESEED=1 even when ES is empty).
|
|
# ---------------------------------------------------------------------------
|
|
log "Seeding sample data with parsedmarc-dev.ini"
|
|
ES_AGG_COUNT=$(curl -sf 'http://localhost:9200/dmarc_aggregate*/_count' 2>/dev/null \
|
|
| python3 -c 'import json,sys; print(json.load(sys.stdin).get("count", 0))' 2>/dev/null \
|
|
|| echo 0)
|
|
if [ "${RESEED:-0}" != "1" ] && [ "$ES_AGG_COUNT" -gt 0 ]; then
|
|
echo " ES already has $ES_AGG_COUNT aggregate docs — skipping seed (RESEED=1 to force)"
|
|
else
|
|
if [ "${RESEED:-0}" = "1" ] && [ "$ES_AGG_COUNT" -gt 0 ]; then
|
|
echo " RESEED=1: wiping existing parsedmarc data from all backends"
|
|
# ES 8.x rejects wildcard DELETEs by default
|
|
# (action.destructive_requires_name=true). Enumerate the daily indexes
|
|
# parsedmarc rolls (dmarc_aggregate-YYYY-MM-DD, dmarc_failure-...,
|
|
# smtp_tls-...) and DELETE each one explicitly. dmarc_forensic-* is the
|
|
# pre-rename failure index family, kept here so RESEED clears old data.
|
|
for prefix in dmarc_aggregate dmarc_failure dmarc_forensic smtp_tls; do
|
|
for idx in $(curl -sf "http://localhost:9200/_cat/indices/${prefix}*?h=index" 2>/dev/null); do
|
|
curl -sS -X DELETE "http://localhost:9200/${idx}" >/dev/null 2>&1 || true
|
|
done
|
|
for idx in $(curl -ksf -u "admin:${OPENSEARCH_INITIAL_ADMIN_PASSWORD}" "https://localhost:9201/_cat/indices/${prefix}*?h=index" 2>/dev/null); do
|
|
curl -ksS -u "admin:${OPENSEARCH_INITIAL_ADMIN_PASSWORD}" \
|
|
-X DELETE "https://localhost:9201/${idx}" >/dev/null 2>&1 || true
|
|
done
|
|
done
|
|
# Splunk has no clean-in-place REST endpoint for live indexes. The
|
|
# standard pattern is to delete and recreate. Settings carry over from
|
|
# the recreate POST below — datatype=event is what parsedmarc HEC needs.
|
|
splunk_curl -X DELETE \
|
|
"https://localhost:8089/services/data/indexes/email" >/dev/null 2>&1 || true
|
|
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
|
splunk_exists https://localhost:8089/services/data/indexes/email || break
|
|
sleep 1
|
|
done
|
|
splunk_curl -X POST https://localhost:8089/services/data/indexes \
|
|
-d name=email -d datatype=event >/dev/null
|
|
# Recreate forces the HEC token allow-list to re-resolve against the
|
|
# new index. Re-apply the token config so the next seed lands.
|
|
splunk_curl -X POST \
|
|
"https://localhost:8089/servicesNS/admin/splunk_httpinput/data/inputs/http/splunk_hec_token" \
|
|
-d "indexes=email,main" -d "index=email" -d "disabled=0" >/dev/null
|
|
# PostgreSQL: drop and recreate the public schema. parsedmarc recreates
|
|
# its tables on the next seed run, so this is a clean wipe.
|
|
"${COMPOSE[@]}" exec -T -e PGPASSWORD="$PG_PASSWORD" postgresql \
|
|
psql -U "$PG_USER" -d "$PG_DB" \
|
|
-c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;' >/dev/null 2>&1 || true
|
|
fi
|
|
|
|
if [ "${RESEED:-0}" = "1" ]; then
|
|
# Loki: the container keeps its data inside itself, so recreating it
|
|
# is a clean wipe. Done whenever RESEED=1, even if ES is empty (e.g.
|
|
# the ES container was recreated): Loki does not dedupe, so seeding on
|
|
# top of leftover logs would append a duplicate copy.
|
|
"${COMPOSE[@]}" up -d --force-recreate loki >/dev/null 2>&1 || true
|
|
wait_for "Loki (after wipe)" curl -sf http://localhost:3100/ready
|
|
fi
|
|
|
|
# Resolve a Python environment for the seed and make sure parsedmarc plus
|
|
# every output integration ([all]) and the PostgreSQL extra (psycopg) are
|
|
# installed in it, so the same run can populate Elasticsearch, OpenSearch,
|
|
# and Postgres. Precedence:
|
|
# 1. An explicit PARSEDMARC_BIN — used as-is, nothing installed.
|
|
# 2. An already-activated virtualenv ($VIRTUAL_ENV).
|
|
# 3. An existing repo venv/ or .venv/.
|
|
# 4. Otherwise a freshly created $REPO_ROOT/venv.
|
|
# Cases 2-4 run `pip install -e ".[all,postgresql]"` only when the CLI,
|
|
# psycopg, or an [all] integration (elasticsearch stands in for the group)
|
|
# is missing, so it's a no-op once the environment is set up.
|
|
if [ -n "${PARSEDMARC_BIN:-}" ]; then
|
|
if [ ! -x "$PARSEDMARC_BIN" ]; then
|
|
echo "ERROR: PARSEDMARC_BIN is set but not executable: $PARSEDMARC_BIN" >&2
|
|
exit 1
|
|
fi
|
|
echo " using PARSEDMARC_BIN: $PARSEDMARC_BIN"
|
|
else
|
|
if [ -n "${VIRTUAL_ENV:-}" ]; then
|
|
seed_venv="$VIRTUAL_ENV"
|
|
echo " using active virtualenv: $seed_venv"
|
|
elif [ -d "$REPO_ROOT/venv" ]; then
|
|
seed_venv="$REPO_ROOT/venv"
|
|
echo " using existing venv: $seed_venv"
|
|
elif [ -d "$REPO_ROOT/.venv" ]; then
|
|
seed_venv="$REPO_ROOT/.venv"
|
|
echo " using existing .venv: $seed_venv"
|
|
else
|
|
seed_venv="$REPO_ROOT/venv"
|
|
echo " creating virtualenv: $seed_venv"
|
|
python3 -m venv "$seed_venv"
|
|
fi
|
|
PARSEDMARC_BIN="$seed_venv/bin/parsedmarc"
|
|
if [ ! -x "$PARSEDMARC_BIN" ] ||
|
|
! "$seed_venv/bin/python" -c 'import psycopg, elasticsearch' >/dev/null 2>&1; then
|
|
echo " installing parsedmarc[all,postgresql] into $seed_venv"
|
|
"$seed_venv/bin/python" -m pip install -q -e "${REPO_ROOT}[all,postgresql]"
|
|
fi
|
|
fi
|
|
if [ ! -x "$PARSEDMARC_BIN" ]; then
|
|
echo "ERROR: parsedmarc CLI not found at $PARSEDMARC_BIN" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Live DNS lookups (no --offline) so source_reverse_dns / source_base_domain
|
|
# are populated. Many samples carry synthetic IPs (10.x, 198.51.100.x,
|
|
# 2001:db8::, etc.) that won't resolve, so cap retries/timeout to bound
|
|
# the cost of those NXDOMAIN-bound lookups. Intentionally invalid samples
|
|
# (empty_reason.xml, invalid_xml.xml, etc.) are skipped from the list.
|
|
# samples/aggregate/!large-example.com!1711897200!1711983600.xml is
|
|
# deliberately NOT seeded: its 2,286 synthetic records would be ~99% of
|
|
# the corpus, drowning the realistic mix on every unfiltered dashboard
|
|
# (and its records carry no envelope_from and empty SPF domains, so that
|
|
# column reads almost entirely blank). To load it for scale or backfill
|
|
# testing, run the seed command below manually with that file appended.
|
|
SAMPLE_FILES=(
|
|
samples/aggregate/!example.com!1538204542!1538463818.xml
|
|
'samples/aggregate/Report domain- borschow.com Submitter- google.com Report-ID- 949348866075514174.eml'
|
|
samples/aggregate/addisonfoods.com!example.com!1536105600!1536191999.xml
|
|
samples/aggregate/estadocuenta1.infonacot.gob.mx!example.com!1536853302!1536939702!2940.xml.zip
|
|
samples/aggregate/example.net!example.com!1529366400!1529452799.xml
|
|
samples/aggregate/fastmail.com!example.com!1516060800!1516147199!102675056.xml.gz
|
|
samples/aggregate/ikea.com!example.de!1538690400!1538776800.xml
|
|
samples/aggregate/protection.outlook.com!example.com!1711756800!1711843200.xml
|
|
samples/aggregate/usssa.com!example.com!1538784000!1538870399.xml
|
|
samples/aggregate/veeam.com!example.com!1530133200!1530219600.xml
|
|
samples/aggregate/rfc9990-sample.xml
|
|
samples/aggregate/rfc9990-example.net!example.com!1700000000!1700086399.xml
|
|
samples/failure/*.eml
|
|
samples/smtp_tls/*.json
|
|
samples/smtp_tls/google.com_smtp_tls_report.eml
|
|
)
|
|
# PostgreSQL config is injected via env vars (parsedmarc synthesizes the
|
|
# [postgresql] section from PARSEDMARC_POSTGRESQL_*), so the same seed run
|
|
# also populates Postgres without touching the gitignored parsedmarc-dev.ini.
|
|
# Only wire it in when psycopg is importable: parsedmarc aborts the whole
|
|
# run (exit 1, nothing written to *any* backend) if a configured output
|
|
# backend can't initialize, so a missing optional extra must not be added.
|
|
pg_seed_env=()
|
|
seed_python="$(dirname "$PARSEDMARC_BIN")/python"
|
|
if [ -x "$seed_python" ] && "$seed_python" -c 'import psycopg' >/dev/null 2>&1; then
|
|
pg_seed_env=(
|
|
PARSEDMARC_POSTGRESQL_HOST=localhost
|
|
PARSEDMARC_POSTGRESQL_PORT=5432
|
|
PARSEDMARC_POSTGRESQL_USER="$PG_USER"
|
|
PARSEDMARC_POSTGRESQL_PASSWORD="$PG_PASSWORD"
|
|
PARSEDMARC_POSTGRESQL_DATABASE="$PG_DB"
|
|
)
|
|
else
|
|
# Reached only for an explicit PARSEDMARC_BIN whose env lacks psycopg
|
|
# (the auto-resolved venv path installs the extra above).
|
|
echo " NOTE: 'psycopg' is not available to ${PARSEDMARC_BIN} — skipping the"
|
|
echo " PostgreSQL seed. Enable it with: pip install -e '.[all,postgresql]'"
|
|
fi
|
|
# Like psycopg above, this is reachable only for an explicit
|
|
# PARSEDMARC_BIN: without the [all] integrations the seed run below
|
|
# exits 1 on its ConfigurationError (swallowed by the `|| true`),
|
|
# leaving every dashboard empty with nothing on the console to say why.
|
|
if [ -x "$seed_python" ] && ! "$seed_python" -c 'import elasticsearch' >/dev/null 2>&1; then
|
|
echo " WARNING: the Elasticsearch/OpenSearch clients are not available to"
|
|
echo " ${PARSEDMARC_BIN} — the seed run will fail to initialize its"
|
|
echo " outputs. Install them with: pip install -e '.[all,postgresql]'"
|
|
fi
|
|
# The Loki output is part of the base install, so it is always seeded.
|
|
# Lines keep the default ingest timestamp: Loki rejects samples older
|
|
# than a week, and the sample reports are years old.
|
|
env "${pg_seed_env[@]}" PARSEDMARC_LOKI_URL=http://localhost:3100 \
|
|
"$PARSEDMARC_BIN" -t 2.0 --dns-retries 1 -c parsedmarc-dev.ini "${SAMPLE_FILES[@]}" || true
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. Import dashboards. Always re-imported on every run — that's the point of
|
|
# invoking this script after editing a dashboard. Datasources are checked
|
|
# first and skipped when already present.
|
|
# ---------------------------------------------------------------------------
|
|
log "Importing Kibana dashboards"
|
|
curl -sS -X POST 'http://localhost:5601/api/saved_objects/_import?overwrite=true' \
|
|
-H 'kbn-xsrf: true' \
|
|
--form file=@dashboards/opensearch/opensearch_dashboards.ndjson | sed 's/^/ /'
|
|
|
|
log "Importing OpenSearch Dashboards saved objects"
|
|
# OSD with the security plugin enabled stores saved objects per tenant. Without
|
|
# a securitytenant header the import lands in the API user's *private* tenant,
|
|
# which is invisible to anyone else (and to the same user when their browser
|
|
# session is on a different tenant). Target the Global tenant — the shared
|
|
# workspace every user has access to and where public dashboards conventionally
|
|
# live. Its securitytenant token is the literal "global"; any *other* string is
|
|
# treated as a custom tenant name, so "global_tenant" would silently create a
|
|
# separate "global_tenant" tenant rather than hit Global. (An empty/omitted
|
|
# header is *not* equivalent — it falls back to the user's configured default
|
|
# tenant, not Global.) To send the import elsewhere set OSD_TENANT=admin_tenant
|
|
# (or any other tenant name) before running.
|
|
OSD_TENANT="${OSD_TENANT:-global}"
|
|
curl -sS -X POST 'http://localhost:5602/api/saved_objects/_import?overwrite=true' \
|
|
-H 'osd-xsrf: true' \
|
|
-H "securitytenant: ${OSD_TENANT}" \
|
|
-u "admin:${OPENSEARCH_INITIAL_ADMIN_PASSWORD}" \
|
|
--form file=@dashboards/opensearch/opensearch_dashboards.ndjson | sed 's/^/ /'
|
|
echo " (imported into OSD tenant: ${OSD_TENANT})"
|
|
|
|
log "Ensuring Grafana Elasticsearch datasource plugin is installed"
|
|
# Grafana >= 13 no longer bundles the Elasticsearch datasource plugin, and
|
|
# GF_INSTALL_PLUGINS cannot install it (the image ships a root-owned
|
|
# plugins-bundled/elasticsearch remnant its background installer fails to
|
|
# replace). `grafana cli` installs into /var/lib/grafana/plugins, which works;
|
|
# a restart is needed for Grafana to load it.
|
|
code=$(curl -sS -u "${GRAFANA_USER}:${GRAFANA_PASSWORD}" \
|
|
-o /dev/null -w "%{http_code}" \
|
|
"http://localhost:3000/api/plugins/elasticsearch/settings")
|
|
if [ "$code" != "200" ]; then
|
|
"${COMPOSE[@]}" exec -T grafana grafana cli plugins install elasticsearch \
|
|
| sed 's/^/ /'
|
|
"${COMPOSE[@]}" restart grafana >/dev/null
|
|
wait_for "Grafana (after plugin install)" \
|
|
curl -sf http://localhost:3000/api/health
|
|
code=$(curl -sS -u "${GRAFANA_USER}:${GRAFANA_PASSWORD}" \
|
|
-o /dev/null -w "%{http_code}" \
|
|
"http://localhost:3000/api/plugins/elasticsearch/settings")
|
|
if [ "$code" != "200" ]; then
|
|
echo "ERROR: elasticsearch datasource plugin failed to install" >&2
|
|
exit 1
|
|
fi
|
|
echo " installed elasticsearch datasource plugin"
|
|
else
|
|
echo " elasticsearch datasource plugin already installed"
|
|
fi
|
|
|
|
log "Configuring Grafana datasources"
|
|
# Two Elasticsearch datasources, one per index family, matching the dashboard's
|
|
# template variables (dmarc-ag and dmarc-fo). Skipped when already present.
|
|
declare -a GF_DS_NAMES=("dmarc-ag" "dmarc-fo")
|
|
# dmarc_f* matches both pre-rename dmarc_forensic* and post-rename
|
|
# dmarc_failure* indices, mirroring the OpenSearch/Kibana dashboards.
|
|
declare -a GF_DS_INDEX=("dmarc_aggregate*" "dmarc_f*")
|
|
declare -a GF_DS_TIME=("date_begin" "arrival_date")
|
|
for i in 0 1; do
|
|
name="${GF_DS_NAMES[$i]}"
|
|
code=$(curl -sS -u "${GRAFANA_USER}:${GRAFANA_PASSWORD}" \
|
|
-o /dev/null -w "%{http_code}" \
|
|
"http://localhost:3000/api/datasources/name/${name}")
|
|
if [ "$code" = "200" ]; then
|
|
echo " datasource '${name}' already exists — skipping"
|
|
continue
|
|
fi
|
|
body=$(cat <<EOF
|
|
{
|
|
"name": "${name}",
|
|
"type": "elasticsearch",
|
|
"url": "http://elasticsearch:9200",
|
|
"access": "proxy",
|
|
"database": "${GF_DS_INDEX[$i]}",
|
|
"isDefault": false,
|
|
"jsonData": {
|
|
"esVersion": "8.0.0",
|
|
"timeField": "${GF_DS_TIME[$i]}",
|
|
"maxConcurrentShardRequests": 5
|
|
}
|
|
}
|
|
EOF
|
|
)
|
|
curl -sS -u "${GRAFANA_USER}:${GRAFANA_PASSWORD}" \
|
|
-H 'Content-Type: application/json' \
|
|
-X POST "http://localhost:3000/api/datasources" \
|
|
-d "$body" | sed 's/^/ /'
|
|
echo
|
|
echo " created datasource '${name}'"
|
|
done
|
|
|
|
# PostgreSQL datasource for the PostgreSQL DMARC dashboard. Fixed uid dmarc-pg
|
|
# so the dashboard import below can resolve its ${DS_POSTGRESQL} input. Skipped
|
|
# when already present.
|
|
pg_ds_code=$(curl -sS -u "${GRAFANA_USER}:${GRAFANA_PASSWORD}" \
|
|
-o /dev/null -w "%{http_code}" \
|
|
"http://localhost:3000/api/datasources/name/PostgreSQL")
|
|
if [ "$pg_ds_code" = "200" ]; then
|
|
echo " datasource 'PostgreSQL' already exists — skipping"
|
|
else
|
|
pg_ds_body=$(cat <<EOF
|
|
{
|
|
"name": "PostgreSQL",
|
|
"uid": "dmarc-pg",
|
|
"type": "grafana-postgresql-datasource",
|
|
"url": "postgresql:5432",
|
|
"access": "proxy",
|
|
"user": "${PG_USER}",
|
|
"database": "${PG_DB}",
|
|
"isDefault": false,
|
|
"jsonData": { "sslmode": "disable" },
|
|
"secureJsonData": { "password": "${PG_PASSWORD}" }
|
|
}
|
|
EOF
|
|
)
|
|
curl -sS -u "${GRAFANA_USER}:${GRAFANA_PASSWORD}" \
|
|
-H 'Content-Type: application/json' \
|
|
-X POST "http://localhost:3000/api/datasources" \
|
|
-d "$pg_ds_body" | sed 's/^/ /'
|
|
echo
|
|
echo " created datasource 'PostgreSQL'"
|
|
fi
|
|
|
|
# Loki datasource for the Loki DMARC dashboard. Created with uid dmarc-loki
|
|
# when missing; an existing datasource named Loki keeps its own uid, which the
|
|
# dashboard import below resolves its ${DS_LOKI} input to.
|
|
LOKI_DS_UID=dmarc-loki
|
|
loki_ds_resp=$(curl -sS -u "${GRAFANA_USER}:${GRAFANA_PASSWORD}" \
|
|
-w $'\n%{http_code}' \
|
|
"http://localhost:3000/api/datasources/name/Loki")
|
|
loki_ds_code="${loki_ds_resp##*$'\n'}"
|
|
if [ "$loki_ds_code" = "200" ]; then
|
|
LOKI_DS_UID=$(printf '%s' "${loki_ds_resp%$'\n'*}" \
|
|
| python3 -c 'import json, sys; print(json.load(sys.stdin)["uid"])')
|
|
echo " datasource 'Loki' already exists (uid ${LOKI_DS_UID}) — skipping"
|
|
else
|
|
curl -sS -u "${GRAFANA_USER}:${GRAFANA_PASSWORD}" \
|
|
-H 'Content-Type: application/json' \
|
|
-X POST "http://localhost:3000/api/datasources" \
|
|
-d '{"name": "Loki", "uid": "dmarc-loki", "type": "loki", "url": "http://loki:3100", "access": "proxy", "isDefault": false}' \
|
|
| sed 's/^/ /'
|
|
echo
|
|
echo " created datasource 'Loki'"
|
|
fi
|
|
|
|
log "Importing Grafana dashboard"
|
|
GF_BODY=$(python3 -c '
|
|
import json, sys
|
|
with open("dashboards/grafana/Grafana-DMARC_Reports.json") as f:
|
|
d = json.load(f)
|
|
# Setting id=None lets Grafana create or replace by uid+overwrite.
|
|
d["id"] = None
|
|
print(json.dumps({"dashboard": d, "overwrite": True, "folderUid": ""}))
|
|
')
|
|
curl -sS -u "${GRAFANA_USER}:${GRAFANA_PASSWORD}" \
|
|
-H 'Content-Type: application/json' \
|
|
-X POST "http://localhost:3000/api/dashboards/db" \
|
|
-d "$GF_BODY" | sed 's/^/ /'
|
|
|
|
log "Importing Grafana PostgreSQL dashboard"
|
|
# Resolve the dashboard's ${DS_POSTGRESQL} input to the dmarc-pg datasource uid
|
|
# created above, drop the export-only __inputs/__requires keys, and let
|
|
# id=None create-or-replace by uid+overwrite.
|
|
GF_PG_BODY=$(python3 -c '
|
|
import json
|
|
with open("dashboards/grafana/Grafana-DMARC_Reports-PostgreSQL.json") as f:
|
|
text = f.read()
|
|
text = text.replace("${DS_POSTGRESQL}", "dmarc-pg")
|
|
d = json.loads(text)
|
|
d.pop("__inputs", None)
|
|
d.pop("__requires", None)
|
|
d["id"] = None
|
|
print(json.dumps({"dashboard": d, "overwrite": True, "folderUid": ""}))
|
|
')
|
|
curl -sS -u "${GRAFANA_USER}:${GRAFANA_PASSWORD}" \
|
|
-H 'Content-Type: application/json' \
|
|
-X POST "http://localhost:3000/api/dashboards/db" \
|
|
-d "$GF_PG_BODY" | sed 's/^/ /'
|
|
|
|
log "Importing Grafana Loki dashboard"
|
|
GF_LOKI_BODY=$(LOKI_DS_UID="$LOKI_DS_UID" python3 -c '
|
|
import json, os
|
|
with open("dashboards/grafana/Grafana-DMARC_Reports-Loki.json") as f:
|
|
text = f.read()
|
|
text = text.replace("${DS_LOKI}", os.environ["LOKI_DS_UID"])
|
|
d = json.loads(text)
|
|
d.pop("__inputs", None)
|
|
d.pop("__requires", None)
|
|
d["id"] = None
|
|
print(json.dumps({"dashboard": d, "overwrite": True, "folderUid": ""}))
|
|
')
|
|
curl -sS -u "${GRAFANA_USER}:${GRAFANA_PASSWORD}" \
|
|
-H 'Content-Type: application/json' \
|
|
-X POST "http://localhost:3000/api/dashboards/db" \
|
|
-d "$GF_LOKI_BODY" | sed 's/^/ /'
|
|
|
|
log "Importing Splunk dashboard views into the DMARC app"
|
|
splunk_import_view() {
|
|
local name="$1"
|
|
local file="$2"
|
|
# DELETE-then-POST is the only path that survives both first-run and
|
|
# re-run; POST to an existing view returns 409.
|
|
splunk_curl -X DELETE \
|
|
"https://localhost:8089/servicesNS/admin/DMARC/data/ui/views/${name}" \
|
|
>/dev/null 2>&1 || true
|
|
splunk_curl -X POST \
|
|
"https://localhost:8089/servicesNS/admin/DMARC/data/ui/views" \
|
|
-d "name=${name}" \
|
|
--data-urlencode "eai:data@-" \
|
|
< "$file" >/dev/null
|
|
echo " imported splunk view: ${name}"
|
|
}
|
|
|
|
splunk_import_view dmarc_aggregate dashboards/splunk/dmarc_aggregate_dashboard.xml
|
|
splunk_import_view dmarc_failure dashboards/splunk/dmarc_failure_dashboard.xml
|
|
splunk_import_view smtp_tls dashboards/splunk/smtp_tls_dashboard.xml
|
|
|
|
cat <<EOF
|
|
|
|
== Done. UIs available at:
|
|
Kibana http://localhost:5601/
|
|
OpenSearch Dashboards http://localhost:5602/ (admin / ${OPENSEARCH_INITIAL_ADMIN_PASSWORD})
|
|
Grafana http://localhost:3000/ (${GRAFANA_USER} / ${GRAFANA_PASSWORD})
|
|
Splunk http://localhost:8000/ (admin / ${SPLUNK_PASSWORD})
|
|
PostgreSQL localhost:5432 (${PG_USER} / ${PG_PASSWORD}, db ${PG_DB})
|
|
Loki http://localhost:3100/
|
|
EOF
|