Initial commit

This commit is contained in:
hmk
2026-07-16 11:04:38 +02:00
commit 1bfbbeca50
3 changed files with 398 additions and 0 deletions
+92
View File
@@ -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.
+203
View File
@@ -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<ip>.*),(?P<name>.*)", r"\g<ip> \g<name>", 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")
+103
View File
@@ -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)