├── .gitignore ├── README.md ├── UNLICENSE ├── settings.py.example └── sync.py /.gitignore: -------------------------------------------------------------------------------- 1 | settings.py 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | If you have multiple devices running [Pi-hole](https://pi-hole.net), keeping whitelists and blacklists in sync across devices can be a chore. 4 | 5 | pihole-sync helps keep those lists in sync across your devices. 6 | 7 | Note: pihole-sync syncs whitelists and blacklists: the ones you see in the Whitelist and Blacklist tabs in the Pi-hole admin page. **It doesn't sync blocklists** (the URLs of lists of domains configured in Settings > Blocklists). 8 | 9 | # Setup 10 | 11 | ``` 12 | cp settings.py.example settings.py 13 | ``` 14 | 15 | Now edit settings.py and add details for each of the devices on your network 16 | that are running Pi-hole. 17 | 18 | # Usage 19 | 20 | ```sh 21 | python3 sync.py 22 | ``` 23 | -------------------------------------------------------------------------------- /UNLICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /settings.py.example: -------------------------------------------------------------------------------- 1 | HOST_CONFIGS = [ 2 | # Add one entry for each host you have running Pi-Hole 3 | { 4 | "address": "192.168.1.4", 5 | # webpassword is the value of the WEBPASSWORD config variable in 6 | # /etc/pihole/setupVars.php. You can get it with this one-liner: 7 | # ssh 192.168.1.4 cat /etc/pihole/setupVars.conf | grep WEBPASSWORD | cut -d= -f2 8 | "webpassword": "" 9 | }, 10 | { 11 | "address": "192.168.1.5", 12 | "webpassword": "" 13 | } 14 | ] -------------------------------------------------------------------------------- /sync.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import json 4 | import sys 5 | 6 | from enum import IntEnum 7 | from http.client import HTTPResponse 8 | from urllib.parse import urlencode, urlunparse, ParseResult 9 | from urllib.request import urlopen 10 | 11 | class ListType(IntEnum): 12 | WHITELIST = 1 13 | BLACKLIST_EXACT = 2 14 | BLACKLIST_REGEX = 3 15 | 16 | class Host: 17 | address = "" 18 | webpassword = "" 19 | _list_cache = {} 20 | 21 | def __init__(self, address, webpassword): 22 | self.address = address 23 | self.webpassword = webpassword 24 | self._list_cache = {} 25 | 26 | def __str__(self): 27 | return self.address 28 | 29 | def _api_call(self, query_params={}, auth=False): 30 | """ 31 | Makes a call to the PHP API (/admin/api.php) with the specified query 32 | params. If auth is True, the call is authenticated with the host's 33 | webpassword. 34 | """ 35 | if auth: 36 | query_params['auth'] = self.webpassword 37 | components = ParseResult( 38 | scheme = 'http', 39 | netloc = self.address, 40 | path = '/admin/api.php', 41 | params = '', 42 | query = urlencode(query_params), 43 | fragment = '' 44 | ) 45 | url = urlunparse(components) 46 | return urlopen(url) 47 | 48 | def get_list(self, list_type): 49 | """ 50 | Returns the contents of the specified list 51 | """ 52 | try: 53 | return self._list_cache[list_type] 54 | except KeyError: 55 | list_api_arg = "black" 56 | if list_type == ListType.WHITELIST: 57 | list_api_arg = "white" 58 | response = self._api_call({"list": list_api_arg}, auth=True) 59 | 60 | # Parse the results 61 | results = json.loads(response.read().decode()) 62 | 63 | # Cache the results 64 | if list_type == ListType.WHITELIST: 65 | self._list_cache[ListType.WHITELIST] = results[0] 66 | else: 67 | # Calling the API with list=black returns both blacklists in the 68 | # same response. Cache both to save us another round trip later. 69 | self._list_cache[ListType.BLACKLIST_EXACT] = results[0] 70 | self._list_cache[ListType.BLACKLIST_REGEX] = results[1] 71 | 72 | return self._list_cache[list_type] 73 | 74 | def add_list_entry(self, entry, list_type): 75 | """ 76 | Adds an entry to the specified list 77 | """ 78 | # Invalidate the cache 79 | try: 80 | del self._list_cache[list_type] 81 | except KeyError: 82 | pass 83 | 84 | print("+ Adding {} to {} on {}".format( 85 | entry, 86 | list_type.name, 87 | self.address 88 | )) 89 | 90 | # NB: when fetching lists, the list arg is 'black' or 'white', and 91 | # 'black' returns the exact and regex blacklists in one result. But when 92 | # setting lists, the list arg is 'black', 'white' or 'regex' (or a 93 | # couple of others we don't use). See 94 | # https://github.com/pi-hole/AdminLTE/blob/master/scripts/pi-hole/php/add.php 95 | list_api_arg = "" 96 | if list_type == ListType.WHITELIST: 97 | list_api_arg = "white" 98 | elif list_type == ListType.BLACKLIST_EXACT: 99 | list_api_arg = "black" 100 | else: 101 | list_api_arg = "regex" 102 | self._api_call({ "list": list_api_arg, "add": entry }, auth=True) 103 | 104 | def load_hosts_from_config(): 105 | """ 106 | Gets a set of Host objects based on the config in settings.py 107 | """ 108 | try: 109 | from settings import HOST_CONFIGS 110 | except ImportError: 111 | sys.stderr.write( 112 | "Error: cound't import HOST_CONFIGS from settings. " 113 | "Have you created your settings.py file?\n" 114 | ) 115 | sys.exit(1) 116 | 117 | hosts = [] 118 | for hc in HOST_CONFIGS: 119 | hosts.append(Host(hc["address"], hc["webpassword"])) 120 | return set(hosts) 121 | 122 | def _sync_list(hosts, list_type): 123 | """ 124 | Syncs the list of the specified type between all hosts. All hosts will end 125 | up with the union of all lists of this type. 126 | 127 | For example: in the scenario where you have two hosts which have the 128 | following whitelists: 129 | 130 | Host 1: ["foo.com", "bar.com"] 131 | Host 2: ["bar.com", "wibble.com"] 132 | 133 | After running `_sync_hosts` with `list_type: ListType.WHITELIST`, both hosts 134 | will have the following whitelist: 135 | 136 | ["foo.com", "bar.com", "wibble.com"] 137 | """ 138 | sync_count = 0 139 | 140 | # First, build a dictionary mapping entries to a set of all the host(s) that 141 | # already have that entry in the appropriate list. 142 | entries_per_host = {} 143 | for host in hosts: 144 | lst = host.get_list(list_type) 145 | for entry in lst: 146 | try: 147 | entries_per_host[entry].add(host) 148 | except KeyError: 149 | entries_per_host[entry] = set([host]) 150 | 151 | # Now step through all the entries we found, adding them to any hosts that 152 | # don't already have them. 153 | for entry in entries_per_host: 154 | # Get the hosts who already have this entry 155 | hosts_with_entry = entries_per_host[entry] 156 | if len(hosts_with_entry) == len(hosts): 157 | # All hosts have this entry: nothing to do 158 | continue 159 | sync_count += 1 160 | 161 | # The hosts without the entry is the set of all hosts minus the set of 162 | # hosts that do have the entry. 163 | hosts_without_entry = hosts - hosts_with_entry 164 | 165 | # For each of those hosts, go ahead and add the entry 166 | for h in hosts_without_entry: 167 | h.add_list_entry(entry, list_type) 168 | return sync_count 169 | 170 | def sync_lists(hosts): 171 | """ 172 | Calls _sync_list for each list type. See that function for a discussion of 173 | the logic involved. 174 | """ 175 | for list_type in ListType: 176 | sync_count = _sync_list(hosts, list_type) 177 | print("{}: {} item(s) synced between hosts".format( 178 | list_type.name, 179 | sync_count 180 | )) 181 | 182 | if __name__ == "__main__": 183 | hosts = load_hosts_from_config() 184 | 185 | sync_lists(hosts) 186 | --------------------------------------------------------------------------------