├── tests ├── __init__.py ├── test_cli.py ├── test_load.py └── test_dnsserver.py ├── dnserver ├── version.py ├── __main__.py ├── __init__.py ├── load_records.py ├── cli.py └── main.py ├── requirements ├── all.txt ├── linting.in ├── testing.in ├── pyproject.txt ├── testing.txt └── linting.txt ├── .gitignore ├── .dockerignore ├── .codecov.yml ├── Dockerfile ├── .pre-commit-config.yaml ├── setup.py ├── Makefile ├── LICENSE ├── example_zones.toml ├── pyproject.toml ├── README.md └── .github └── workflows └── ci.yml /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dnserver/version.py: -------------------------------------------------------------------------------- 1 | VERSION = '0.4.0' 2 | -------------------------------------------------------------------------------- /requirements/all.txt: -------------------------------------------------------------------------------- 1 | -r ./linting.txt 2 | -r ./testing.txt 3 | -r ./pyproject.txt 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | /env/ 3 | /env*/ 4 | /.idea 5 | /.coverage 6 | /.pytest_cache/ 7 | /htmlcov/ 8 | /dist/ 9 | -------------------------------------------------------------------------------- /requirements/linting.in: -------------------------------------------------------------------------------- 1 | black 2 | flake8 3 | flake8-pyproject 4 | flake8-quotes 5 | isort[colors] 6 | pyupgrade 7 | pre-commit 8 | -------------------------------------------------------------------------------- /requirements/testing.in: -------------------------------------------------------------------------------- 1 | coverage[toml] 2 | dirty-equals 3 | dnspython 4 | pytest 5 | pytest-mock 6 | pytest-sugar 7 | pytest-timeout 8 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | /env/ 3 | /env*/ 4 | /.idea 5 | /.coverage 6 | /.pytest_cache/ 7 | /htmlcov/ 8 | /tests/ 9 | /.github/ 10 | -------------------------------------------------------------------------------- /dnserver/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This allows usage via `python -m dnserver` 3 | """ 4 | from .cli import cli 5 | 6 | if __name__ == '__main__': 7 | cli() 8 | -------------------------------------------------------------------------------- /dnserver/__init__.py: -------------------------------------------------------------------------------- 1 | from .load_records import Zone 2 | from .main import DNSServer 3 | from .version import VERSION 4 | 5 | __all__ = 'DNSServer', 'Zone', '__version__' 6 | __version__ = VERSION 7 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | precision: 2 3 | range: [95, 100] 4 | status: 5 | patch: false 6 | project: false 7 | 8 | comment: 9 | layout: 'header, diff, flags, files, footer' 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10-alpine 2 | 3 | RUN mkdir /zones 4 | ADD ./example_zones.toml /zones/zones.toml 5 | 6 | ADD ./dnserver /home/root/code/dnserver 7 | ADD ./pyproject.toml /home/root/code 8 | ADD ./LICENSE /home/root/code 9 | ADD ./README.md /home/root/code 10 | RUN pip install /home/root/code 11 | EXPOSE 53/tcp 12 | EXPOSE 53/udp 13 | ENTRYPOINT ["dnserver"] 14 | CMD ["/zones/zones.toml"] 15 | -------------------------------------------------------------------------------- /requirements/pyproject.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with python 3.7 3 | # To update, run: 4 | # 5 | # pip-compile --extra=watch --output-file=requirements/pyproject.txt pyproject.toml 6 | # 7 | dnslib==0.9.20 8 | # via dnserver (pyproject.toml) 9 | tomli==2.0.1 ; python_version < "3.11" 10 | # via dnserver (pyproject.toml) 11 | typing-extensions==4.3.0 ; python_version < "3.8" 12 | # via dnserver (pyproject.toml) 13 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.3.0 4 | hooks: 5 | - id: check-yaml 6 | args: ['--unsafe'] 7 | - id: check-toml 8 | - id: end-of-file-fixer 9 | - id: trailing-whitespace 10 | 11 | - repo: local 12 | hooks: 13 | - id: lint 14 | name: Lint 15 | entry: make lint 16 | types: [python] 17 | language: system 18 | pass_filenames: false 19 | - id: pyupgrade 20 | name: Pyupgrade 21 | entry: pyupgrade --py37-plus 22 | types: [python] 23 | language: system 24 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | sys.stderr.write( 4 | """ 5 | =============================== 6 | Unsupported installation method 7 | =============================== 8 | dnserver does not supports installation with `python setup.py install`. 9 | Please use `python -m pip install .` instead. 10 | """ 11 | ) 12 | sys.exit(1) 13 | 14 | 15 | # The below code will never execute, however GitHub is particularly 16 | # picky about where it finds Python packaging metadata. 17 | # See: https://github.com/github/feedback/discussions/6456 18 | # 19 | # To be removed once GitHub catches up. 20 | 21 | setup( 22 | name='dnserver', 23 | install_requires=[ 24 | 'dnslib>=0.9.20', 25 | 'tomli;python_version<"3.11"', 26 | 'typing_extensions;python_version<"3.8"', 27 | ], 28 | ) 29 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := all 2 | sources = dnserver tests 3 | 4 | .PHONY: install 5 | install: 6 | pip install -U pip 7 | pip install -r requirements/all.txt 8 | pip install -e . 9 | pre-commit install 10 | 11 | .PHONY: format 12 | format: 13 | pyupgrade --py37-plus --exit-zero-even-if-changed `find $(sources) -name "*.py" -type f` 14 | isort $(sources) 15 | black $(sources) 16 | 17 | .PHONY: lint 18 | lint: 19 | flake8 $(sources) 20 | isort $(sources) --check-only --df 21 | black $(sources) --check --diff 22 | 23 | .PHONY: test 24 | test: 25 | coverage run -m pytest 26 | 27 | .PHONY: testcov 28 | testcov: test 29 | @echo "building coverage html" 30 | @coverage html 31 | 32 | .PHONY: all 33 | all: lint testcov 34 | 35 | .PHONY: clean 36 | clean: 37 | rm -rf `find . -name __pycache__` 38 | rm -f `find . -type f -name '*.py[co]' ` 39 | rm -f `find . -type f -name '*~' ` 40 | rm -f `find . -type f -name '.*~' ` 41 | rm -rf .cache 42 | rm -rf .pytest_cache 43 | rm -rf htmlcov 44 | rm -rf *.egg-info 45 | rm -f .coverage 46 | rm -f .coverage.* 47 | rm -rf build 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017, 2022 Samuel Colvin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /requirements/testing.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with python 3.7 3 | # To update, run: 4 | # 5 | # pip-compile --output-file=requirements/testing.txt requirements/testing.in 6 | # 7 | attrs==22.1.0 8 | # via pytest 9 | coverage[toml]==6.4.4 10 | # via -r requirements/testing.in 11 | dirty-equals==0.5.0 12 | # via -r requirements/testing.in 13 | dnspython==2.2.1 14 | # via -r requirements/testing.in 15 | importlib-metadata==4.12.0 16 | # via 17 | # pluggy 18 | # pytest 19 | iniconfig==1.1.1 20 | # via pytest 21 | packaging==21.3 22 | # via 23 | # pytest 24 | # pytest-sugar 25 | pluggy==1.0.0 26 | # via pytest 27 | py==1.11.0 28 | # via pytest 29 | pyparsing==3.0.9 30 | # via packaging 31 | pytest==7.1.2 32 | # via 33 | # -r requirements/testing.in 34 | # pytest-mock 35 | # pytest-sugar 36 | # pytest-timeout 37 | pytest-mock==3.8.2 38 | # via -r requirements/testing.in 39 | pytest-sugar==0.9.5 40 | # via -r requirements/testing.in 41 | pytest-timeout==2.1.0 42 | # via -r requirements/testing.in 43 | pytz==2022.2.1 44 | # via dirty-equals 45 | termcolor==1.1.0 46 | # via pytest-sugar 47 | tomli==2.0.1 48 | # via 49 | # coverage 50 | # pytest 51 | typing-extensions==4.3.0 52 | # via 53 | # dirty-equals 54 | # importlib-metadata 55 | zipp==3.8.1 56 | # via importlib-metadata 57 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | from dnserver.cli import cli_logic 2 | 3 | 4 | def test_cli(mocker): 5 | calls = [] 6 | 7 | class MockDNSServer: 8 | """Using this rather than MagicMock as I couldn't get is_running to evaluate to falsey""" 9 | 10 | def __init__(self, *args, **kwargs): 11 | self.run_check = 0 12 | calls.append(f'init {args} {kwargs}') 13 | 14 | @classmethod 15 | def from_toml(cls, *args, **kwargs): 16 | return cls(*args, **kwargs) 17 | 18 | def start(self): 19 | calls.append('start') 20 | 21 | @property 22 | def is_running(self): 23 | calls.append('is_running') 24 | self.run_check += 1 25 | return self.run_check < 2 26 | 27 | def stop(self): 28 | calls.append('stop') 29 | 30 | mocker.patch('dnserver.cli.DNSServer', new=MockDNSServer) 31 | mock_signal = mocker.patch('dnserver.cli.signal.signal') 32 | assert cli_logic(['--port', '1234', 'zones.txt']) == 0 33 | assert calls == [ 34 | "init ('zones.txt',) {'port': '1234', 'upstream': '1.1.1.1'}", 35 | 'start', 36 | 'is_running', 37 | 'is_running', 38 | 'stop', 39 | ] 40 | assert mock_signal.call_count == 2 41 | 42 | 43 | def test_cli_no_zones(mocker): 44 | mock_dnserver = mocker.patch('dnserver.cli.DNSServer') 45 | mock_signal = mocker.patch('dnserver.cli.signal.signal') 46 | assert cli_logic(['--port', '1234']) == 1 47 | assert mock_dnserver.call_count == 0 48 | assert mock_signal.call_count == 0 49 | -------------------------------------------------------------------------------- /requirements/linting.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with python 3.10 3 | # To update, run: 4 | # 5 | # pip-compile --output-file=requirements/linting.txt requirements/linting.in 6 | # 7 | black==22.6.0 8 | # via -r requirements/linting.in 9 | cfgv==3.3.1 10 | # via pre-commit 11 | click==8.1.3 12 | # via black 13 | colorama==0.4.5 14 | # via isort 15 | distlib==0.3.6 16 | # via virtualenv 17 | filelock==3.8.0 18 | # via virtualenv 19 | flake8==5.0.4 20 | # via 21 | # -r requirements/linting.in 22 | # flake8-pyproject 23 | # flake8-quotes 24 | flake8-pyproject==1.1.0.post0 25 | # via -r requirements/linting.in 26 | flake8-quotes==3.3.1 27 | # via -r requirements/linting.in 28 | identify==2.5.5 29 | # via pre-commit 30 | isort[colors]==5.10.1 31 | # via -r requirements/linting.in 32 | mccabe==0.7.0 33 | # via flake8 34 | mypy-extensions==0.4.3 35 | # via black 36 | nodeenv==1.7.0 37 | # via pre-commit 38 | pathspec==0.9.0 39 | # via black 40 | platformdirs==2.5.2 41 | # via 42 | # black 43 | # virtualenv 44 | pre-commit==2.20.0 45 | # via -r requirements/linting.in 46 | pycodestyle==2.9.1 47 | # via flake8 48 | pyflakes==2.5.0 49 | # via flake8 50 | pyupgrade==2.37.3 51 | # via -r requirements/linting.in 52 | pyyaml==6.0 53 | # via pre-commit 54 | tokenize-rt==4.2.1 55 | # via pyupgrade 56 | toml==0.10.2 57 | # via pre-commit 58 | tomli==2.0.1 59 | # via 60 | # black 61 | # flake8-pyproject 62 | virtualenv==20.16.5 63 | # via pre-commit 64 | 65 | # The following packages are considered to be unsafe in a requirements file: 66 | # setuptools 67 | -------------------------------------------------------------------------------- /example_zones.toml: -------------------------------------------------------------------------------- 1 | # this is an example zones file 2 | [[zones]] 3 | host = 'example.com' 4 | type = 'A' 5 | answer = '1.2.3.4' 6 | 7 | [[zones]] 8 | host = 'example.com' 9 | type = 'A' 10 | answer = '1.2.3.4' 11 | 12 | [[zones]] 13 | host = 'example.com' 14 | type = 'CNAME' 15 | answer = 'whatever.com' 16 | 17 | [[zones]] 18 | host = 'example.com' 19 | type = 'MX' 20 | answer = ['whatever.com.', 5] 21 | 22 | [[zones]] 23 | host = 'example.com' 24 | type = 'MX' 25 | answer = ['mx2.whatever.com.', 10] 26 | 27 | [[zones]] 28 | host = 'example.com' 29 | type = 'MX' 30 | answer = ['mx3.whatever.com.', 20] 31 | 32 | [[zones]] 33 | host = 'example.com' 34 | type = 'NS' 35 | answer = 'ns1.whatever.com.' 36 | 37 | [[zones]] 38 | host = 'example.com' 39 | type = 'NS' 40 | answer = 'ns2.whatever.com.' 41 | 42 | [[zones]] 43 | host = 'example.com' 44 | type = 'TXT' 45 | answer = 'hello this is some text' 46 | 47 | [[zones]] 48 | host = 'example.com' 49 | type = 'SOA' 50 | answer = ['ns1.example.com', 'dns.example.com'] 51 | 52 | [[zones]] 53 | host = 'testing.com' 54 | type = 'TXT' 55 | # because the next record exceeds 255 in length dnserver will automatically 56 | # split it into a multipart record, the new lines here have no effect on that 57 | answer = """ 58 | one long value: IICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAg 59 | FWZUed1qcBziAsqZ/LzT2ASxJYuJ5sko1CzWFhFuxiluNnwKjSknSjanyYnm0vro4dhAtyiQ7O 60 | PVROOaNy9Iyklvu91KuhbYi6l80Rrdnuq1yjM//xjaB6DGx8+m1ENML8PEdSFbKQbh9akm2bkN 61 | w5DC5a8Slp7j+eEVHkgV3k3oRhkPcrKyoPVvniDNH+Ln7DnSGC+Aw5Sp+fhu5aZmoODhhX5/1m 62 | ANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA26JaFWZUed1qcBziAsqZ/LzTF2ASxJYuJ5sk 63 | """ 64 | 65 | [[zones]] 66 | host = '_caldavs._tcp.example.com' 67 | type = 'SRV' 68 | answer = [0, 1, 80, 'caldav'] 69 | -------------------------------------------------------------------------------- /dnserver/load_records.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations as _annotations 2 | 3 | import re 4 | import sys 5 | from dataclasses import dataclass 6 | from pathlib import Path 7 | from typing import Any 8 | 9 | try: 10 | from typing import Literal 11 | except ImportError: 12 | from typing_extensions import Literal 13 | 14 | __all__ = 'load_records', 'RecordType', 'Zone' 15 | 16 | RecordType = Literal[ 17 | 'A', 'AAAA', 'CAA', 'CNAME', 'DNSKEY', 'MX', 'NAPTR', 'NS', 'PTR', 'RRSIG', 'SOA', 'SRV', 'TXT', 'SPF' 18 | ] 19 | RECORD_TYPES = RecordType.__args__ # type: ignore 20 | 21 | 22 | @dataclass 23 | class Zone: 24 | host: str 25 | type: RecordType 26 | answer: str | list[str | int] 27 | # TODO we could add ttl and other args here if someone wanted it 28 | 29 | @classmethod 30 | def from_raw(cls, index: int, data: Any) -> Zone: 31 | if not isinstance(data, dict) or data.keys() != {'host', 'type', 'answer'}: 32 | raise ValueError( 33 | f'Zone {index} is not a valid dict, must have keys "host", "type" and "answer", got {data!r}' 34 | ) 35 | 36 | host = data['host'] 37 | if not isinstance(host, str): 38 | raise ValueError(f'Zone {index} is invalid, "host" must be string, got {data!r}') 39 | 40 | type_ = data['type'] 41 | if type_ not in RECORD_TYPES: 42 | raise ValueError(f'Zone {index} is invalid, "type" must be one of {", ".join(RECORD_TYPES)}, got {data!r}') 43 | 44 | answer = data['answer'] 45 | if isinstance(answer, str): 46 | answer = re.sub(r'\s*\r?\n', '', answer) 47 | elif not isinstance(answer, list) or not all(isinstance(x, (str, int)) for x in answer): 48 | raise ValueError( 49 | f'Zone {index} is invalid, "answer" must be a string or list of strings and ints, got {data!r}' 50 | ) 51 | 52 | return cls(host, type_, answer) 53 | 54 | 55 | @dataclass 56 | class Records: 57 | zones: list[Zone] 58 | 59 | 60 | def load_records(zones_file: str | Path) -> Records: 61 | data = parse_toml(zones_file) 62 | try: 63 | zones = data['zones'] 64 | except KeyError: 65 | raise ValueError(f'No zones found in {zones_file}') 66 | 67 | if not isinstance(zones, list): 68 | raise ValueError(f'Zones must be a list, not {type(zones).__name__}') 69 | return Records([Zone.from_raw(i, zone) for i, zone in enumerate(zones, start=1)]) 70 | 71 | 72 | def parse_toml(zones_file: str | Path) -> dict[str, Any]: 73 | if sys.version_info >= (3, 11): 74 | import tomllib as toml_ 75 | else: 76 | import tomli as toml_ 77 | 78 | with open(zones_file, 'rb') as rf: 79 | return toml_.load(rf) 80 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ['hatchling'] 3 | build-backend = 'hatchling.build' 4 | 5 | [tool.hatch.version] 6 | path = 'dnserver/version.py' 7 | 8 | [project] 9 | name = 'dnserver' 10 | description = 'Simple DNS server written in python for use in development and testing.' 11 | authors = [{name = 'Samuel Colvin', email = 's@muelcolvin.com'}] 12 | license-files = { paths = ['LICENSE'] } 13 | readme = 'README.md' 14 | classifiers = [ 15 | 'Development Status :: 4 - Beta', 16 | 'Environment :: Console', 17 | 'Intended Audience :: Developers', 18 | 'Intended Audience :: Information Technology', 19 | 'Intended Audience :: System Administrators', 20 | 'License :: OSI Approved :: MIT License', 21 | 'Operating System :: Unix', 22 | 'Operating System :: POSIX :: Linux', 23 | 'Programming Language :: Python', 24 | 'Programming Language :: Python :: 3', 25 | 'Programming Language :: Python :: 3 :: Only', 26 | 'Programming Language :: Python :: 3.7', 27 | 'Programming Language :: Python :: 3.8', 28 | 'Programming Language :: Python :: 3.9', 29 | 'Programming Language :: Python :: 3.10', 30 | 'Programming Language :: Python :: 3.11', 31 | 'Topic :: Internet', 32 | 'Topic :: Internet :: Name Service (DNS)', 33 | 'Topic :: Software Development :: Libraries :: Python Modules', 34 | 'Topic :: System :: Systems Administration', 35 | 'Topic :: Software Development :: Testing', 36 | 'Topic :: Software Development :: Testing :: Mocking', 37 | ] 38 | requires-python = '>=3.7' 39 | dependencies = [ 40 | 'dnslib>=0.9.20', 41 | 'tomli;python_version<"3.11"', 42 | 'typing_extensions;python_version<"3.8"', 43 | ] 44 | dynamic = ['version', 'license'] 45 | 46 | [project.scripts] 47 | dnserver = 'dnserver.cli:cli' 48 | 49 | [project.urls] 50 | Homepage = 'https://github.com/samuelcolvin/dnserver' 51 | Documentation = 'https://github.com/samuelcolvin/dnserver' 52 | Source = 'https://github.com/samuelcolvin/dnserver' 53 | Changelog = 'https://github.com/samuelcolvin/dnserver/releases' 54 | 55 | [tool.pytest.ini_options] 56 | testpaths = 'tests' 57 | filterwarnings = ['error'] 58 | timeout = 10 59 | 60 | [tool.coverage.run] 61 | source = ['dnserver'] 62 | branch = true 63 | omit = ['dnserver/__main__.py'] 64 | 65 | [tool.coverage.report] 66 | precision = 2 67 | exclude_lines = [ 68 | 'pragma: no cover', 69 | 'raise NotImplementedError', 70 | 'raise NotImplemented', 71 | 'if TYPE_CHECKING:', 72 | '@overload', 73 | ] 74 | 75 | [tool.flake8] 76 | max_line_length = 120 77 | max_complexity = 14 78 | inline_quotes = 'single' 79 | multiline_quotes = 'double' 80 | 81 | [tool.black] 82 | color = true 83 | line-length = 120 84 | target-version = ['py37', 'py38', 'py39', 'py310', 'py311'] 85 | skip-string-normalization = true 86 | 87 | [tool.isort] 88 | line_length = 120 89 | known_third_party = 'foxglove' 90 | multi_line_output = 3 91 | include_trailing_comma = true 92 | force_grid_wrap = 0 93 | combine_as_imports = true 94 | color_output = true 95 | -------------------------------------------------------------------------------- /dnserver/cli.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations as _annotations 2 | 3 | import argparse 4 | import os 5 | import signal 6 | import sys 7 | from time import sleep 8 | 9 | from .main import DEFAULT_UPSTREAM, DNSServer, logger 10 | from .version import VERSION 11 | 12 | __all__ = ('cli',) 13 | 14 | 15 | def handle_sig(signum, frame): # pragma: no cover 16 | logger.info('pid=%d, got signal: %s, stopping...', os.getpid(), signal.Signals(signum).name) 17 | raise KeyboardInterrupt 18 | 19 | 20 | HELP_TEXT = f"""\ 21 | Simple DNS server written in python for use in development and testing. 22 | 23 | See https://github.com/samuelcolvin/dnserver for more information. 24 | 25 | V{VERSION} 26 | """ 27 | 28 | 29 | def cli_logic(args: list[str]) -> int: 30 | parser = argparse.ArgumentParser( 31 | prog='dnserver', description=HELP_TEXT, formatter_class=argparse.RawTextHelpFormatter 32 | ) 33 | parser.add_argument( 34 | 'zones_file', 35 | nargs='?', 36 | help='TOML file containing zones info, if omitted will use DNSERVER_ZONE_FILE env var', 37 | ) 38 | parser.add_argument('--port', help='Port to run on, if omitted will use DNSERVER_PORT env var, or 53') 39 | parser.add_argument( 40 | '--upstream', 41 | help=( 42 | 'Upstream DNS server to use if no record is found in the zone TOML file, ' 43 | 'if omitted will use DNSERVER_UPSTREAM env var, or 1.1.1.1' 44 | ), 45 | ) 46 | parser.add_argument( 47 | '--no-upstream', 48 | action='store_true', 49 | default=False, 50 | help=( 51 | 'Disable upstream DNS server. ' 52 | 'If set and a domain is not in the zone TOML file, the DNS server will ' 53 | 'consider the domain is not valid. If omitted will use DNSERVER_NO_UPSTREAM env var, or False' 54 | ), 55 | ) 56 | parser.add_argument('--version', action='version', version=f'%(prog)s v{VERSION}') 57 | parsed_args = parser.parse_args(args) 58 | 59 | port = parsed_args.port or os.getenv('DNSERVER_PORT', None) 60 | no_upstream = parsed_args.no_upstream or os.getenv('DNSERVER_NO_UPSTREAM', False) 61 | if no_upstream: 62 | upstream = None 63 | else: 64 | upstream = parsed_args.upstream or os.getenv('DNSERVER_UPSTREAM', DEFAULT_UPSTREAM) 65 | zones_file = parsed_args.zones_file or os.getenv('DNSERVER_ZONE_FILE', None) 66 | if zones_file is None: 67 | print('no zones file specified, use --help for more information', file=sys.stderr) 68 | return 1 69 | 70 | signal.signal(signal.SIGTERM, handle_sig) 71 | signal.signal(signal.SIGINT, handle_sig) 72 | 73 | server = DNSServer.from_toml(zones_file, port=port, upstream=upstream) 74 | server.start() 75 | 76 | try: 77 | while server.is_running: 78 | sleep(0.1) 79 | except KeyboardInterrupt: # pragma: no cover 80 | pass 81 | finally: 82 | logger.info('stopping DNS server') 83 | server.stop() 84 | 85 | return 0 86 | 87 | 88 | def cli(): # pragma: no cover 89 | exit(cli_logic(sys.argv[1:])) 90 | -------------------------------------------------------------------------------- /tests/test_load.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from dnserver.load_records import Records, Zone, load_records 4 | from dnserver.main import Record 5 | 6 | 7 | def test_load_records(): 8 | records = load_records('example_zones.toml') 9 | assert records == Records( 10 | zones=[ 11 | Zone(host='example.com', type='A', answer='1.2.3.4'), 12 | Zone(host='example.com', type='A', answer='1.2.3.4'), 13 | Zone(host='example.com', type='CNAME', answer='whatever.com'), 14 | Zone(host='example.com', type='MX', answer=['whatever.com.', 5]), 15 | Zone(host='example.com', type='MX', answer=['mx2.whatever.com.', 10]), 16 | Zone(host='example.com', type='MX', answer=['mx3.whatever.com.', 20]), 17 | Zone(host='example.com', type='NS', answer='ns1.whatever.com.'), 18 | Zone(host='example.com', type='NS', answer='ns2.whatever.com.'), 19 | Zone(host='example.com', type='TXT', answer='hello this is some text'), 20 | Zone(host='example.com', type='SOA', answer=['ns1.example.com', 'dns.example.com']), 21 | Zone( 22 | host='testing.com', 23 | type='TXT', 24 | answer=( 25 | 'one long value: IICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgFWZUed1qcBziAsqZ/LzT2ASxJYuJ5sko1CzWFhFu' 26 | 'xiluNnwKjSknSjanyYnm0vro4dhAtyiQ7OPVROOaNy9Iyklvu91KuhbYi6l80Rrdnuq1yjM//xjaB6DGx8+m1ENML8PEdSFbK' 27 | 'Qbh9akm2bkNw5DC5a8Slp7j+eEVHkgV3k3oRhkPcrKyoPVvniDNH+Ln7DnSGC+Aw5Sp+fhu5aZmoODhhX5/1mANBgkqhkiG9w' 28 | '0BAQEFAAOCAg8AMIICCgKCAgEA26JaFWZUed1qcBziAsqZ/LzTF2ASxJYuJ5sk' 29 | ), 30 | ), 31 | Zone(host='_caldavs._tcp.example.com', type='SRV', answer=[0, 1, 80, 'caldav']), 32 | ], 33 | ) 34 | 35 | 36 | def test_create_server(): 37 | records = load_records('example_zones.toml') 38 | [Record(zone) for zone in records.zones] 39 | 40 | 41 | def test_no_zones(tmp_path): 42 | path = tmp_path / 'zones.toml' 43 | path.write_text('x = 4') 44 | with pytest.raises(ValueError, match=r'^No zones found in .+zones\.toml$'): 45 | load_records(path) 46 | 47 | 48 | @pytest.mark.parametrize( 49 | 'toml,error', 50 | [ 51 | ('x = 4', r'^No zones found in .+zones\.toml$'), 52 | ('[zones]\ntype = 4', r'^Zones must be a list, not dict$'), 53 | ('zones = [4]', 'Zone 1 is not a valid dict, must have keys "host", "type" and "answer"'), 54 | ('[[zones]]\ntype = 4', 'Zone 1 is not a valid dict, must have keys "host", "type" and "answer"'), 55 | ( 56 | 'zones = [{host="a",type="A",answer="c"},4]', 57 | 'Zone 2 is not a valid dict, must have keys "host", "type" and "answer"', 58 | ), 59 | ('zones = [{host=42,type="A",answer="c"}]', 'Zone 1 is invalid, "host" must be string'), 60 | ('zones = [{host="a",type="c",answer="c"}]', r'Zone 1 is invalid, "type" must be one of A, AAAA.+'), 61 | ], 62 | ) 63 | def test_invalid_zones(tmp_path, toml, error): 64 | path = tmp_path / 'zones.toml' 65 | path.write_text(toml) 66 | with pytest.raises(ValueError, match=error): 67 | load_records(path) 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dnserver 2 | 3 | [![CI](https://github.com/samuelcolvin/dnserver/workflows/CI/badge.svg?event=push)](https://github.com/samuelcolvin/dnserver/actions?query=event%3Apush+branch%3Amain+workflow%3ACI) 4 | [![Coverage](https://codecov.io/gh/samuelcolvin/dnserver/branch/main/graph/badge.svg)](https://codecov.io/gh/samuelcolvin/dnserver) 5 | [![pypi](https://img.shields.io/pypi/v/dnserver.svg)](https://pypi.python.org/pypi/dnserver) 6 | [![docker](https://img.shields.io/docker/image-size/samuelcolvin/dnserver?sort=date)](https://hub.docker.com/r/samuelcolvin/dnserver/) 7 | [![versions](https://img.shields.io/pypi/pyversions/dnserver.svg)](https://github.com/samuelcolvin/dnserver) 8 | [![license](https://img.shields.io/github/license/samuelcolvin/dnserver.svg)](https://github.com/samuelcolvin/dnserver/blob/main/LICENSE) 9 | 10 | Simple DNS server written in python for use in development and testing. 11 | 12 | The DNS serves its own records, if none are found it proxies the request to an upstream DNS server 13 | eg. CloudFlare at [`1.1.1.1`](https://www.cloudflare.com/learning/dns/what-is-1.1.1.1/). 14 | 15 | You can set up records you want to serve with a custom `zones.toml` file, 16 | see [example_zones.toml](https://github.com/samuelcolvin/dnserver/blob/main/example_zones.toml) an example. 17 | 18 | ## Installation from PyPI 19 | 20 | Install with: 21 | 22 | ```bash 23 | pip install dnserver 24 | ``` 25 | 26 | Usage: 27 | 28 | ```bash 29 | dnserver --help 30 | ``` 31 | 32 | (or `python -m dnserver --help`) 33 | 34 | For example, to serve a file called `my_zones.toml` file on port `5053`, run: 35 | 36 | ```bash 37 | dnserver --port 5053 my_zones.toml 38 | ``` 39 | 40 | ## Usage with Python 41 | 42 | ```python 43 | from dnserver import DNSServer 44 | 45 | server = DNSServer.from_toml('example_zones.toml', port=5053) 46 | server.start() 47 | assert server.is_running 48 | 49 | # now you can do some requests with your favorite dns library 50 | 51 | server.stop() 52 | ``` 53 | 54 | ## Usage with Docker 55 | 56 | To use with docker: 57 | 58 | ```bash 59 | docker run -p 5053:53/udp -p 5053:53/tcp --rm samuelcolvin/dnserver 60 | ``` 61 | 62 | (See [dnserver on hub.docker.com](https://hub.docker.com/r/samuelcolvin/dnserver/)) 63 | 64 | Or with a custom zone file: 65 | 66 | ```bash 67 | docker run -p 5053:53/udp -v `pwd`/zones.toml:/zones/zones.toml --rm samuelcolvin/dnserver 68 | ``` 69 | 70 | (assuming you have your zone records at `./zones.toml`, 71 | TCP isn't required to use `dig`, hence why it's omitted in this case.) 72 | 73 | You can then test (either of the above) with 74 | 75 | ```shell 76 | ~ ➤ dig @localhost -p 5053 example.com MX 77 | ... 78 | ;; ANSWER SECTION: 79 | example.com. 300 IN MX 5 whatever.com. 80 | example.com. 300 IN MX 10 mx2.whatever.com. 81 | example.com. 300 IN MX 20 mx3.whatever.com. 82 | 83 | ;; Query time: 2 msec 84 | ;; SERVER: 127.0.0.1#5053(127.0.0.1) 85 | ;; WHEN: Sun Feb 26 18:14:52 GMT 2017 86 | ;; MSG SIZE rcvd: 94 87 | 88 | ~ ➤ dig @localhost -p 5053 tutorcruncher.com MX 89 | ... 90 | ;; ANSWER SECTION: 91 | tutorcruncher.com. 299 IN MX 10 aspmx2.googlemail.com. 92 | tutorcruncher.com. 299 IN MX 5 alt1.aspmx.l.google.com. 93 | tutorcruncher.com. 299 IN MX 5 alt2.aspmx.l.google.com. 94 | tutorcruncher.com. 299 IN MX 1 aspmx.l.google.com. 95 | tutorcruncher.com. 299 IN MX 10 aspmx3.googlemail.com. 96 | 97 | ;; Query time: 39 msec 98 | ;; SERVER: 127.0.0.1#5053(127.0.0.1) 99 | ;; WHEN: Sun Feb 26 18:14:48 GMT 2017 100 | ;; MSG SIZE rcvd: 176 101 | ``` 102 | 103 | You can see that the first query took 2ms and returned results from `example_zones.toml`, 104 | the second query took 39ms as dnserver didn't have any records for the domain so had to proxy the query to 105 | the upstream DNS server. 106 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - '**' 9 | pull_request: {} 10 | 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - name: set up python 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: '3.10' 22 | 23 | - uses: actions/cache@v3 24 | id: cache 25 | with: 26 | path: ${{ env.pythonLocation }} 27 | key: > 28 | lint 29 | ${{ runner.os }} 30 | ${{ env.pythonLocation }} 31 | ${{ hashFiles('requirements/linting.txt') }} 32 | 33 | - name: install 34 | if: steps.cache.outputs.cache-hit != 'true' 35 | run: pip install -r requirements/linting.txt 36 | 37 | - uses: pre-commit/action@v3.0.0 38 | with: 39 | extra_args: --all-files --verbose 40 | 41 | test: 42 | name: test ${{ matrix.python-version }} on ${{ matrix.os }} 43 | strategy: 44 | fail-fast: false 45 | matrix: 46 | os: [ubuntu, macos] 47 | python-version: ['3.7', '3.8', '3.9', '3.10'] 48 | # test 3.11-dev and pypy on ubuntu only to speed up CI, no reason why macos X pypy should fail separately 49 | include: 50 | - os: 'ubuntu' 51 | python-version: '3.11-dev' 52 | - os: 'ubuntu' 53 | python-version: 'pypy-3.7' 54 | - os: 'ubuntu' 55 | python-version: 'pypy-3.8' 56 | - os: 'ubuntu' 57 | python-version: 'pypy-3.9' 58 | 59 | runs-on: ${{ matrix.os }}-latest 60 | 61 | env: 62 | PYTHON: ${{ matrix.python-version }} 63 | OS: ${{ matrix.os }} 64 | 65 | steps: 66 | - uses: actions/checkout@v3 67 | 68 | - name: set up python 69 | uses: actions/setup-python@v4 70 | with: 71 | python-version: ${{ matrix.python-version }} 72 | 73 | - uses: actions/cache@v3 74 | id: cache 75 | with: 76 | path: ${{ env.pythonLocation }} 77 | key: ${{ runner.os }}-${{ env.pythonLocation }}-${{ hashFiles('requirements/pyproject.txt') }}-${{ hashFiles('requirements/testing.txt') }} 78 | 79 | - run: pip install -r requirements/pyproject.txt -r requirements/testing.txt 80 | if: steps.cache.outputs.cache-hit != 'true' 81 | 82 | - run: coverage run -m pytest 83 | 84 | - run: coverage xml 85 | 86 | - uses: codecov/codecov-action@v3 87 | with: 88 | file: ./coverage.xml 89 | env_vars: PYTHON,OS 90 | 91 | docker-build: 92 | runs-on: ubuntu-latest 93 | 94 | steps: 95 | - uses: actions/checkout@v3 96 | 97 | - run: docker build . -t dnserver 98 | - run: docker run --rm dnserver --help 99 | 100 | deploy: 101 | name: Deploy 102 | needs: [lint, test, docker-build] 103 | if: "success() && startsWith(github.ref, 'refs/tags/')" 104 | runs-on: ubuntu-latest 105 | 106 | steps: 107 | - uses: actions/checkout@v2 108 | 109 | - uses: docker/setup-buildx-action@v2 110 | 111 | - name: Login to DockerHub 112 | uses: docker/login-action@v2 113 | with: 114 | username: samuelcolvin 115 | password: ${{ secrets.dockerhub_token }} 116 | 117 | - name: set up python 118 | uses: actions/setup-python@v4 119 | with: 120 | python-version: '3.10' 121 | 122 | - name: install 123 | run: pip install -U twine build packaging 124 | 125 | - name: check version 126 | id: check-version 127 | run: python <(curl -Ls https://gist.githubusercontent.com/samuelcolvin/4e1ad439c5489e8d6478cdee3eb952ef/raw/check_version.py) 128 | env: 129 | VERSION_PATH: 'dnserver/version.py' 130 | 131 | - name: build 132 | run: python -m build 133 | 134 | - run: twine check dist/* 135 | 136 | - name: upload to pypi 137 | run: twine upload dist/* 138 | env: 139 | TWINE_USERNAME: __token__ 140 | TWINE_PASSWORD: ${{ secrets.pypi_token }} 141 | 142 | - name: Build and push 143 | uses: docker/build-push-action@v3 144 | with: 145 | push: true 146 | tags: samuelcolvin/dnserver:latest,samuelcolvin/dnserver:v${{ steps.check-version.outputs.VERSION }} 147 | -------------------------------------------------------------------------------- /tests/test_dnsserver.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, Dict, List 2 | 3 | import dns 4 | import pytest 5 | from dirty_equals import IsIP, IsPositive 6 | from dns.resolver import NoAnswer, Resolver as RawResolver 7 | 8 | from dnserver import DNSServer, Zone 9 | 10 | Resolver = Callable[[str, str], List[Dict[str, Any]]] 11 | 12 | 13 | def convert_answer(answer) -> Dict[str, Any]: 14 | rdtype = answer.rdtype.name 15 | d = {'type': rdtype} 16 | if rdtype == 'MX': 17 | d.update( 18 | preference=answer.preference, 19 | value=answer.exchange.to_text(), 20 | ) 21 | elif rdtype == 'SOA': 22 | d.update( 23 | rname=str(answer.rname.choose_relativity()), 24 | mname=str(answer.mname.choose_relativity()), 25 | serial=answer.serial, 26 | refresh=answer.refresh, 27 | retry=answer.retry, 28 | expire=answer.expire, 29 | minimum=answer.minimum, 30 | ) 31 | else: 32 | d['value'] = answer.to_text() 33 | return d 34 | 35 | 36 | @pytest.fixture(scope='session') 37 | def dns_server(): 38 | port = 5053 39 | 40 | server = DNSServer.from_toml('example_zones.toml', port=port) 41 | server.start() 42 | assert server.is_running 43 | yield server 44 | server.stop 45 | 46 | 47 | @pytest.fixture(scope='session') 48 | def dns_resolver(dns_server: DNSServer): 49 | resolver = RawResolver() 50 | resolver.nameservers = ['127.0.0.1'] 51 | resolver.port = dns_server.port 52 | 53 | def resolve(name: str, type_: str) -> List[Dict[str, Any]]: 54 | answers = resolver.resolve(name, type_) 55 | return [convert_answer(answer) for answer in answers] 56 | 57 | yield resolve 58 | 59 | 60 | def test_a_record(dns_resolver: Resolver): 61 | assert dns_resolver('example.com', 'A') == [ 62 | { 63 | 'type': 'A', 64 | 'value': '1.2.3.4', 65 | }, 66 | ] 67 | 68 | 69 | def test_cname_record(dns_resolver: Resolver): 70 | assert dns_resolver('example.com', 'CNAME') == [ 71 | { 72 | 'type': 'CNAME', 73 | 'value': 'whatever.com.', 74 | }, 75 | ] 76 | 77 | 78 | def test_mx_record(dns_resolver: Resolver): 79 | assert dns_resolver('example.com', 'MX') == [ 80 | { 81 | 'type': 'MX', 82 | 'preference': 5, 83 | 'value': 'whatever.com.', 84 | }, 85 | { 86 | 'type': 'MX', 87 | 'preference': 10, 88 | 'value': 'mx2.whatever.com.', 89 | }, 90 | { 91 | 'type': 'MX', 92 | 'preference': 20, 93 | 'value': 'mx3.whatever.com.', 94 | }, 95 | ] 96 | 97 | 98 | def test_proxy(dns_resolver: Resolver): 99 | assert dns_resolver('example.org', 'A') == [ 100 | { 101 | 'type': 'A', 102 | 'value': IsIP(version=4), 103 | }, 104 | ] 105 | 106 | 107 | def test_soa(dns_resolver: Resolver): 108 | assert dns_resolver('example.com', 'SOA') == [ 109 | { 110 | 'type': 'SOA', 111 | 'rname': 'dns.example.com.', 112 | 'mname': 'ns1.example.com.', 113 | 'serial': IsPositive(), 114 | 'refresh': 3600, 115 | 'retry': 10800, 116 | 'expire': 86400, 117 | 'minimum': 3600, 118 | } 119 | ] 120 | 121 | 122 | def test_soa_higher(dns_resolver: Resolver): 123 | """ 124 | This is testing the "found higher level SOA resource for" logic, however dnspython thinks the response 125 | is wrong. I really don't know, but adding this test to enforce current behaviour. 126 | 127 | I'd love someone who knows how DNS is supposed to work to comment on this. 128 | """ 129 | with pytest.raises(NoAnswer) as exc_info: 130 | dns_resolver('subdomain.example.com', 'SOA') 131 | assert str(exc_info.value) == ( 132 | 'The DNS response does not contain an answer to the question: subdomain.example.com. IN SOA' 133 | ) 134 | 135 | 136 | def test_dns_server_without_upstream(): 137 | port = 5054 138 | 139 | server = DNSServer.from_toml('example_zones.toml', port=port, upstream=None) 140 | server.start() 141 | 142 | resolver = RawResolver() 143 | resolver.nameservers = ['127.0.0.1'] 144 | resolver.port = port 145 | 146 | def resolve(name: str, type_: str) -> List[Dict[str, Any]]: 147 | answers = resolver.resolve(name, type_) 148 | return [convert_answer(answer) for answer in answers] 149 | 150 | try: 151 | assert resolve('example.com', 'A') == [ 152 | { 153 | 'type': 'A', 154 | 'value': '1.2.3.4', 155 | }, 156 | ] 157 | with pytest.raises(NoAnswer): 158 | resolve('python.org', 'A') 159 | finally: 160 | server.stop() 161 | 162 | 163 | def test_dynamic_zone_update(dns_server: DNSServer, dns_resolver: Resolver): 164 | assert dns_resolver('example.com', 'A') == [ 165 | { 166 | 'type': 'A', 167 | 'value': '1.2.3.4', 168 | }, 169 | ] 170 | with pytest.raises(dns.resolver.NXDOMAIN): 171 | dns_resolver('another-example.org', 'A') 172 | 173 | dns_server.add_record(Zone(host='another-example.com', type='A', answer='2.3.4.5')) 174 | 175 | assert dns_resolver('example.com', 'A') == [ 176 | { 177 | 'type': 'A', 178 | 'value': '1.2.3.4', 179 | }, 180 | ] 181 | assert dns_resolver('another-example.com', 'A') == [ 182 | { 183 | 'type': 'A', 184 | 'value': '2.3.4.5', 185 | }, 186 | ] 187 | 188 | dns_server.set_records([Zone(host='example.com', type='A', answer='4.5.6.7')]) 189 | 190 | assert dns_resolver('example.com', 'A') == [ 191 | { 192 | 'type': 'A', 193 | 'value': '4.5.6.7', 194 | }, 195 | ] 196 | with pytest.raises(dns.resolver.NXDOMAIN): 197 | dns_resolver('another-example.org', 'A') 198 | 199 | 200 | def test_no_zone_at_initialization(): 201 | port = 5055 202 | 203 | server = DNSServer(port=port, upstream=None) 204 | server.start() 205 | 206 | resolver = RawResolver() 207 | resolver.nameservers = ['127.0.0.1'] 208 | resolver.port = port 209 | 210 | def resolve(name: str, type_: str) -> List[Dict[str, Any]]: 211 | answers = resolver.resolve(name, type_) 212 | return [convert_answer(answer) for answer in answers] 213 | 214 | try: 215 | with pytest.raises(NoAnswer): 216 | resolve('example.com', 'A') 217 | finally: 218 | server.stop() 219 | -------------------------------------------------------------------------------- /dnserver/main.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations as _annotations 2 | 3 | import logging 4 | from datetime import datetime 5 | from pathlib import Path 6 | from textwrap import wrap 7 | from typing import Any, List 8 | 9 | from dnslib import QTYPE, RR, DNSLabel, dns 10 | from dnslib.proxy import ProxyResolver as LibProxyResolver 11 | from dnslib.server import BaseResolver as LibBaseResolver, DNSServer as LibDNSServer 12 | 13 | from .load_records import Records, Zone, load_records 14 | 15 | __all__ = 'DNSServer', 'logger' 16 | 17 | SERIAL_NO = int((datetime.utcnow() - datetime(1970, 1, 1)).total_seconds()) 18 | 19 | handler = logging.StreamHandler() 20 | handler.setLevel(logging.INFO) 21 | handler.setFormatter(logging.Formatter('%(asctime)s: %(message)s', datefmt='%H:%M:%S')) 22 | 23 | logger = logging.getLogger(__name__) 24 | logger.addHandler(handler) 25 | logger.setLevel(logging.INFO) 26 | 27 | TYPE_LOOKUP = { 28 | 'A': (dns.A, QTYPE.A), 29 | 'AAAA': (dns.AAAA, QTYPE.AAAA), 30 | 'CAA': (dns.CAA, QTYPE.CAA), 31 | 'CNAME': (dns.CNAME, QTYPE.CNAME), 32 | 'DNSKEY': (dns.DNSKEY, QTYPE.DNSKEY), 33 | 'MX': (dns.MX, QTYPE.MX), 34 | 'NAPTR': (dns.NAPTR, QTYPE.NAPTR), 35 | 'NS': (dns.NS, QTYPE.NS), 36 | 'PTR': (dns.PTR, QTYPE.PTR), 37 | 'RRSIG': (dns.RRSIG, QTYPE.RRSIG), 38 | 'SOA': (dns.SOA, QTYPE.SOA), 39 | 'SRV': (dns.SRV, QTYPE.SRV), 40 | 'TXT': (dns.TXT, QTYPE.TXT), 41 | 'SPF': (dns.TXT, QTYPE.TXT), 42 | } 43 | DEFAULT_PORT = 53 44 | DEFAULT_UPSTREAM = '1.1.1.1' 45 | 46 | 47 | class Record: 48 | def __init__(self, zone: Zone): 49 | self._rname = DNSLabel(zone.host) 50 | 51 | rd_cls, self._rtype = TYPE_LOOKUP[zone.type] 52 | 53 | args: list[Any] 54 | if isinstance(zone.answer, str): 55 | if self._rtype == QTYPE.TXT: 56 | args = [wrap(zone.answer, 255)] 57 | else: 58 | args = [zone.answer] 59 | else: 60 | if self._rtype == QTYPE.SOA and len(zone.answer) == 2: 61 | # add sensible times to SOA 62 | args = zone.answer + [(SERIAL_NO, 3600, 3600 * 3, 3600 * 24, 3600)] 63 | else: 64 | args = zone.answer 65 | 66 | if self._rtype in (QTYPE.NS, QTYPE.SOA): 67 | ttl = 3600 * 24 68 | else: 69 | ttl = 300 70 | 71 | self.rr = RR( 72 | rname=self._rname, 73 | rtype=self._rtype, 74 | rdata=rd_cls(*args), 75 | ttl=ttl, 76 | ) 77 | 78 | def match(self, q): 79 | return q.qname == self._rname and (q.qtype == QTYPE.ANY or q.qtype == self._rtype) 80 | 81 | def sub_match(self, q): 82 | return self._rtype == QTYPE.SOA and q.qname.matchSuffix(self._rname) 83 | 84 | def __str__(self): 85 | return str(self.rr) 86 | 87 | 88 | def resolve(request, handler, records): 89 | records = [Record(zone) for zone in records.zones] 90 | type_name = QTYPE[request.q.qtype] 91 | reply = request.reply() 92 | for record in records: 93 | if record.match(request.q): 94 | reply.add_answer(record.rr) 95 | 96 | if reply.rr: 97 | logger.info('found zone for %s[%s], %d replies', request.q.qname, type_name, len(reply.rr)) 98 | return reply 99 | 100 | # no direct zone so look for an SOA record for a higher level zone 101 | for record in records: 102 | if record.sub_match(request.q): 103 | reply.add_answer(record.rr) 104 | 105 | if reply.rr: 106 | logger.info('found higher level SOA resource for %s[%s]', request.q.qname, type_name) 107 | return reply 108 | 109 | 110 | class BaseResolver(LibBaseResolver): 111 | def __init__(self, records: Records): 112 | self.records = records 113 | super().__init__() 114 | 115 | def resolve(self, request, handler): 116 | answer = resolve(request, handler, self.records) 117 | if answer: 118 | return answer 119 | 120 | type_name = QTYPE[request.q.qtype] 121 | logger.info('no local zone found, not proxying %s[%s]', request.q.qname, type_name) 122 | return request.reply() 123 | 124 | 125 | class ProxyResolver(LibProxyResolver): 126 | def __init__(self, records: Records, upstream: str): 127 | self.records = records 128 | super().__init__(address=upstream, port=53, timeout=5) 129 | 130 | def resolve(self, request, handler): 131 | answer = resolve(request, handler, self.records) 132 | if answer: 133 | return answer 134 | 135 | type_name = QTYPE[request.q.qtype] 136 | logger.info('no local zone found, proxying %s[%s]', request.q.qname, type_name) 137 | return super().resolve(request, handler) 138 | 139 | 140 | class DNSServer: 141 | def __init__( 142 | self, 143 | records: Records | None = None, 144 | port: int | str | None = DEFAULT_PORT, 145 | upstream: str | None = DEFAULT_UPSTREAM, 146 | ): 147 | self.port: int = DEFAULT_PORT if port is None else int(port) 148 | self.upstream: str | None = upstream 149 | self.udp_server: LibDNSServer | None = None 150 | self.tcp_server: LibDNSServer | None = None 151 | self.records: Records = records if records else Records(zones=[]) 152 | 153 | @classmethod 154 | def from_toml( 155 | cls, zones_file: str | Path, *, port: int | str | None = DEFAULT_PORT, upstream: str | None = DEFAULT_UPSTREAM 156 | ) -> 'DNSServer': 157 | records = load_records(zones_file) 158 | logger.info( 159 | 'loaded %d zone record from %s, with %s as a proxy DNS server', 160 | len(records.zones), 161 | zones_file, 162 | upstream, 163 | ) 164 | return DNSServer(records, port=port, upstream=upstream) 165 | 166 | def start(self): 167 | if self.upstream: 168 | logger.info('starting DNS server on port %d, upstream DNS server "%s"', self.port, self.upstream) 169 | resolver = ProxyResolver(self.records, self.upstream) 170 | else: 171 | logger.info('starting DNS server on port %d, without upstream DNS server', self.port) 172 | resolver = BaseResolver(self.records) 173 | 174 | self.udp_server = LibDNSServer(resolver, port=self.port) 175 | self.tcp_server = LibDNSServer(resolver, port=self.port, tcp=True) 176 | self.udp_server.start_thread() 177 | self.tcp_server.start_thread() 178 | 179 | def stop(self): 180 | self.udp_server.stop() 181 | self.udp_server.server.server_close() 182 | self.tcp_server.stop() 183 | self.tcp_server.server.server_close() 184 | 185 | @property 186 | def is_running(self): 187 | return (self.udp_server and self.udp_server.isAlive()) or (self.tcp_server and self.tcp_server.isAlive()) 188 | 189 | def add_record(self, zone: Zone): 190 | self.records.zones.append(zone) 191 | 192 | def set_records(self, zones: List[Zone]): 193 | self.records.zones = zones 194 | --------------------------------------------------------------------------------