#!/usr/bin/env python3 """Create Pi-hole daily reports and retain a short Deco identity history. Pi-hole is queried remotely over the user's existing SSH access. Deco snapshots and completed reports are stored locally in a separate SQLite database. No Pi-hole credentials or router session material is read or written here. """ from __future__ import annotations import argparse import json import os import shlex import sqlite3 import subprocess import sys import time as time_module from collections import defaultdict from datetime import date, datetime, time, timedelta, timezone from pathlib import Path from typing import Dict, List, Optional from zoneinfo import ZoneInfo TZ = ZoneInfo("America/New_York") DEFAULT_SERVER = "adolforeyna@192.168.68.110" DEFAULT_PIHOLE_DB = "/home/adolforeyna/pihole/etc-pihole/pihole-FTL.db" DEFAULT_STORAGE_DB = str(Path.home() / "brain/projects/network_usage/network_usage.sqlite3") BLOCKED_STATUSES = (1, 4, 5, 6, 7, 8, 9, 10, 11, 18) STATUS_NAMES = { 1: "gravity", 4: "regex", 5: "denylist", 6: "external-blocked-ip", 7: "external-blocked-null", 8: "external-blocked-nxra", 9: "gravity-cname", 10: "regex-cname", 11: "denylist-cname", 18: "external-blocked-ede15", } def parse_day(value: str, today: date) -> date: if value == "today": return today if value == "yesterday": return today - timedelta(days=1) return date.fromisoformat(value) def run_json(command: List[str], timeout: int) -> dict: proc = subprocess.run(command, capture_output=True, text=True, timeout=timeout) if proc.returncode: detail = proc.stderr.strip() or proc.stdout.strip() or "exit %s" % proc.returncode raise RuntimeError("command failed: %s" % detail[:500]) try: return json.loads(proc.stdout) except json.JSONDecodeError as exc: raise RuntimeError("command returned invalid JSON: %s" % exc) from exc def get_deco_clients(cli: str) -> List[dict]: payload = run_json([cli, "deco", "clients", "--json"], timeout=120) if not payload.get("ok"): raise RuntimeError("Deco client lookup failed: %s" % payload) return payload.get("result", {}).get("clients", []) def connect_storage(path: str) -> sqlite3.Connection: storage = Path(path).expanduser() storage.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(str(storage), timeout=10) conn.execute("PRAGMA foreign_keys=ON") conn.execute("PRAGMA busy_timeout=10000") conn.execute("PRAGMA journal_mode=WAL") conn.executescript( """ CREATE TABLE IF NOT EXISTS deco_snapshots ( snapshot_id INTEGER PRIMARY KEY AUTOINCREMENT, captured_at TEXT NOT NULL, captured_epoch INTEGER NOT NULL, client_count INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_deco_snapshots_epoch ON deco_snapshots(captured_epoch); CREATE TABLE IF NOT EXISTS deco_clients ( snapshot_id INTEGER NOT NULL REFERENCES deco_snapshots(snapshot_id) ON DELETE CASCADE, ip TEXT NOT NULL, mac TEXT, hostname TEXT, connection TEXT, interface TEXT, active INTEGER, linked_deco_mac TEXT, linked_deco_name TEXT, linked_deco_role TEXT, raw_json TEXT NOT NULL, PRIMARY KEY(snapshot_id, ip, mac) ); CREATE INDEX IF NOT EXISTS idx_deco_clients_ip ON deco_clients(ip); CREATE TABLE IF NOT EXISTS daily_reports ( report_day TEXT PRIMARY KEY, generated_at TEXT NOT NULL, report_json TEXT NOT NULL, report_markdown TEXT NOT NULL ); """ ) conn.commit() return conn def save_deco_snapshot(storage_path: str, clients: List[dict], captured: Optional[datetime] = None) -> dict: captured = captured or datetime.now(timezone.utc) captured_epoch = int(captured.timestamp()) captured_text = captured.isoformat() conn = connect_storage(storage_path) try: with conn: cur = conn.execute( "INSERT INTO deco_snapshots(captured_at,captured_epoch,client_count) VALUES(?,?,?)", (captured_text, captured_epoch, len(clients)), ) snapshot_id = cur.lastrowid rows = [] for client in clients: ip = str(client.get("ip") or "").strip() if not ip: continue rows.append( ( snapshot_id, ip, client.get("mac"), client.get("hostname"), client.get("connection"), client.get("interface"), 1 if client.get("active") else 0, client.get("linkedDecoMac"), client.get("linkedDecoName"), client.get("linkedDecoRole"), json.dumps(client, separators=(",", ":"), sort_keys=True), ) ) conn.executemany( """INSERT OR REPLACE INTO deco_clients (snapshot_id,ip,mac,hostname,connection,interface,active, linked_deco_mac,linked_deco_name,linked_deco_role,raw_json) VALUES(?,?,?,?,?,?,?,?,?,?,?)""", rows, ) return {"snapshot_id": snapshot_id, "captured_at": captured_text, "clients": len(rows)} finally: conn.close() def load_snapshot_map(storage_path: str, start_epoch: int, end_epoch: int) -> dict: """Return latest identity per IP observed during the report day. If an IP was associated with multiple MACs during the day, the latest record is selected but the mapping is explicitly marked ambiguous. """ conn = connect_storage(storage_path) try: rows = conn.execute( """SELECT s.captured_at,c.ip,c.mac,c.hostname,c.connection,c.interface, c.active,c.linked_deco_mac,c.linked_deco_name,c.linked_deco_role FROM deco_clients c JOIN deco_snapshots s ON s.snapshot_id=c.snapshot_id WHERE s.captured_epoch>=? AND s.captured_epoch 1: deco["mapping_status"] = "multiple_identities_seen" deco["mapping_identity_count"] = len(identities[ip]) return by_ip def prune_old_snapshots(storage_path: str, before_epoch: int) -> int: conn = connect_storage(storage_path) try: with conn: cur = conn.execute("DELETE FROM deco_snapshots WHERE captured_epoch None: conn = connect_storage(storage_path) try: with conn: conn.execute( """INSERT INTO daily_reports(report_day,generated_at,report_json,report_markdown) VALUES(?,?,?,?) ON CONFLICT(report_day) DO UPDATE SET generated_at=excluded.generated_at, report_json=excluded.report_json, report_markdown=excluded.report_markdown""", ( report["day"], report["generated_at"], json.dumps(report, indent=2), markdown_text, ), ) finally: conn.close() def get_pihole_data(server: str, db_path: str, start_epoch: int, end_epoch: int) -> dict: blocked = ",".join(str(x) for x in BLOCKED_STATUSES) remote = f'''import sqlite3,json p={db_path!r}; start={start_epoch}; end={end_epoch}; blocked=({blocked},) c=sqlite3.connect("file:"+p+"?mode=ro",uri=True) c.row_factory=sqlite3.Row base="""from query_storage q join domain_by_id d on d.id=q.domain join client_by_id cb on cb.id=q.client where q.timestamp>=? and q.timestamp=? and q.timestamp List[dict]: by_ip = dict(historical_map) for client in deco_clients: ip = str(client.get("ip") or "") if ip and ip not in by_ip: copy = dict(client) copy["mapping_status"] = "live_deco_lookup" by_ip[ip] = copy domains_by_ip = defaultdict(list) for row in pihole["domains"]: domains_by_ip[row["ip"]].append(row) result = [] for row in pihole["clients"]: ip = row["ip"] deco = by_ip.get(ip) visited = sorted( (x for x in domains_by_ip[ip] if not x["blocked"]), key=lambda x: (x["allowed"], x["queries"], x["domain"]), reverse=True, ) blocked = sorted( (x for x in domains_by_ip[ip] if x["blocked"]), key=lambda x: (x["blocked"], x["queries"], x["domain"]), reverse=True, ) result.append({ "ip": ip, "pihole_name": row["pihole_name"], "queries": row["queries"], "blocked_queries": row["blocked"], "block_rate_percent": round(100 * row["blocked"] / row["queries"], 2) if row["queries"] else 0, "unique_domains": row["unique_domains"], "last_query_epoch": row["last_query"], "deco": deco or { "hostname": None, "mac": None, "linkedDecoName": None, "active": False, "mapping_status": "not_in_snapshot_or_live_deco_list", }, "top_visited": visited[:10], "top_blocked": blocked[:10], }) return result def markdown(report: dict) -> str: lines = [ f"# Pi-hole DNS usage — {report['day']}", "", f"Window: {report['window']['start_local']} to {report['window']['end_local']} ({report['timezone']})", f"Generated: {report['generated_at']}", f"Pi-hole: {report['pihole']['server']} (FTL database, read-only)", f"Deco mapping source: {report['deco_mapping_source']}", "", "Blocked status codes counted: " + ", ".join(f"{k}={v}" for k, v in STATUS_NAMES.items()), "", "## Device summary", "", "| Device | IP | Deco/location | Queries | Blocked | Block rate | Top visited | Top blocked |", "|---|---|---|---:|---:|---:|---|---|", ] for d in report["devices"]: deco = d["deco"] label = deco.get("hostname") or d["pihole_name"] or "Unknown device" location = deco.get("linkedDecoName") or "not mapped" visited = d["top_visited"][0]["domain"] if d["top_visited"] else "—" blocked = d["top_blocked"][0]["domain"] if d["top_blocked"] else "—" lines.append(f"| {label} | {d['ip']} | {location} | {d['queries']:,} | {d['blocked_queries']:,} | {d['block_rate_percent']:.2f}% | `{visited}` | `{blocked}` |") lines += ["", "## Per-device detail", ""] for d in report["devices"]: deco = d["deco"] label = deco.get("hostname") or d["pihole_name"] or "Unknown device" location = deco.get("linkedDecoName") or "not mapped" lines += [ f"### {label} ({d['ip']})", f"Location: {location}; MAC: {deco.get('mac') or 'unknown'}; mapping: {deco.get('mapping_status', 'unknown')}.", f"Queries: {d['queries']:,}; blocked: {d['blocked_queries']:,}; block rate: {d['block_rate_percent']:.2f}%; unique domains: {d['unique_domains']:,}.", "", "Top visited (not blocked):", ] lines += [f"- {x['domain']} — {x['queries']:,}" for x in d["top_visited"]] or ["- none"] lines.append("Top blocked:") lines += [f"- {x['domain']} — {x['blocked']:,} blocked of {x['queries']:,}" for x in d["top_blocked"]] or ["- none"] lines.append("") return "\n".join(lines) def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--date", default="today", help="today, yesterday, or YYYY-MM-DD") parser.add_argument("--output-dir", default=str(Path.home() / "brain/projects/network_usage/reports")) parser.add_argument("--storage-db", default=os.environ.get("NETWORK_USAGE_DB", DEFAULT_STORAGE_DB)) parser.add_argument("--server", default=os.environ.get("PIHOLE_SSH", DEFAULT_SERVER)) parser.add_argument("--db-path", default=os.environ.get("PIHOLE_DB_PATH", DEFAULT_PIHOLE_DB)) parser.add_argument("--deco-cli", default=os.environ.get("REYNA_CLI", "reyna-cli")) parser.add_argument("--snapshot-only", action="store_true") parser.add_argument("--prune", action="store_true", help="after saving the report, remove snapshots before today") args = parser.parse_args() if args.snapshot_only: clients = get_deco_clients(args.deco_cli) result = save_deco_snapshot(args.storage_db, clients) print(json.dumps({"ok": True, "job": "deco_snapshot", "storage_db": args.storage_db, **result}, separators=(",", ":"))) return 0 today = datetime.now(TZ).date() day = parse_day(args.date, today) start_dt = datetime.combine(day, time.min, tzinfo=TZ) end_dt = start_dt + timedelta(days=1) now = datetime.now(TZ) start_epoch = int(start_dt.timestamp()) end_epoch = int(end_dt.timestamp()) data = get_pihole_data(args.server, args.db_path, start_epoch, end_epoch) historical_map = load_snapshot_map(args.storage_db, start_epoch, end_epoch) live_clients = [] if day == today or not historical_map: live_clients = get_deco_clients(args.deco_cli) devices = enrich(data, live_clients, historical_map) if historical_map: mapping_source = "historical Deco snapshots plus live fallback" else: mapping_source = "live Deco lookup" report = { "schema_version": 2, "day": day.isoformat(), "timezone": str(TZ), "generated_at": now.isoformat(), "deco_mapping_source": mapping_source, "window": { "start_local": start_dt.isoformat(), "end_local": end_dt.isoformat(), "start_epoch": start_epoch, "end_epoch": end_epoch, }, "pihole": { "server": args.server, "database": args.db_path, "blocked_statuses": list(BLOCKED_STATUSES), "status_names": STATUS_NAMES, "status_counts": data["statuses"], }, "storage": { "database": args.storage_db, "historical_snapshot_client_ips": len(historical_map), "live_deco_client_count": len(live_clients), }, "deco_live_client_count": len(live_clients), "devices": devices, } report_text = markdown(report) save_daily_report(args.storage_db, report, report_text) output_dir = Path(args.output_dir).expanduser() output_dir.mkdir(parents=True, exist_ok=True) json_path = output_dir / f"{day.isoformat()}.json" md_path = output_dir / f"{day.isoformat()}.md" json_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") md_path.write_text(report_text + "\n", encoding="utf-8") pruned = 0 if args.prune: current_start = int(datetime.combine(today, time.min, tzinfo=TZ).timestamp()) pruned = prune_old_snapshots(args.storage_db, current_start) print(json.dumps({"ok": True, "day": report["day"], "devices": len(devices), "historical_snapshot_ips": len(historical_map), "live_deco_clients": len(live_clients), "pruned_snapshots": pruned, "storage_db": args.storage_db, "json": str(json_path), "markdown": str(md_path)}, separators=(",", ":"))) return 0 if __name__ == "__main__": try: raise SystemExit(main()) except Exception as exc: print(json.dumps({"ok": False, "error": str(exc)})) raise SystemExit(1)