#!/usr/bin/env python3 import argparse import logging import time from datetime import datetime from pathlib import Path import requests import urllib3 from watchdog.events import FileSystemEventHandler from watchdog.observers import Observer urllib3.disable_warnings() class WatchdogHandler(FileSystemEventHandler): def __init__(self, args): self.args = args def on_modified(self, event): if event.src_path == args.custom_path: file_modified(args) def file_modified(args): response = requests.get(args.opnsense + "/api/unbound/settings/searchHostOverride", auth=(args.api_key, args.api_secret), verify=not args.insecure) if response.status_code == 200: data = response.json() current_overrides = set([(x['server'], x['domain']) for x in data.get('rows', []) if x['description'] != 'SAVE']) current_overrides_uuid = {x['domain']: x for x in data.get('rows', [])} pihole_custom = set([(x[0], x[1]) for x in [y.split(' ') for y in Path(args.custom_path).read_text().split('\n')] if len(x) > 1]) to_remove = current_overrides - pihole_custom to_add = pihole_custom - current_overrides if not len(to_remove) and not len(to_add): logger.info('No changes.') return for item_ip, item_domain in to_remove: item_data = current_overrides_uuid[item_domain] p = requests.post(args.opnsense + f"/api/unbound/settings/delHostOverride/{item_data['uuid']}", auth=(args.api_key, args.api_secret), verify=not args.insecure) if p.status_code != 200: raise Exception(f'Failed to delete item {item_domain}: {p.status_code} - {p.text}') else: logger.info(f'Deleted: {item_domain}') now = datetime.now() dt_string = now.strftime("%m/%d/%Y %H:%M:%S") for item_ip, item_domain in to_add: p_data = { 'host': { 'enabled': '1', 'hostname': '', 'domain': item_domain, 'rr': 'A', 'mxprio': '', 'mx': '', 'server': item_ip, 'description': f'Synced from Pi-hole {dt_string}' } } p = requests.post(args.opnsense + "/api/unbound/settings/addHostOverride", json=p_data, auth=(args.api_key, args.api_secret), verify=not args.insecure) if p.status_code != 200: raise Exception(f'Failed to add item {item_domain}: {p.status_code} - {p.text}') else: logger.info(f'Added: {item_domain}') p_reload = requests.post(args.opnsense + "/api/unbound/service/reconfigure", auth=(args.api_key, args.api_secret), verify=not args.insecure) if p_reload.status_code != 200: raise Exception(f'Failed to reload service: {p_reload.status_code} - {p_reload.text}') else: logger.info(f'Unbound reloaded.') else: raise Exception(f"Failed to fetch host overrides: {response.status_code} - {response.text}") if __name__ == '__main__': parser = argparse.ArgumentParser(description='Sync custom DNS entries in Pi-hole to OPNsense Unbound.') parser.add_argument('--opnsense', required=True, help='Address of your OPNsense server. Example: https://192.168.1.1') parser.add_argument('--custom-path', default='/etc/pihole/custom.list', help="Path to Pi-hole's custom.list. Default: /etc/pihole/custom.list") parser.add_argument('--api-key', required=True, help='OPNsense API key.') parser.add_argument('--api-secret', required=True, help='OPNsense API secret.') parser.add_argument('--insecure', action='store_true', help="Don't verify SSL.") args = parser.parse_args() logging.basicConfig() logger = logging.getLogger('syncer') logger.setLevel(logging.INFO) logger.info('Running initial sync...') file_modified(args) event_handler = WatchdogHandler(args) observer = Observer() observer.schedule(event_handler, path='/etc/pihole/', recursive=False) observer.start() logger.info('Started watchdog.') try: while True: time.sleep(100) except KeyboardInterrupt: observer.stop() observer.join()