Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: Bug report
description: A wrong answer, a crash, or a resolution that should have succeeded
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
For anything security-sensitive, please use a
[private advisory](https://github.com/sylvinus/recursive-resolver/security/advisories/new)
instead of a public issue.
- type: input
id: domain
attributes:
label: Domain and record type
description: The exact query, so the behaviour can be reproduced.
placeholder: "s1._domainkey.example.com TXT"
validations:
required: true
- type: textarea
id: trace
attributes:
label: Delegation trace
description: Output of `recursive-resolver --trace <domain> <type>`. This is usually enough to diagnose the problem on its own.
render: text
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected versus actual
description: >-
What you expected, what happened, and what a reference resolver says.
Please query an explicit resolver rather than the system stub —
`dig +dnssec @1.1.1.1 <domain> <type>` — because a stub can synthesise
a CNAME of its own and the comparison then is not like for like.
validations:
required: true
- type: input
id: version
attributes:
label: Version
description: Output of `recursive-resolver --version` and your Python version.
validations:
required: true
5 changes: 5 additions & 0 deletions .github/ISSUE_TEMPLATE/config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Security vulnerability
url: https://github.com/sylvinus/recursive-resolver/security/advisories/new
about: Please report security issues privately, never as a public issue.
20 changes: 20 additions & 0 deletions .github/ISSUE_TEMPLATE/feature_request.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: Feature request
description: Suggest a capability or a change in behaviour
labels: ["enhancement"]
body:
- type: textarea
id: problem
attributes:
label: What problem does this solve?
description: The use case, not the implementation.
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed behaviour
- type: textarea
id: rfc
attributes:
label: Relevant RFCs or prior art
description: How do BIND, Unbound, Knot or PowerDNS handle this?
13 changes: 13 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
## What and why

<!-- What changes, and what problem it solves. -->

## Checklist

- [ ] `make check` passes (lint, format, types, offline tests)
- [ ] New behaviour has tests; security-relevant changes have a regression test in `tests/test_security.py`
- [ ] `CHANGELOG.md` updated
- [ ] If a defensive check was relaxed, the reasoning is stated in the PR body
- [ ] I am the author of this change and can stand behind it in review. If an AI
tool helped write it, I have read and understood the result, and no AI is
credited as an author or co-author (see CONTRIBUTING.md)
113 changes: 113 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
name: CI

on:
push:
branches: [main]
pull_request:
schedule:
# Weekly, so root-hint / trust-anchor drift and upstream breakage surface
# before a release rather than after one.
- cron: "17 5 * * 1"

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Set up Python ${{ matrix.python-version }}
run: uv python install ${{ matrix.python-version }}
# --python is explicit on purpose: without it uv picks its default
# interpreter, and every matrix job could silently test the same one.
- name: Install
run: uv sync --all-extras --python ${{ matrix.python-version }}
- name: Lint
run: uv run ruff check src/ tests/ scripts/
- name: Format check
run: uv run ruff format --check src/ tests/ scripts/
- name: Type check
run: uv run mypy src/
- name: Unit tests
run: uv run pytest -m "not integration" -q --no-cov

authors:
name: No AI co-authors
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
# The whole history, so the base commit of the PR is present.
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Check commit authorship
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: python scripts/check_authors.py "$BASE_SHA..$HEAD_SHA"

integration:
runs-on: ubuntu-latest
# Live DNS: without a bound, a hang runs to the 6-hour default.
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: astral-sh/setup-uv@v5
- run: uv python install 3.13
- run: uv sync --all-extras --python 3.13
- name: Full suite (live DNS + DNSSEC), 100% statement and branch coverage enforced
run: uv run pytest -q --cov

build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: astral-sh/setup-uv@v5
- run: uv python install 3.13
- name: Build sdist and wheel
run: uv build
- name: Check metadata
run: uvx twine check dist/*
- name: Verify the wheel ships py.typed
# `uv run --no-project python` rather than a bare `python`: this job
# never runs setup-python, and `uv python install` does not promise a
# `python` on PATH. zipfile also takes a single archive, so assert
# that dist/ holds exactly one wheel before looking inside it.
run: |
shopt -s nullglob
wheels=(dist/*.whl)
if [ ${#wheels[@]} -ne 1 ]; then
echo "expected exactly one wheel in dist/, found ${#wheels[@]}"; exit 1
fi
uv run --no-project python -m zipfile -l "${wheels[0]}" \
| grep -q 'recursive_resolver/py.typed' \
|| { echo "py.typed missing from the wheel"; exit 1; }
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Changelog

All notable changes to this project are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.1.0] - unreleased

First public release.
178 changes: 178 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
# Contributing

## Development setup

```bash
make install # uv sync
make test # unit tests (no network)
make test-integration # integration tests (needs network)
make coverage # coverage report
make lint # ruff
make format # ruff format
make typecheck # mypy
make check # lint, format, types and the offline tests
```

A Docker shell with `uv`, `make`, `dig` and the dependencies preinstalled:

```bash
make docker-shell
```

## AI-assisted contributions

Using an AI tool to help write a patch is fine. Three conditions apply.

**Merging is best-effort, and nothing more is promised.** A patch that arrives
with a lot of generated code still has to be read, understood and maintained by
someone. Large or unfocused submissions may be closed without a detailed review.
Small, well-scoped changes with tests are far more likely to land.

**The MIT licence still has to hold.** You are submitting the patch under
[LICENSE](LICENSE), so you need to be satisfied that nothing in it was copied
from an incompatible source. Generated code can reproduce training data, and
several of the reference resolvers cited in this project are copyleft. See
[THIRD-PARTY.md](THIRD-PARTY.md) for where the line sits.

**The human author is responsible.** You are the author of anything you submit,
whatever produced the first draft: you are expected to understand it, to stand
behind it in review, and to have run the tests. Do not credit an AI tool as an
author or co-author. `Co-authored-by:` trailers naming Claude, Codex, Copilot or
any other tool will be removed before merge. This is enforced: CI runs
`scripts/check_authors.py` over every commit in a pull request, checking the
author, the committer and any `Co-authored-by:` trailer.

## Licensing

This package is MIT and every runtime dependency is permissive. Before copying
anything in, check [THIRD-PARTY.md](THIRD-PARTY.md) and note that several
reference resolvers are **copyleft**: PowerDNS Recursor and Knot Resolver are
GPL, BIND 9 is MPL-2.0. Their *behaviour* is fair to cite: parameter names and
default values are facts, and recording them lets a reader check our choices
against the state of the art. Their code, documentation text and curated data
(prefix lists, tables) are not, and must not be pasted in.

If you adapt from a permissively-licensed project, put the copyright notice in
the file header and add a row to `THIRD-PARTY.md`.

## Guidelines

- **Security changes need a regression test.** `tests/test_security.py` is
organised by the attack each control prevents; add yours there with a comment
explaining what breaks without it.
- **Do not loosen a defensive check without saying why.** The referral,
bailiwick, AA, class and address rules each exist because a specific attack or
real-world failure was observed. `CHANGELOG.md` records which.
- **Keep dnspython exceptions inside the resolver.** Everything raised out of
the public API must be a `ResolverError`. Bare `except Exception` is not
acceptable in the query path: it is what previously turned malformed input
into phantom network timeouts.
- Unit tests must not touch the network. Use the builders in `tests/conftest.py`
and patch `_send_query`.
- Integration tests go behind the `integration` marker.

## Differential testing

Beyond the unit and integration suites, `scripts/diff_harness.py` compares this
resolver against reference resolvers across a large corpus, broken down by
record type, corpus category and resolver configuration.

```bash
# Build a deliberately awkward corpus (~4k names).
python scripts/collect_domains_diverse.py -o domains.csv

# Compare against dig, all supported types, one configuration.
python scripts/diff_harness.py --csv domains.csv

# Compare configurations against each other on a sample.
python scripts/diff_harness.py --csv domains.csv --sample 250 \
--types A,MX,TXT,NS --configs default,no-dnssec,no-cache,no-tcp,small-edns,ipv6
```

The corpus is assembled from Tranco sampled across popularity bands (the tail is
where broken configurations live), every IANA TLD including the IDN ones,
multi-label public suffixes, mail and underscore subdomains layered onto popular
domains, and a curated set of pathological cases: signed and deliberately-bogus
zones, IDN homographs, shared zone cuts, empty non-terminals, CNAME chains.

Two things matter when reading the output:

* The reference is queried **by explicit IP**, never through the system stub. A
local `systemd-resolved` synthesises a self-referential `CNAME` with TTL 0 for
names that have none, which makes every CNAME query look like a mismatch.
* Several references are queried and matching **any** of them counts as
agreement. Zones whose parent delegation disagrees with their own NS RRset
make public recursives disagree with each other; those are reported separately
rather than counted against either resolver.

`scripts/prepare_test_domains.py` and the `collect_domains_*.py` scripts build
alternative corpora, and `tests/test_csv.py` runs a smaller version under
pytest:

```bash
make test-from-csv CSV=domains.csv
```

By default this covers every supported type that a list of apex domains can
exercise: `A, AAAA, MX, TXT, NS, SOA, CAA, CNAME, DS, DNSKEY`. A type the domain
does not publish yields NODATA on both sides, which is still a meaningful
agreement.

`PTR`, `SRV` and `NAPTR` are excluded from this harness because apex domains
cannot exercise them: PTR needs an IP address and SRV/NAPTR need
`_service._proto` labels. They are covered by the unit and integration suites.

Mismatches are written to a timestamped JSONL file. The test asserts an overall
match rate of at least 96%; the residual differences are CDN round-robin and
domains with genuinely dead nameservers. DNSSEC is disabled for this comparison
because `dig +short` does not validate either.

## Releasing

1. **Bump the version** in both `pyproject.toml` and
`src/recursive_resolver/__init__.py`, and add a `CHANGELOG.md` entry.
2. **Run `scripts/release.sh`.**

```bash
./scripts/release.sh
```

The script is interactive and every step is a y/N gate, so you can read what it
found before continuing. It:

1. checks the tree is clean, the two version strings agree, the changelog has an
entry and the tag does not already exist;
2. runs `make check-all`;
3. builds the sdist and wheel **inside Docker**, so the host never needs `pip`,
`build` or `twine`, and runs `twine check`;
4. inspects the artifacts against what this package promises: `py.typed`
present, no tests or scripts in the wheel, LICENSE bundled, and the
`dnspython` floor at or above the 2.8.0 security floor with the `dnssec` and
`idna` extras. It reads these out of the built wheel's `METADATA`, not out
of `pyproject.toml`, so a build that lost them is caught;
5. uploads to TestPyPI, then installs from there in a throwaway container and
**actually resolves a name**, checking that a signed zone validates and that
a multi-chunk DKIM key joins without a separator;
6. uploads to PyPI;
7. tags, pushes, and creates the GitHub release with the artifacts attached and
the changelog section as the notes.

`SKIP_GATES=1` skips step 2 on a retry; `SKIP_TESTPYPI=1` goes straight to PyPI.

### Prerequisites

Docker, git, and, for the last step only, an authenticated `gh` CLI. You will
be prompted for the TestPyPI and PyPI API tokens; they are read into the
container's environment and never written to disk.

### Root hints and trust anchors

`src/recursive_resolver/roots.py` contains hardcoded root server addresses and
DNSSEC trust anchors. Re-check them against the upstream sources when preparing
a release:

- Root hints: <https://www.internic.net/domain/named.root>
- Trust anchors: <https://data.iana.org/root-anchors/root-anchors.xml>

Only anchors that are currently valid belong in the file: retired ones (such as
KSK-2010, tag 19036) must be removed, not kept "just in case".
Loading
Loading