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