├── .editorconfig ├── .gitignore ├── .idea ├── DoHVerifier.iml ├── misc.xml ├── modules.xml └── vcs.xml ├── .travis.yml ├── .vscode ├── launch.json └── settings.json ├── README.md ├── doh_verifier.py ├── libs ├── __init__.py └── log │ └── __init__.py ├── logging.conf └── requirements.txt /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org/ 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | end_of_line = lf 11 | charset = utf-8 12 | 13 | # Docstrings and comments use max_line_length = 79 14 | [*.py] 15 | max_line_length = 119 16 | 17 | # Use 2 spaces for the HTML files 18 | [*.html] 19 | indent_size = 2 20 | 21 | # The JSON files contain newlines inconsistently 22 | [*.json] 23 | indent_size = 2 24 | insert_final_newline = ignore 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | *~ 3 | .fuse_hidden* 4 | .directory 5 | .Trash-* 6 | .nfs* 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | *.so 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | pip-wheel-metadata/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | *.manifest 31 | *.spec 32 | pip-log.txt 33 | pip-delete-this-directory.txt 34 | htmlcov/ 35 | .tox/ 36 | .nox/ 37 | .coverage 38 | .coverage.* 39 | .cache 40 | nosetests.xml 41 | coverage.xml 42 | *.cover 43 | .hypothesis/ 44 | .pytest_cache/ 45 | *.mo 46 | *.pot 47 | *.log 48 | local_settings.py 49 | db.sqlite3 50 | instance/ 51 | .webassets-cache 52 | .scrapy 53 | docs/_build/ 54 | target/ 55 | .ipynb_checkpoints 56 | profile_default/ 57 | ipython_config.py 58 | .python-version 59 | celerybeat-schedule 60 | *.sage.py 61 | .env 62 | .venv 63 | env/ 64 | venv/ 65 | ENV/ 66 | env.bak/ 67 | venv.bak/ 68 | .spyderproject 69 | .spyproject 70 | .ropeproject 71 | /site 72 | .mypy_cache/ 73 | .dmypy.json 74 | dmypy.json 75 | .pyre/ 76 | .idea/**/workspace.xml 77 | .idea/**/tasks.xml 78 | .idea/**/usage.statistics.xml 79 | .idea/**/dictionaries 80 | .idea/**/shelf 81 | .idea/**/contentModel.xml 82 | .idea/**/dataSources/ 83 | .idea/**/dataSources.ids 84 | .idea/**/dataSources.local.xml 85 | .idea/**/sqlDataSources.xml 86 | .idea/**/dynamic.xml 87 | .idea/**/uiDesigner.xml 88 | .idea/**/dbnavigator.xml 89 | .idea/**/gradle.xml 90 | .idea/**/libraries 91 | cmake-build-*/ 92 | .idea/**/mongoSettings.xml 93 | *.iws 94 | out/ 95 | .idea_modules/ 96 | atlassian-ide-plugin.xml 97 | .idea/replstate.xml 98 | com_crashlytics_export_strings.xml 99 | crashlytics.properties 100 | crashlytics-build.properties 101 | fabric.properties 102 | .idea/httpRequests 103 | .idea/caches/build_file_checksums.ser 104 | .DS_Store 105 | .AppleDouble 106 | .LSOverride 107 | Icon 108 | ._* 109 | .DocumentRevisions-V100 110 | .fseventsd 111 | .Spotlight-V100 112 | .TemporaryItems 113 | .Trashes 114 | .VolumeIcon.icns 115 | .com.apple.timemachine.donotpresent 116 | .AppleDB 117 | .AppleDesktop 118 | Network Trash Folder 119 | Temporary Items 120 | .apdisk 121 | Thumbs.db 122 | Thumbs.db:encryptable 123 | ehthumbs.db 124 | ehthumbs_vista.db 125 | *.stackdump 126 | [Dd]esktop.ini 127 | $RECYCLE.BIN/ 128 | *.cab 129 | *.msi 130 | *.msix 131 | *.msm 132 | *.msp 133 | *.lnk 134 | 135 | GeoLite2-Country.mmdb 136 | public-resolvers.md 137 | 138 | # https://github.com/github/gitignore/blob/master/Global/VisualStudioCode.gitignore 139 | .vscode/* 140 | !.vscode/settings.json 141 | !.vscode/tasks.json 142 | !.vscode/launch.json 143 | !.vscode/extensions.json 144 | 145 | 146 | COPYRIGHT.txt 147 | LICENSE.txt 148 | -------------------------------------------------------------------------------- /.idea/DoHVerifier.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 11 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial # required for Python >= 3.7 2 | language: python 3 | python: 4 | - "3.7" 5 | install: 6 | - pip install -r requirements.txt 7 | - curl https://download.dnscrypt.info/resolvers-list/v2/public-resolvers.md -o public-resolvers.md 8 | - curl https://raw.githubusercontent.com/akeeba/geoip-plugin/8bf26d5abae7024622e6fca21648f2af1fb8876f/plugins/system/akgeoip/db/GeoLite2-Country.mmdb -o GeoLite2-Country.mmdb 9 | script: 10 | - python3 doh_verifier.py 11 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [{ 7 | "name": "Python: Current File", 8 | "type": "python", 9 | "request": "launch", 10 | "program": "${file}", 11 | "console": "integratedTerminal" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.pythonPath": "venv/bin/python" 3 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DoHVerifier 2 | 3 | [![status](https://img.shields.io/travis/TheCjw/DoHVerifier.svg?style=flat-square)](https://travis-ci.org/TheCjw/DoHVerifier) 4 | 5 | Find best DoH resolver and test if ECS(edns client subnet) supported. -------------------------------------------------------------------------------- /doh_verifier.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import concurrent.futures 5 | import base64 6 | import io 7 | from struct import unpack 8 | import re 9 | 10 | import requests 11 | from requests import ConnectTimeout 12 | from requests import ReadTimeout 13 | from tabulate import tabulate 14 | import maxminddb 15 | 16 | from libs.log import logger 17 | 18 | reader = maxminddb.open_database("GeoLite2-Country.mmdb") 19 | 20 | 21 | def parse_resolvers(content): 22 | result = re.findall(r"^##.+?(?P.+$)(?P(\n|.)+?)(?P^sdns.+)", 23 | content, re.M) 24 | if result is None: 25 | return None 26 | 27 | resolvers = [] 28 | for r in result: 29 | # Skip sdns:// 30 | stamp = r[3][7:] 31 | 32 | # FIX Padding. 33 | stamp += "=" * ((4 - len(stamp) % 4) % 4) 34 | decoded_stamp = base64.urlsafe_b64decode(stamp) 35 | 36 | stream = io.BytesIO(decoded_stamp) 37 | # https://github.com/jedisct1/dnscrypt-proxy/wiki/stamps 38 | 39 | flag = unpack("B", stream.read(1))[0] 40 | 41 | # Parse DNS-over-HTTPS only. 42 | if flag != 0x02: 43 | continue 44 | 45 | resolver = {} 46 | 47 | resolver["name"] = r[0] 48 | resolver["ip_address"] = "" 49 | 50 | props = unpack("Q", stream.read(8))[0] 51 | 52 | _len = unpack("B", stream.read(1))[0] 53 | if _len != 0: 54 | # can be empty. 55 | ip_address = stream.read(_len) 56 | resolver["ip_address"] = ip_address.decode() 57 | 58 | # https://github.com/jedisct1/dnscrypt-proxy/blob/master/vendor/github.com/jedisct1/go-dnsstamps/dnsstamps.go#L159 59 | while True: 60 | vlen = unpack("B", stream.read(1))[0] 61 | _len = vlen & (~0x80) 62 | if _len > 0: 63 | hashes = stream.read(_len) 64 | 65 | if (vlen & 0x80) != 0x80: 66 | break 67 | 68 | _len = unpack("B", stream.read(1))[0] 69 | host = None 70 | if _len != 0: 71 | host = stream.read(_len) 72 | 73 | _len = unpack("B", stream.read(1))[0] 74 | path = None 75 | if _len != 0: 76 | path = stream.read(_len) 77 | 78 | resolver["url"] = f"https://{host.decode()}{path.decode()}" 79 | resolvers.append(resolver) 80 | 81 | return resolvers 82 | 83 | 84 | def test_resolver(resolver): 85 | logger.debug(f"Querying {resolver['name']}") 86 | try: 87 | params = { 88 | "name": "dl.google.com" 89 | } 90 | r = requests.get(resolver["url"], params=params, timeout=2) 91 | resolver["latency(ms)"] = int(r.elapsed.total_seconds() * 1000) 92 | 93 | for answer in r.json()["Answer"]: 94 | if answer["type"] == 1: 95 | ip = answer["data"] 96 | country = reader.get(ip) 97 | resolver["google"] = f"{ip}({country['country']['iso_code']})" 98 | break 99 | except (ConnectTimeout, ReadTimeout): 100 | resolver["latency(ms)"] = "timeout" 101 | return resolver 102 | 103 | 104 | def main(): 105 | content = open("public-resolvers.md", encoding="utf-8").read() 106 | resolvers = parse_resolvers(content) 107 | 108 | ipv4_resolvers = [] 109 | for resolver in resolvers: 110 | ip_address = resolver["ip_address"] 111 | if len(ip_address): 112 | # Ignore ipv6 113 | if ip_address[0] == "[": 114 | continue 115 | ipv4_resolvers.append(resolver) 116 | 117 | result = [] 118 | with concurrent.futures.ThreadPoolExecutor() as executor: 119 | future_list = {executor.submit( 120 | test_resolver, resolver): resolver for resolver in ipv4_resolvers} 121 | for future in concurrent.futures.as_completed(future_list): 122 | try: 123 | if future.result(): 124 | result.append(future.result()) 125 | except: 126 | pass 127 | 128 | print(tabulate(result, headers="keys", tablefmt="github")) 129 | 130 | 131 | if __name__ == "__main__": 132 | main() 133 | -------------------------------------------------------------------------------- /libs/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- -------------------------------------------------------------------------------- /libs/log/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import logging.config 5 | 6 | logging.config.fileConfig("logging.conf", disable_existing_loggers=True) 7 | 8 | logger = logging.getLogger("DoHVerifier") 9 | -------------------------------------------------------------------------------- /logging.conf: -------------------------------------------------------------------------------- 1 | [loggers] 2 | keys=root,DoHVerifier 3 | 4 | [handlers] 5 | keys=consoleHandler 6 | 7 | [formatters] 8 | keys=colorlog 9 | 10 | [logger_root] 11 | level=ERROR 12 | handlers=consoleHandler 13 | 14 | [logger_DoHVerifier] 15 | level = DEBUG 16 | handlers = consoleHandler 17 | qualname = DoHVerifier 18 | propagate = 0 19 | 20 | [handler_consoleHandler] 21 | class = StreamHandler 22 | level = DEBUG 23 | formatter = colorlog 24 | args = (sys.stdout,) 25 | 26 | [formatter_colorlog] 27 | class = colorlog.ColoredFormatter 28 | format = %(log_color)s%(asctime)s %(levelname)s%(reset)s %(message)s 29 | datefmt = %Y-%m-%d %H:%M:%S 30 | reset = true 31 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | colorlog 3 | maxminddb 4 | tabulate --------------------------------------------------------------------------------