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 <daniel@pew.cc>
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 <daniel@pew.cc>
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<domain>([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')
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
SYMBOL INDEX (13 symbols across 1 files) FILE: update-zonefile.py function download_list (line 64) | def download_list(url): function check_domain (line 94) | def check_domain(domain, origin): function read_list (line 112) | def read_list(filename): function parse_lists (line 119) | def parse_lists(origin): function load_zone (line 166) | def load_zone(zonefile, origin, raw): function update_serial (line 210) | def update_serial(zone): function check_zone (line 218) | def check_zone(origin, zonefile): function rndc_reload (line 223) | def rndc_reload(cmd): function reload_zone (line 236) | def reload_zone(origin, views): function is_exe (line 245) | def is_exe(fpath): function compile_zone (line 248) | def compile_zone(source, target, origin, fromFormat, toFormat): function save_zone (line 254) | def save_zone(tmpzonefile, zonefile, origin, raw): function append_domain_to_zonefile (line 260) | def append_domain_to_zonefile(file, domain):
Condensed preview — 12 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (22K chars).
[
{
"path": ".gitignore",
"chars": 22,
"preview": "venv*\n.cache/*\n*.zone\n"
},
{
"path": "DOCKER.md",
"chars": 1293,
"preview": "# Docker\n\nDockerfile and docker-compose.yml provide alternative for installations and development\n\nSome tweaking is expe"
},
{
"path": "Dockerfile",
"chars": 422,
"preview": "FROM python:latest\n\nWORKDIR /root\n\nCOPY blocklist.txt .\nCOPY config.yml .\nCOPY update-zonefile.py .\nCOPY requirements.tx"
},
{
"path": "LICENSE",
"chars": 1088,
"preview": "MIT License\n\nCopyright (c) 2018 Daniel Triendl <daniel@pew.cc>\n\nPermission is hereby granted, free of charge, to any per"
},
{
"path": "Pipfile",
"chars": 213,
"preview": "[[source]]\nname = \"pypi\"\nurl = \"https://pypi.org/simple\"\nverify_ssl = true\n\n[dev-packages]\n\n[packages]\nrequests = \"*\"\npa"
},
{
"path": "README.md",
"chars": 3289,
"preview": "# BIND ad blocker\n\nFetch various blocklists and generate a BIND zone from them.\n\nConfigure BIND to return `NXDOMAIN` for"
},
{
"path": "blocklist.txt",
"chars": 128,
"preview": "# Stop Firefox from enabeling DoH. Since we are running an ad-blocking DNS \n# Server we want to use it.\nuse-application-"
},
{
"path": "config.yml",
"chars": 1510,
"preview": "# Blocklist download request timeout\nreq_timeout_s: 10\n\n# Also block *.domain.tld\nwildcard_block: False\n\n# Cache directo"
},
{
"path": "docker-compose.yml",
"chars": 205,
"preview": "---\nversion: \"3\"\n\nservices:\n updater:\n container_name: adblock\n build:\n context: .\n volumes:\n - 'bin"
},
{
"path": "example_root_crontab",
"chars": 1009,
"preview": "# This is an example crontab file, which assumes the user (e.g. root) \n# has no crontab file yet, at all. You may want "
},
{
"path": "requirements.txt",
"chars": 169,
"preview": "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\nrequ"
},
{
"path": "update-zonefile.py",
"chars": 10924,
"preview": "#!/usr/bin/env python3\n\n'''\nCopyright (c) 2018 Daniel Triendl <daniel@pew.cc>\n\nPermission is hereby granted, free of cha"
}
]
About this extraction
This page contains the full source code of the Trellmor/bind-adblock GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 12 files (19.8 KB), approximately 5.2k tokens, and a symbol index with 13 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.