Repository: Trellmor/bind-adblock Branch: master Commit: c621ce83e89d Files: 12 Total size: 19.8 KB Directory structure: gitextract_2f3ac_vy/ ├── .gitignore ├── DOCKER.md ├── Dockerfile ├── LICENSE ├── Pipfile ├── README.md ├── blocklist.txt ├── config.yml ├── docker-compose.yml ├── example_root_crontab ├── requirements.txt └── update-zonefile.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ venv* .cache/* *.zone ================================================ FILE: DOCKER.md ================================================ # Docker Dockerfile and docker-compose.yml provide alternative for installations and development Some tweaking is expected to suit a production deployment ## Build ```shell docker-compose build ``` ## AdBlock Zone The example container writes the generated rpz-adblocker.zone file under /bind-adblock Docker-compose file has an options for using either a volume (i.e containerized bind9 integration) or host mount point (i.e. bind9 running on the docker host os) for that path. ## Deploy ```shell docker-compose up -d ``` ## Running Considerations ### Cron The current image runs the python script at startup and then exits. There is no cron scheduler provided at this time. ### On Demand Once deployed, issuing a run will start the container again, run the update script, then exit after writing the zone file. ```shell docker run bind-adblock_updater ``` ### Bind9 Zone reloads You might wish to combine bind-adblock and bind9 into the same container in which a cron service controls executing this python script and in turn can trigger named to reload the zone. An aggressive workaround is start the adblock updater container then fully restart the bind9 container. ```shell docker-compose up updater >/dev/null 2>&1 && docker-compose restart bind9 >/dev/null 2>&1 ``` ================================================ FILE: Dockerfile ================================================ FROM python:latest WORKDIR /root COPY blocklist.txt . COPY config.yml . COPY update-zonefile.py . COPY requirements.txt . ENV VIRTUAL_ENV=/opt/venv RUN python3 -m venv $VIRTUAL_ENV ENV PATH="$VIRTUAL_ENV/bin:$PATH" RUN pip install --upgrade pip && \ pip install -r requirements.txt RUN mkdir /bind-adblock CMD ["python3", "./update-zonefile.py", "--no-bind", "/bind-adblock/rpz-adblocker.zone", "rpz.adblocker"] ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2018 Daniel Triendl Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Pipfile ================================================ [[source]] name = "pypi" url = "https://pypi.org/simple" verify_ssl = true [dev-packages] [packages] requests = "*" pathlib = "*" dnspython = "*" pyyaml = "*" validators = "*" [requires] python_version = "3.9" ================================================ FILE: README.md ================================================ # BIND ad blocker Fetch various blocklists and generate a BIND zone from them. Configure BIND to return `NXDOMAIN` for ad and tracking domains to stop clients from contacting them. Requires BIND 9.8 or newer for [RPZ](https://en.wikipedia.org/wiki/Response_policy_zone) support. Uses the following sources: * [Peter Lowe’s Ad and tracking server list](https://pgl.yoyo.org/adservers/) * [MVPS HOSTS](http://winhelp2002.mvps.org/) * [Adaway default blocklist](https://adaway.org/hosts.txt) * [Dan Pollock’s hosts file](http://someonewhocares.org/hosts/zero/) * [MalwareDomainList.com Hosts List](http://www.malwaredomainlist.com/hostslist/hosts.txt) * [StevenBlack Unified hosts file](https://github.com/StevenBlack/hosts) * [CAMELEON](http://sysctl.org/cameleon/) * [Disconnect.me Basic tracking list](https://disconnect.me/trackerprotection) * [Disconnect.me Ad Filter list](https://disconnect.me/trackerprotection) * [Polish CERT Phishing list](https://www.cert.pl/ostrzezenia_phishing/) ## Setup ### Python packages See [requirements.txt](requirements.txt) To install ``` python3 -m venv venv source venv/bin/activate pip install --upgrade pip pip install -r requirements.txt ``` ### Configure BIND Add the `response-policy` statement to the BIND options ``` // For AdBlock response-policy { zone "rpz.example.com"; }; ``` Add your rpz zone. Replace example.com with a domain of your choice. ``` // AdBlock zone "rpz.example.com" { type master; file "/etc/bind/db.rpz.example.com"; masterfile-format text; allow-query { none; }; }; ``` Create a zone file for your zone. Replace example.com with the domain you used before. ``` @ 3600 IN SOA @ admin.example.com. 0 86400 7200 2592000 86400 @ 3600 IN NS ns.example.com. ``` ## Usage usage: update-zonefile.py [-h] [--no-bind] [--raw] [--empty] zonefile origin Update zone file from public DNS ad blocking lists positional arguments: zonefile path to zone file origin zone origin optional arguments: -h, --help show this help message and exit --no-bind Don't try to check/reload bind zone --raw Save the zone file in raw format. Requires named-compilezone --empty Create header-only (empty) rpz zone file --views If using multiple BIND views, list where each zone is defined Example: `update-zonefile.py /etc/bind/db.rpz.example.com rpz.example.com` `update-zonefile.py` will update the zone file with the fetched adserver lists and issue a `rndc reload origin` afterwards. ### Multiple BIND Views If you defined the adblock rpz across multiple BIND views, then you will need to pass --views a space separated list of which views the zone is defined. Doing so will issue 'rndc reload origin IN view' for each view provided for the origin zone. ```shell --views "internal dmz test" ``` This argument can be omitted if the origin zone only occurs once in your configuration. The following error is an indication you are using the rpz zone multiple views. ```text zone 'rpz.adblocker' was found in multiple views ``` ## Whitelist You can either use an additional zone to whitelist domains (Or add them to `config.yml`) See [Whitelist](https://github.com/Trellmor/bind-adblock/wiki/whitelist) for adding a whitelist zone. ================================================ FILE: blocklist.txt ================================================ # Stop Firefox from enabeling DoH. Since we are running an ad-blocking DNS # Server we want to use it. use-application-dns.net ================================================ FILE: config.yml ================================================ # Blocklist download request timeout req_timeout_s: 10 # Also block *.domain.tld wildcard_block: False # Cache directory cache: '.cache/bind_adblock' # Can be either NULL or NXDOMAIN blocking_mode: 'NXDOMAIN' # Blocklists # List of blocklists # Format: # url: Link to blocklist # file: Blocklist file # format: domain or hosts, default: domain lists: - file: 'blocklist.txt' - url: 'https://pgl.yoyo.org/as/serverlist.php?hostformat=nohtml&showintro=0' - url: 'http://mirror1.malwaredomains.com/files/justdomains' - url: 'http://winhelp2002.mvps.org/hosts.txt' format: hosts - url: 'https://adaway.org/hosts.txt' format: hosts - url: 'https://www.someonewhocares.org/hosts/zero/hosts' format: hosts # adlists from pi-hole: https://github.com/pi-hole/pi-hole/blob/master/adlists.default # # The below list amalgamates several lists we used previously. # See `https://github.com/StevenBlack/hosts` for details # StevenBlack's list - url: 'https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts' format: hosts # Cameleon - url: 'http://sysctl.org/cameleon/hosts' format: hosts # Disconnect.me Tracking - url: 'https://s3.amazonaws.com/lists.disconnect.me/simple_tracking.txt' # Disconnect.me Ads - url: 'https://s3.amazonaws.com/lists.disconnect.me/simple_ad.txt' # Polish CERT - https://www.cert.pl/ostrzezenia_phishing/ - url: 'https://hole.cert.pl/domains/domains.txt' # Don't block domains listed here domain_whitelist: [] ================================================ FILE: docker-compose.yml ================================================ --- version: "3" services: updater: container_name: adblock build: context: . volumes: - 'bind9-zones:/bind-adblock' # - '/var/bind:/bind-adblock' volumes: bind9-zones: ================================================ FILE: example_root_crontab ================================================ # This is an example crontab file, which assumes the user (e.g. root) # has no crontab file yet, at all. You may want to do: # 'crontab -l' to validate that assumption... # if valid, feel free to edit values below, and then run # 'crontab example_crontab_file' # if invalid, you'll need to merge at least the last line into your # current crontab file # # use /bin/bash to run commands, no matter what /etc/passwd says SHELL=/bin/bash # mail any output to user `nick', no matter whose crontab this is # nick is an example username...replace with your prefered recipient MAILTO=nick CRON_TZ=EST5EDT PATH=/usr/share/Modules/bin:/usr/lib64/ccache:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin # run five minutes after 2am, every day # assumes your rpz domain file is /etc/bin/db.rpz.example.com and # your rpz domain is named rpz.example.com in your BIND config 5 2 * * * (cd /path/to/bind-adblock/bind-adblock-master; python3 ./update-zonefile.py /etc/bind/db.rpz.example.com rpz.example.com) 2>&1 ================================================ FILE: requirements.txt ================================================ certifi==2022.12.7 charset-normalizer==3.0.1 decorator==5.1.1 dnspython==2.3.0 idna==3.4 pathlib==1.0.1 PyYAML==6.0 requests==2.28.2 urllib3==1.26.14 validators==0.20.0 ================================================ FILE: update-zonefile.py ================================================ #!/usr/bin/env python3 ''' Copyright (c) 2018 Daniel Triendl Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ''' import requests from pathlib import Path from datetime import datetime import email.utils as eut import os import hashlib import re import sys import dns.zone import dns.name import dns.version from dns.exception import DNSException import subprocess import textwrap import shutil from argparse import ArgumentParser import yaml import validators config = { # Blocklist download request timeout 'req_timeout_s': 10, # Also block *.domain.tld 'wildcard_block': False, # Cache directory 'cache': Path(os.path.dirname(os.path.realpath(__file__)), ) } parent_dir = os.path.dirname(os.path.realpath(__file__)) main_conf_file = os.path.join(parent_dir, 'config.yml') config = yaml.safe_load(open(main_conf_file)) config['cache'] = Path(config['cache']) if not config['cache'].is_absolute(): config['cache'] = Path(parent_dir, config['cache']) regex_domain = '^(127|0)\\.0\\.0\\.(0|1)[\\s\\t]+(?P([a-z0-9\\-_]+\\.)+[a-z][a-z0-9_-]*)$' regex_no_comment = '^#.*|^$' regex_no_comment_in_line = '^([^#]+)' def download_list(url): headers = None cache = Path(config['cache'], hashlib.sha1(url.encode()).hexdigest()) if cache.is_file(): last_modified = datetime.utcfromtimestamp(cache.stat().st_mtime) headers = { 'If-modified-since': eut.format_datetime(last_modified), 'User-Agent': 'Bind adblock zonfile updater v1.0 (https://github.com/Trellmor/bind-adblock)' } try: r = requests.get(url, headers=headers, timeout=config['req_timeout_s']) if r.status_code == 200: with cache.open('w', encoding='utf8') as f: f.write(r.text) if 'last-modified' in r.headers: last_modified = eut.parsedate_to_datetime(r.headers['last-modified']).timestamp() os.utime(str(cache), times=(last_modified, last_modified)) return r.text except requests.exceptions.RequestException as e: print(e) if cache.is_file(): with cache.open('r', encoding='utf8') as f: return f.read() def check_domain(domain, origin): if domain == '': return False if config['wildcard_block']: domain = '*.' + domain try: name = dns.name.from_text(domain, origin) except DNSException as e: return False if not validators.domain(domain): print('Ignoring invalid domain {}'.format(domain)) return False return True def read_list(filename): path = Path(filename) if path.exists: with path.open('r', encoding='utf8') as f: return f.read() def parse_lists(origin): domains = set() origin_name = dns.name.from_text(origin) for l in config['lists']: data = None if 'url' in l: print(l['url']) data = download_list(l['url']) elif 'file' in l: print(l['file']) data = read_list(l['file']) if data: lines = data.splitlines() print("\t{} lines".format(len(lines))) c = len(domains) for line in data.splitlines(): domain = '' if re.match(regex_no_comment, line): continue m = re.search(regex_no_comment_in_line, line) if m: line = m.group(1).strip() if line == '': continue if l.get('format', 'domain') == 'hosts': m = re.match(regex_domain, line) if m: domain = m.group('domain') else: domain = line domain = domain.strip() if check_domain(domain, origin_name): domains.add(domain) print("\t{} domains".format(len(domains) - c)) print("\nTotal\n\t{} domains".format(len(domains))) return domains def load_zone(zonefile, origin, raw): zone_text = '' path = Path(zonefile) tmpPath = Path(config['cache'], 'tempzone') if not path.exists(): with tmpPath.open('w') as f: f.write('@ 3600 IN SOA @ admin.{}. 0 86400 7200 2592000 86400\n@ 3600 IN NS LOCALHOST.'.format(origin)) save_zone(tmpPath, zonefile, origin, raw) print(textwrap.dedent('''\ Zone file "{0}" created. Add BIND options entry: response-policy {{ zone "{1}"; }}; Add BIND zone entry: zone "{1}" {{ type master; file "{0}"; masterfile-format {2}; allow-query {{ none; }}; }}; ''').format(path.resolve(), origin, 'raw' if raw else 'text')) if raw: try: compile_zone(zonefile, tmpPath, origin, 'raw', 'text') path = tmpPath except: pass with path.open('r') as f: for line in f: zone_text += line if "IN NS" in line: break return dns.zone.from_text(zone_text, origin) def update_serial(zone): soaset = zone.get_rdataset('@', dns.rdatatype.SOA) soa = soaset[0] if dns.version.MAJOR < 2: soa.serial += 1 else: soaset.add(soa.replace(serial=soa.serial + 1)) def check_zone(origin, zonefile): cmd = ['named-checkzone', '-q', origin, str(zonefile)] r = subprocess.call(cmd) return r == 0 def rndc_reload(cmd): try: r = subprocess.check_output(cmd, stderr=subprocess.PIPE) except subprocess.CalledProcessError as e: print( '{}'.format( e.stderr.decode(sys.getfilesystemencoding()) ) ) if "multiple" in e.stderr.decode('utf-8'): sys.exit('Please pass --views the list of configured BIND views containing origin zone.') if e.returncode != 0: sys.exit('rndc failed with return code {}'.format(e.returncode)) print( '{}'.format( r.decode(sys.getfilesystemencoding()) ) ) def reload_zone(origin, views): if views: for v in views.split(): print ("view {}, {} ".format(v, origin), end='', flush=True) rndc_reload( ['rndc', 'reload', origin, "IN", v] ) else: print ("{} ".format(origin), end='', flush=True) rndc_reload( ['rndc', 'reload', origin] ) def is_exe(fpath): return os.path.isfile(fpath) and os.access(fpath, os.X_OK) def compile_zone(source, target, origin, fromFormat, toFormat): cmd = ['named-compilezone', '-f', fromFormat, '-F', toFormat, '-o', str(target), origin, str(source)] r = subprocess.call(cmd) if r != 0: raise Exception('named-compilezone failed with return code {}'.format(r)) def save_zone(tmpzonefile, zonefile, origin, raw): if raw: compile_zone(tmpzonefile, zonefile, origin, 'text', 'raw') else: shutil.move(str(tmpzonefile), str(zonefile)) def append_domain_to_zonefile(file, domain): if config['blocking_mode'] == 'NXDOMAIN' or "_" in domain: file.write(domain + ' IN CNAME .\n') else: file.write(domain + ' IN A 0.0.0.0\n') file.write(domain + ' IN AAAA ::\n') if __name__ == '__main__': parser = ArgumentParser(description='Update zone file from public DNS ad blocking lists') parser.add_argument('--no-bind', dest='no_bind', action='store_true', help='Don\'t try to check/reload bind zone') parser.add_argument('--raw', dest='raw_zone', action='store_true', help='Save the zone file in raw format. Requires named-compilezone') parser.add_argument('--empty', dest='empty', action='store_true', help='Create header-only (empty) rpz zone file') parser.add_argument('--views', dest='views', type=str, help='If using multiple BIND views, list where each zone is defined') parser.add_argument('zonefile', help='path to zone file') parser.add_argument('origin', help='zone origin') args = parser.parse_args() os.chdir(os.path.dirname(os.path.realpath(__file__))) if not config['cache'].is_dir(): config['cache'].mkdir(parents=True) zone = load_zone(args.zonefile, args.origin, args.raw_zone) update_serial(zone) if args.empty: domains = set() else: domains = parse_lists(args.origin) tmpzonefile = Path(config['cache'], 'tempzone') zone.to_file(str(tmpzonefile)) with tmpzonefile.open('a') as f: for d in (sorted(domains)): if d in config['domain_whitelist']: continue append_domain_to_zonefile(f, d) if config['wildcard_block']: append_domain_to_zonefile(f, '*.' + d) if args.no_bind: save_zone(tmpzonefile, args.zonefile, args.origin, args.raw_zone) else: if check_zone(args.origin, tmpzonefile): save_zone(tmpzonefile, args.zonefile, args.origin, args.raw_zone) if is_exe('/usr/sbin/getenforce'): cmd = ['/usr/sbin/getenforce'] r = subprocess.check_output(cmd).strip() print('SELinux getenforce output / Current State is: ',r) if r == b'Enforcing': print('SELinux restorecon being run to reset MAC security context on zone file') if is_exe('/sbin/restorecon'): cmd = ['/sbin/restorecon', '-F', args.zonefile] r = subprocess.call(cmd) if r != 0: raise Exception('Cannot run selinux restorecon on the zonefile - return code {}'.format(r)) reload_zone(args.origin, args.views) else: print('Zone file invalid, not loading')