commit 1bfbbeca50213a4c2e2497b39ba5db0bf6978de2 Author: hmk Date: Thu Jul 16 11:04:38 2026 +0200 Initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..8c91f3a --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +# Pihole Scripts + +A collection of helper scripts for Pi-hole v6 that use the new Pi-hole API (`/api/...`) to automate common DHCP/DNS maintenance tasks. Both scripts authenticate against a local Pi-hole instance and require read (and, for the sync script, write) access to the Pi-hole configuration. + +## Requirements + +- Pi-hole v6 (uses the FTL REST API, not the legacy PHP admin API) +- Python 3 +- `requests` (`pip install requests`) +- A Pi-hole API password stored in a plain-text file (see "Password file" below) +- The Pi-hole TLS CA certificate available locally if your instance uses HTTPS with a self-signed/internal CA (scripts default to `/etc/pihole/tls_ca.crt` for certificate verification) + +### Password file + +Both scripts read the Pi-hole API password from a file instead of accepting it as a command-line argument or hardcoding it, to avoid leaking the password in shell history or process listings. + +``` +echo "your-api-password" > /etc/pihole/api_password +chmod 600 /etc/pihole/api_password +``` + +The default path is `/etc/pihole/api_password` and can be overridden per script with `-p/--password-file`. + +## Scripts + +### dhcp-leases.py + +Lists currently active non-static (dynamic) DHCP leases, i.e. leases handed out by Pi-hole's DHCP server that do not correspond to a configured static host mapping. + +The script authenticates, reads the configured static DHCP host mappings to determine which MAC addresses are static, then fetches the current lease table and filters out any lease whose MAC address matches a static mapping. + +Output formats: + +- Default: CSV (`ip,mac,hostname,expires`), machine-readable, raw unix timestamp for lease expiry. +- `-j` / `--json`: same data as a JSON array. +- `-t` / `--table`: aligned, human-readable table with lease expiry converted to local time. + +Options: + +- `-p, --password-file PATH` - path to the API password file (default: `/etc/pihole/api_password`) +- `-t, --table` - human-readable table output +- `-j, --json` - JSON output + +`-t` and `-j` are mutually exclusive; without either, output is CSV. + +### dhcp-dns-sync.py + +Synchronizes Pi-hole's static DHCP host mappings (MAC/IP/hostname entries configured under DHCP) into Pi-hole's local DNS host records, so that statically-assigned DHCP clients automatically get a matching DNS entry. + +Beyond a simple one-way copy, the script handles three additional cases: + +- **IP address changes**: if a hostname already exists in DNS but with a different IP than its current DHCP mapping, the old DNS entry is deleted and replaced with the updated one, rather than leaving both a stale and a new entry. +- **Removed DHCP entries**: if a static DHCP host mapping is deleted, its corresponding DNS entry is removed as well, keeping DNS from accumulating stale records. +- **Keep-list**: DNS entries that should always be preserved regardless of DHCP state (e.g. manually-managed hosts not tied to a static DHCP lease) can be listed in a keep file, referenced by their full `ip hostname` entry. Entries in the keep file are never removed, even if not present in DHCP. + +Keep file format (default path `/etc/pihole/dns_hosts_keep`), one full DNS entry per line, `#` for comments: + +``` +# entries to always keep in DNS regardless of DHCP state +192.168.1.10 nas.local +192.168.1.11 printer.local +``` + +All resulting DNS changes (additions, IP changes, removals) and unchanged/kept entries are computed first, sorted by IP address, and then applied and reported in that order. + +Output formats: + +- Default: CSV (`ip,hostname,status,old_ip`), one row per DHCP/DNS entry considered. `status` is one of `unchanged`, `new`, `ip changed`, `removed`, or `kept`. `old_ip` is populated only for `ip changed` rows. +- `-j` / `--json`: same data as a JSON array. +- `-t` / `--table`: aligned, human-readable table with the same columns. + +By default, output includes every entry considered (verbose behavior). Use `-s/--terse` to restrict output to rows that represent an actual change (`new`, `ip changed`, `removed`), omitting `unchanged` and `kept` rows. + +Detailed processing information (raw API responses, per-entry comparison decisions, HTTP call results for PUT/DELETE/restart operations) is written to stderr and only shown with `-d/--debug`, keeping stdout clean and parseable in default operation. + +Options: + +- `-p, --password-file PATH` - path to the API password file (default: `/etc/pihole/api_password`) +- `-k, --keep-file PATH` - path to the keep-list file (default: `/etc/pihole/dns_hosts_keep`) +- `-n, --dry-run` - compute and display changes without making any modifying API calls (no PUT/DELETE/DNS restart) +- `-s, --terse` - only output changed rows, omitting unchanged/kept rows +- `-d, --debug` - print detailed processing steps and raw API call results to stderr +- `-t, --table` - human-readable table output +- `-j, --json` - JSON output + +`-t` and `-j` are mutually exclusive; without either, output is CSV. + +## Notes + +- Both scripts log out of the Pi-hole API session (`DELETE /auth`) on exit, including when an error occurs partway through, so sessions are not left open. +- Neither script currently supports IPv6 DHCP/DNS entries; both are written and tested against IPv4 static leases and host mappings. +- These scripts modify live DNS/DHCP configuration (with the exception of `dhcp-leases.py`, which is read-only). Use `-n/--dry-run` on `dhcp-dns-sync.py` before running it unattended in a new environment. diff --git a/dhcp-dns-sync.py b/dhcp-dns-sync.py new file mode 100755 index 0000000..3a994d0 --- /dev/null +++ b/dhcp-dns-sync.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +import argparse +import csv +import ipaddress +import json +import sys +import requests +import re +import urllib.parse +from pathlib import Path + +api_url = "https://pi.hole/api" +verify = "/etc/pihole/tls_ca.crt" +default_password_file = "/etc/pihole/api_password" +default_keep_file = "/etc/pihole/dns_hosts_keep" + +parser = argparse.ArgumentParser(description="Sync static Pi-hole DHCP leases to local DNS entries.") +parser.add_argument("-p", "--password-file", default=default_password_file, + help=f"Path to file containing the Pi-hole API password (default: {default_password_file})") +parser.add_argument("-k", "--keep-file", default=default_keep_file, + help=f"Path to file listing full 'ip name' DNS entries to keep regardless of DHCP state (default: {default_keep_file})") +parser.add_argument("-n", "--dry-run", action="store_true", + help="Show what would change without making any API calls that modify state") +parser.add_argument("-s", "--terse", action="store_true", + help="Only output rows that represent an actual change (new, ip changed, removed) - omit unchanged/kept rows") +parser.add_argument("-d", "--debug", action="store_true", + help="Show detailed processing steps and raw API call results") +parser.add_argument("-t", "--table", action="store_true", + help="Print a human-readable aligned table instead of the default CSV") +parser.add_argument("-j", "--json", action="store_true", + help="Print output as JSON instead of the default CSV") +args = parser.parse_args() + +if args.table and args.json: + parser.error("--table and --json are mutually exclusive") + + +def read_password(path): + try: + return Path(path).read_text().strip() + except OSError as e: + raise SystemExit(f"Could not read password file '{path}': {e}") + + +def read_keep_list(path): + p = Path(path) + if not p.exists(): + return set() + entries = set() + for line in p.read_text().splitlines(): + line = line.strip() + if line and not line.startswith("#"): + entries.add(line) + return entries + + +def ip_sort_key(ip_str): + try: + return (0, ipaddress.ip_address(ip_str)) + except ValueError: + return (1, ip_str) + + +def debug(msg): + if args.debug: + print(f"DEBUG: {msg}", file=sys.stderr) + + +auth_payload = {"password": read_password(args.password_file)} +keep_entries = read_keep_list(args.keep_file) +hosts_changed = False + +debug(f"Password file: {args.password_file}") +debug(f"Keep file: {args.keep_file} ({len(keep_entries)} entries loaded)") + +with requests.post(f"{api_url}/auth", json=auth_payload, verify=verify) as auth: + debug(f"POST /auth -> {auth.status_code}") + auth_json = auth.json() + session = auth_json.get('session') + if not session or not session.get('sid'): + raise SystemExit(f"Authentication failed: {auth_json}") + + headers = { + "X-FTL-SID": session.get('sid'), + "X-FTL-CSRF": session.get('sid') + } + + try: + with requests.get(f"{api_url}/config", headers=headers, verify=verify) as config: + debug(f"GET /config -> {config.status_code}") + config_json = config.json() + + raw_dhcp_hosts = config_json.get('config').get('dhcp').get('hosts') + host_mappings = [] + for dhcp_host_mapping in raw_dhcp_hosts: + converted = re.sub("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2},(?P.*),(?P.*)", r"\g \g", dhcp_host_mapping) + debug(f"DHCP raw '{dhcp_host_mapping}' -> '{converted}'") + host_mappings.append(converted) + dhcp_names = {hm.split(" ", 1)[1] for hm in host_mappings} + + dns_host_mappings = config_json.get('config').get('dns').get('hosts') + dns_by_name = {} + for entry in dns_host_mappings: + parts = entry.split(" ", 1) + if len(parts) == 2: + ip, name = parts + dns_by_name[name] = (entry, ip) + else: + debug(f"Skipping malformed DNS entry: '{entry}'") + + # records: dicts with ip, hostname, status, old_ip (only set for "ip changed") + records = [] + actions = [] # (ip, fn) executed in IP order, fn performs the real API calls + + for host_mapping in host_mappings: + ip, name = host_mapping.split(" ", 1) + + if host_mapping in dns_host_mappings: + records.append({"ip": ip, "hostname": name, "status": "unchanged", "old_ip": ""}) + continue + + if name in dns_by_name: + old_entry, old_ip = dns_by_name[name] + records.append({"ip": ip, "hostname": name, "status": "ip changed", "old_ip": old_ip}) + else: + old_entry = None + records.append({"ip": ip, "hostname": name, "status": "new", "old_ip": ""}) + + def do_add_or_change(host_mapping=host_mapping, ip=ip, name=name, old_entry_ref=dns_by_name.get(name)): + global hosts_changed + hosts_changed = True + if old_entry_ref: + old_entry, old_ip = old_entry_ref + debug(f"IP change for '{name}': {old_ip} -> {ip}") + if not args.dry_run: + delete_url = f"{api_url}/config/dns/hosts/{urllib.parse.quote(old_entry)}" + with requests.delete(delete_url, headers=headers, verify=verify) as del_result: + debug(f"DELETE '{old_entry}': {del_result.status_code} - {del_result.reason}") + if not args.dry_run: + target_url = f"{api_url}/config/dns/hosts/{urllib.parse.quote(host_mapping)}" + with requests.put(target_url, headers=headers, verify=verify) as result: + debug(f"PUT '{host_mapping}': {result.status_code} - {result.reason}") + + actions.append((ip, do_add_or_change)) + + for name, (entry, ip) in dns_by_name.items(): + if name in dhcp_names: + continue + + if entry in keep_entries: + records.append({"ip": ip, "hostname": name, "status": "kept", "old_ip": ""}) + continue + + records.append({"ip": ip, "hostname": name, "status": "removed", "old_ip": ""}) + + def do_remove(entry=entry, ip=ip): + global hosts_changed + hosts_changed = True + debug(f"Removing '{entry}' (no longer in DHCP)") + if not args.dry_run: + delete_url = f"{api_url}/config/dns/hosts/{urllib.parse.quote(entry)}" + with requests.delete(delete_url, headers=headers, verify=verify) as del_result: + debug(f"DELETE '{entry}': {del_result.status_code} - {del_result.reason}") + + actions.append((ip, do_remove)) + + actions.sort(key=lambda item: ip_sort_key(item[0])) + debug(f"Executing {len(actions)} action(s) in IP order") + for _, action_fn in actions: + action_fn() + + if hosts_changed and not args.dry_run: + with requests.post(f"{api_url}/action/restartdns", headers=headers, verify=verify) as result: + debug(f"Restart DNS: {result.status_code} - {result.reason}") + elif hosts_changed: + debug("Would restart DNS") + + # --- Output --- + records.sort(key=lambda r: ip_sort_key(r["ip"])) + if args.terse: + records = [r for r in records if r["status"] not in ("unchanged", "kept")] + + if args.table: + if not records: + print("No changes." if args.terse else "No entries.") + else: + ip_w = max(len(r["ip"]) for r in records + [{"ip": "IP"}]) + name_w = max(len(r["hostname"]) for r in records + [{"hostname": "Hostname"}]) + status_w = max(len(r["status"]) for r in records + [{"status": "Status"}]) + print(f"{'IP':<{ip_w}} {'Hostname':<{name_w}} {'Status':<{status_w}} Old IP") + print(f"{'-' * ip_w} {'-' * name_w} {'-' * status_w} {'-' * 6}") + for r in records: + print(f"{r['ip']:<{ip_w}} {r['hostname']:<{name_w}} {r['status']:<{status_w}} {r['old_ip']}") + elif args.json: + print(json.dumps(records, indent=2)) + else: + writer = csv.DictWriter(sys.stdout, fieldnames=["ip", "hostname", "status", "old_ip"]) + writer.writeheader() + writer.writerows(records) + finally: + requests.delete(f"{api_url}/auth", headers=headers, verify=verify) + debug("Logout completed") + diff --git a/dynamic-leases.py b/dynamic-leases.py new file mode 100755 index 0000000..dd9e8a3 --- /dev/null +++ b/dynamic-leases.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +import argparse +import csv +import json +import sys +import requests +from datetime import datetime +from pathlib import Path + +api_url = "https://pi.hole/api" +verify = "/etc/pihole/tls_ca.crt" +default_password_file = "/etc/pihole/api_password" + +parser = argparse.ArgumentParser(description="List non-static (dynamic) Pi-hole DHCP leases.") +parser.add_argument("-p", "--password-file", default=default_password_file, + help=f"Path to file containing the Pi-hole API password (default: {default_password_file})") +parser.add_argument("-t", "--table", action="store_true", + help="Print a human-readable aligned table (with local time expiry)") +parser.add_argument("-j", "--json", action="store_true", + help="Print machine-readable output as JSON instead of the default CSV") +args = parser.parse_args() + +if args.table and args.json: + parser.error("--table and --json are mutually exclusive") + + +def read_password(path): + try: + return Path(path).read_text().strip() + except OSError as e: + raise SystemExit(f"Could not read password file '{path}': {e}") + + +def format_expiry_human(value): + try: + return datetime.fromtimestamp(int(value)).strftime("%Y-%m-%d %H:%M:%S") + except (TypeError, ValueError): + return str(value) + + +auth_payload = {"password": read_password(args.password_file)} + +with requests.post(f"{api_url}/auth", json=auth_payload, verify=verify) as auth: + auth_json = auth.json() + session = auth_json.get('session') + if not session or not session.get('sid'): + raise SystemExit(f"Authentication failed: {auth_json}") + + headers = { + "X-FTL-SID": session.get('sid'), + "X-FTL-CSRF": session.get('sid') + } + + try: + # Get static DHCP host mappings to know which MACs are static + with requests.get(f"{api_url}/config", headers=headers, verify=verify) as config: + config_json = config.json() + static_macs = set() + for entry in config_json.get('config').get('dhcp').get('hosts'): + mac = entry.split(",")[0].lower() + static_macs.add(mac) + + # Get current active DHCP leases + with requests.get(f"{api_url}/dhcp/leases", headers=headers, verify=verify) as leases_resp: + leases_json = leases_resp.json() + leases = leases_json.get('leases', []) + + non_static = [l for l in leases if l.get('hwaddr', '').lower() not in static_macs] + + records = [] + for lease in non_static: + records.append({ + "ip": lease.get('ip', '-'), + "mac": lease.get('hwaddr', '-'), + "hostname": lease.get('name') or '-', + "expires": lease.get('expires', '-'), + }) + + if args.table: + if not records: + print("No non-static (dynamic) leases found.") + else: + rows = [(r["ip"], r["mac"], r["hostname"], format_expiry_human(r["expires"])) for r in records] + ip_w = max(len(r[0]) for r in rows + [("IP", "", "", "")]) + mac_w = max(len(r[1]) for r in rows + [("", "MAC", "", "")]) + name_w = max(len(r[2]) for r in rows + [("", "", "Hostname", "")]) + + print(f"{'IP':<{ip_w}} {'MAC':<{mac_w}} {'Hostname':<{name_w}} Expires (local time)") + print(f"{'-' * ip_w} {'-' * mac_w} {'-' * name_w} {'-' * 19}") + for ip, mac, name, expires in rows: + print(f"{ip:<{ip_w}} {mac:<{mac_w}} {name:<{name_w}} {expires}") + + elif args.json: + print(json.dumps(records, indent=2)) + + else: + # Default: machine-readable CSV, raw (unix timestamp) expiry + writer = csv.DictWriter(sys.stdout, fieldnames=["ip", "mac", "hostname", "expires"]) + writer.writeheader() + writer.writerows(records) + finally: + requests.delete(f"{api_url}/auth", headers=headers, verify=verify) +