├── .github
└── workflows
│ └── ci.yml
├── .gitignore
├── Dockerfile
├── LICENSE
├── Makefile
├── README.md
├── check_xmpp_dns.py
├── poetry.lock
├── pyproject.toml
└── templates
├── index_base.html.jinja
├── index_with_lookup_error.html.jinja
└── index_with_successful_lookup.html.jinja
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: Lint, check formatting, and build and publish Docker image
2 |
3 | on:
4 | push:
5 | pull_request:
6 |
7 | permissions: {}
8 |
9 | jobs:
10 | lint:
11 | permissions:
12 | contents: read
13 |
14 | runs-on: ubuntu-latest
15 |
16 | steps:
17 | - name: Check out repo
18 | uses: actions/checkout@v4
19 |
20 | - name: Set up Python
21 | uses: actions/setup-python@v5
22 | with:
23 | python-version: "3.10"
24 |
25 | - name: Install dependencies
26 | run: |
27 | pipx install poetry==2.0.1
28 | poetry install --no-root --with=dev
29 |
30 | - name: Lint code with Ruff
31 | run: poetry run ruff check --output-format=github check_xmpp_dns.py
32 |
33 | - name: Check code formatting with Ruff
34 | run: poetry run ruff format --diff check_xmpp_dns.py
35 |
36 | - name: Check types with Mypy
37 | run: poetry run mypy check_xmpp_dns.py
38 |
39 | build-and-push-docker-image:
40 | # This job was copied from https://docs.github.com/en/actions/use-cases-and-examples/publishing-packages/publishing-docker-images#publishing-images-to-github-packages
41 | # See that page for updates and more details.
42 |
43 | needs: lint
44 |
45 | permissions:
46 | contents: read
47 | packages: write
48 | attestations: write
49 | id-token: write
50 |
51 | runs-on: ubuntu-latest
52 |
53 | env:
54 | REGISTRY: ghcr.io
55 | IMAGE_NAME: ${{ github.repository }}
56 | SHOULD_PUSH: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
57 |
58 | steps:
59 | - name: Check out repo
60 | uses: actions/checkout@v4
61 |
62 | # Log in to the container registry using the account and password that will publish the
63 | # packages. Once published, the packages are scoped to the account defined here.
64 | # For more info see https://github.com/docker/login-action
65 | - name: Log in to the container registry
66 | uses: docker/login-action@v3
67 | with:
68 | registry: ${{ env.REGISTRY }}
69 | username: ${{ github.actor }}
70 | password: ${{ secrets.GITHUB_TOKEN }}
71 |
72 | # Extract tags and labels and save in the "meta" id.
73 | # The `images` value provides the base name for the tags and labels.
74 | # For more info see https://github.com/docker/metadata-action
75 | - name: Extract metadata (tags, labels) for Docker
76 | id: meta
77 | uses: docker/metadata-action@v5
78 | with:
79 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
80 |
81 | # This is needed for multi-platform builds.
82 | - name: Set up Docker Buildx
83 | uses: docker/setup-buildx-action@v3
84 |
85 | # Build the image using the repo's Dockerfile and push to the registry.
86 | # For more info see https://github.com/docker/build-push-action
87 | - name: Build Docker image. Push to registry if building the branch "main"
88 | id: push
89 | uses: docker/build-push-action@v6
90 | with:
91 | context: .
92 | platforms: linux/amd64,linux/arm64
93 | push: ${{ env.SHOULD_PUSH }}
94 | # "steps.meta" refers to the fields from the extract step above.
95 | tags: ${{ steps.meta.outputs.tags }}
96 | labels: ${{ steps.meta.outputs.labels }}
97 |
98 | # Generate an artifact attestation for the image, which is an unforgeable statement about
99 | # where and how it was built. It increases supply chain security for people who consume the
100 | # image.
101 | # For more info see https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations
102 | - name: Generate artifact attestation
103 | if: ${{ env.SHOULD_PUSH == 'true' }}
104 | uses: actions/attest-build-provenance@v2
105 | with:
106 | subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
107 | subject-digest: ${{ steps.push.outputs.digest }}
108 | push-to-registry: true
109 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Files in any directory.
2 | *.pyc
3 |
4 | # Specific files.
5 | /.venv/
6 | /requestledger.txt
7 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # These steps are loosely ordered from "rarely changes" to "frequently changes" to take advantage
2 | # of Docker build caching, so that builds during the development process are as fast as possible.
3 |
4 | FROM python:3.13-alpine
5 |
6 | WORKDIR /app
7 |
8 | # Create a user for running the application, to avoid running as root.
9 | RUN ["addgroup", "--system", "check-xmpp-dns"]
10 | RUN ["adduser", "--disabled-password", "--home", "/app", "--ingroup", "check-xmpp-dns", "--no-create-home", "--shell", "/bin/false", "--system", "check-xmpp-dns"]
11 |
12 | # Create a directory for the application to write logs to.
13 | # The "install" command is used to create the directory with the desired ownership using a single
14 | # command, to avoid having to run two commands (mkdir+chown).
15 | RUN ["install", "-o", "check-xmpp-dns", "-g", "check-xmpp-dns", "-d", "/var/log/check-xmpp-dns"]
16 |
17 | # Install poetry.
18 | # The version is pinned as a best practice. It should be bumped regularly.
19 | RUN ["pip", "install", "poetry==2.0.1"]
20 |
21 | # Copy the minimum files needed to install dependencies.
22 | # This is done at an early Docker layer so they don't need to be reinstalled each time the
23 | # check-xmpp-dns source changes. chmod is used to remove write access.
24 | COPY --chmod=444 poetry.lock pyproject.toml .
25 |
26 | # Configure poetry to put the virtual environment in the local directory (/app).
27 | # This is important because dependencies are installed as root but the app is run as a non-root
28 | # user, so without this poetry would look for the virtual environment in a different location.
29 | ENV POETRY_VIRTUALENVS_IN_PROJECT=true
30 |
31 | # Configure poetry not to install pip in the virtualenv that it creates. It's not needed.
32 | ENV POETRY_VIRTUALENVS_OPTIONS_NO_PIP=true
33 |
34 | # Install dependencies.
35 | RUN ["poetry", "install", "--compile", "--no-ansi", "--no-cache", "--no-directory", "--no-interaction", "--no-root", "--only=main"]
36 |
37 | # Copy application files.
38 | # chmod is used to remove write access.
39 | COPY --chmod=444 templates templates
40 | COPY --chmod=555 check_xmpp_dns.py .
41 |
42 | # Compile Python files for faster execution.
43 | RUN ["python", "-m", "compileall", "check_xmpp_dns.py"]
44 |
45 | USER check-xmpp-dns
46 | EXPOSE 8000
47 |
48 | # ENTRYPOINT specifies the command+args that are appropriate always.
49 | # port is specified even though 8000 is the default so that the behavior of this container doesn't
50 | # change if the default value is changed in Uvicorn.
51 | ENTRYPOINT ["poetry", "run", "uvicorn", "--factory", "--host=0.0.0.0", "--port=8000", "check_xmpp_dns:application"]
52 |
53 | # CMD specifies reasonable default args that someone might conceivably want to change.
54 | CMD ["--no-server-header", "--root-path=/check_xmpp_dns"]
55 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | All files in this repository are licensed as follows:
2 |
3 | BSD 2-Clause License (aka "Simplified BSD License" or "FreeBSD License")
4 |
5 | Copyright (c) 2011-2014,2019,2022,2024, Mark Doliner
6 |
7 | Redistribution and use in source and binary forms, with or without
8 | modification, are permitted provided that the following conditions are met:
9 |
10 | 1. Redistributions of source code must retain the above copyright notice, this
11 | list of conditions and the following disclaimer.
12 |
13 | 2. Redistributions in binary form must reproduce the above copyright notice,
14 | this list of conditions and the following disclaimer in the documentation
15 | and/or other materials provided with the distribution.
16 |
17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
18 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
19 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
20 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
21 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
22 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
23 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
24 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
25 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .DEFAULT_GOAL:=check
2 |
3 | .PHONY: install-deps
4 | install-deps:
5 | poetry install --no-root --with=dev
6 |
7 | .PHONY: check
8 | check:
9 | poetry run ruff format check_xmpp_dns.py
10 | poetry run ruff check --fix check_xmpp_dns.py
11 | poetry run mypy check_xmpp_dns.py
12 |
13 | .PHONY: run-local
14 | run-local:
15 | CHECK_XMPP_DNS_REQUEST_LEDGER_FILENAME=./requestledger.txt poetry run uvicorn --factory --no-server-header --reload check_xmpp_dns:application
16 |
17 | .PHONY: docker
18 | docker:
19 | docker build --tag=check-xmpp-dns .
20 |
21 | .PHONY: run-docker
22 | run-docker: docker
23 | docker run --name check-xmpp-dns --publish=127.0.0.1:8000:8000/tcp --rm check-xmpp-dns
24 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Overview
2 | ========
3 | This is a python ASGI application that prints the DNS SRV records used
4 | for XMPP. You can see it running at https://kingant.net/check_xmpp_dns/
5 |
6 | Source available at https://github.com/markdoliner/check_xmpp_dns
7 |
8 |
9 | Dependencies
10 | ============
11 | * Python dns library. Used to query DNS servers directly.
12 | * Homepage: http://www.dnspython.org/
13 | * Python jinja library. Used to render the HTML templates.
14 | * Homepage: https://jinja.palletsprojects.com/
15 | * Python Starlette library.
16 | * Homepage: https://www.starlette.io/
17 | * Uvicorn. Or you could use any other ASGI server.
18 | * Homepage: https://www.uvicorn.org/
19 |
20 |
21 | Local Development
22 | =================
23 | First-time setup:
24 | 1. Install Poetry. The steps for doing so are system-dependent. One option on macOS is `brew
25 | install poetry`
26 | 2. ```
27 | poetry install --no-root --with=dev
28 | ```
29 |
30 | To run a local server that automatically reloads when code changes, run:
31 | ```
32 | make run-local
33 | ```
34 | Then open http://localhost:8000/ in a web browser.
35 |
36 | To check code style:
37 | ```
38 | make check
39 | ```
40 |
41 |
42 | Running in Production
43 | =====================
44 | I suggest running as a Docker container.
45 |
46 | To build the Docker image:
47 | ```
48 | make docker
49 | ```
50 |
51 | To run the Docker image:
52 | ```
53 | make run-docker
54 | ```
55 |
56 | It uses the Uvicorn ASGI server. If you wish to use another ASGI server you'll have to figure out
57 | the steps yourself.
58 |
59 | The application does not configure Python logging itself. It's expected that the ASGI will do this.
60 | Uvicorn _does_ configure logging, and writes various information to stdout.
61 |
62 | The application records each hostname that was searched for in
63 | /var/log/check-xmpp-dns/requestledger.txt
64 | You can bind mount this directory and collect the logs, do log rotation, etc.
65 |
--------------------------------------------------------------------------------
/check_xmpp_dns.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | # Licensed as follows (this is the 2-clause BSD license, aka
4 | # "Simplified BSD License" or "FreeBSD License"):
5 | #
6 | # Copyright (c) 2011-2014,2019,2022,2024 Mark Doliner
7 | # All rights reserved.
8 | #
9 | # Redistribution and use in source and binary forms, with or without
10 | # modification, are permitted provided that the following conditions are met:
11 | # - Redistributions of source code must retain the above copyright notice,
12 | # this list of conditions and the following disclaimer.
13 | # - Redistributions in binary form must reproduce the above copyright notice,
14 | # this list of conditions and the following disclaimer in the documentation
15 | # and/or other materials provided with the distribution.
16 | #
17 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
18 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
19 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
20 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
21 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
22 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
23 | # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
24 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
25 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
26 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
27 | # POSSIBILITY OF SUCH DAMAGE.
28 |
29 | # TODO: Maybe print a friendly warning if a DNS server returns a CNAME record
30 | # when we query for DNS SRV? It's possible this is legit, and we should
31 | # recursively query the CNAME record for DNS SRV. But surely not all
32 | # XMPP clients will do that.
33 | # TODO: Add mild info/warning if client and server records point to different
34 | # hosts? Or resolve to different hosts? (aegee.org)
35 | # TODO: Better info message if port is 443? Talk about how clients might not
36 | # realize they need to use ssl? (buddycloud.com)
37 | # TODO: Better info message if port is 5223? Talk about how clients might not
38 | # realize they need to use ssl? (biscotti.com)
39 | # TODO: Make sure record.target ends with a period?
40 | # TODO: Add JavaScript to strip leading and trailing whitespace in the
41 | # hostname before submitting the form, so that the hostname is pretty in
42 | # the URL.
43 |
44 | import collections.abc
45 | import contextlib
46 | import enum
47 | import logging
48 | import os
49 | import typing
50 | import urllib.parse
51 |
52 | # import cgitb; cgitb.enable()
53 | import anyio
54 | import dns.asyncresolver
55 | import dns.exception
56 | import dns.name
57 | import dns.nameserver
58 | import dns.rdata
59 | import dns.rdatatype
60 | import dns.rdtypes.IN.A
61 | import dns.rdtypes.IN.AAAA
62 | import dns.rdtypes.IN.SRV
63 | import dns.resolver
64 | import jinja2
65 | import starlette.applications
66 | import starlette.requests
67 | import starlette.responses
68 | import starlette.routing
69 |
70 | _REQUEST_LEDGER_FILENAME_ENV_VAR: typing.Final = "CHECK_XMPP_DNS_REQUEST_LEDGER_FILENAME"
71 | _REQUEST_LEDGER_DEFAULT_FILENAME: typing.Final = "/var/log/check-xmpp-dns/requestledger.txt"
72 |
73 | _jinja2_env: jinja2.Environment | None = None
74 |
75 | logger: typing.Final = logging.getLogger(__name__)
76 |
77 |
78 | @enum.unique
79 | class ClientOrServerType(enum.Enum):
80 | CLIENT = "client"
81 | SERVER = "server"
82 |
83 |
84 | @enum.unique
85 | class TlsType(enum.Enum):
86 | # The normal XMPP ports.
87 | # These come from either _xmpp-client._tcp.example.com
88 | # or _xmpp-server._tcp.example.com DNS records.
89 | STARTTLS = "STARTTLS"
90 |
91 | # Ports that require TLS negotiation immediately upon connecting,
92 | # as discussed in XEP-0368.
93 | # These come from either _xmpps-client._tcp.example.com
94 | # or _xmpps-server._tcp.example.com DNS records.
95 | DIRECT_TLS = "Direct TLS"
96 |
97 |
98 | STANDARD_PORTS = {
99 | (ClientOrServerType.CLIENT, TlsType.STARTTLS): 5222,
100 | (ClientOrServerType.CLIENT, TlsType.DIRECT_TLS): 5223,
101 | (ClientOrServerType.SERVER, TlsType.STARTTLS): 5269,
102 | (ClientOrServerType.SERVER, TlsType.DIRECT_TLS): 5270,
103 | }
104 |
105 |
106 | class AnswerTuple(typing.NamedTuple):
107 | answer: dns.resolver.Answer | None
108 | client_or_server: ClientOrServerType
109 | tls_type: TlsType
110 |
111 |
112 | @enum.unique
113 | class NoteType(enum.Enum):
114 | DIRECT_TLS = enum.auto()
115 | NON_STANDARD_PORT = enum.auto()
116 | PORT_REUSED_WITH_BOTH_TYPES_DIFFERENT = enum.auto()
117 | PORT_REUSED_WITH_DIFFERENT_CLIENT_OR_SERVER_TYPE = enum.auto()
118 | PORT_REUSED_WITH_DIFFERENT_TLS_TYPE = enum.auto()
119 |
120 |
121 | class NoteForDisplay(typing.NamedTuple):
122 | note_type: NoteType
123 | note: str
124 |
125 |
126 | class RecordForDisplay(typing.NamedTuple):
127 | port: int
128 | priority: int
129 | target: str
130 | weight: int
131 | notes: list[NoteForDisplay]
132 |
133 |
134 | def _get_jinja2_env() -> jinja2.Environment:
135 | global _jinja2_env
136 |
137 | if _jinja2_env is None:
138 | _jinja2_env = jinja2.Environment(
139 | autoescape=True,
140 | enable_async=True,
141 | loader=jinja2.FileSystemLoader("templates"),
142 | undefined=jinja2.StrictUndefined,
143 | )
144 | _jinja2_env.globals = {"NoteType": NoteType}
145 |
146 | return _jinja2_env
147 |
148 |
149 | async def _append_to_request_ledger(hostname: str) -> None:
150 | request_ledger_filename = os.environ.get(_REQUEST_LEDGER_FILENAME_ENV_VAR) or _REQUEST_LEDGER_DEFAULT_FILENAME
151 | async with await anyio.open_file(request_ledger_filename, "a") as f:
152 | await f.write("%s\n" % urllib.parse.quote(hostname))
153 |
154 |
155 | def _sort_records_for_display(records: list[RecordForDisplay]) -> list[RecordForDisplay]:
156 | return sorted(
157 | records,
158 | key=lambda record: "%10d %10d %50s %d"
159 | % (record.priority, 1000000000 - record.weight, record.target, record.port),
160 | )
161 |
162 |
163 | def _assert_srv_records(answer: dns.resolver.Answer) -> collections.abc.Iterator[dns.rdtypes.IN.SRV.SRV]:
164 | """Return an iterator through the Answer's records, asserting that they're all SRV records."""
165 | for record in answer:
166 | if not isinstance(record, dns.rdtypes.IN.SRV.SRV):
167 | message = f"record type should have been dns.rdtypes.IN.SRV.SRV but was {type(record)}"
168 | raise AssertionError(message)
169 | yield record
170 |
171 |
172 | def _build_records_for_display(
173 | answers_list: list[AnswerTuple], client_or_server: ClientOrServerType
174 | ) -> tuple[list[RecordForDisplay], dict[NoteType, int]]:
175 | records_for_display = list[RecordForDisplay]()
176 |
177 | for answers in answers_list:
178 | if answers.client_or_server == client_or_server and answers.answer is not None:
179 | for record in _assert_srv_records(answers.answer):
180 | records_for_display.append(_build_record_for_display(record, answers, answers_list))
181 |
182 | sorted_records_for_display = _sort_records_for_display(records_for_display)
183 |
184 | # dict is used here rather than set so that order is preserved.
185 | # (As of Python 3.7 according to https://stackoverflow.com/a/39980744/1634007)
186 | note_types_used_by_these_records = {
187 | note.note_type: None for record in sorted_records_for_display for note in record.notes
188 | }
189 | note_types_to_footnote_indexes = {
190 | note_type: index + 1 for index, note_type in enumerate(note_types_used_by_these_records)
191 | }
192 |
193 | return (
194 | sorted_records_for_display,
195 | note_types_to_footnote_indexes,
196 | )
197 |
198 |
199 | def _has_record_for_host_and_port(answers: AnswerTuple, target: dns.name.Name, port: int) -> bool:
200 | """Return True if any record in `answers` points to the given
201 | `target` and `port`.
202 | """
203 | return answers.answer is not None and any(
204 | record.target == target and record.port == port for record in _assert_srv_records(answers.answer)
205 | )
206 |
207 |
208 | def _build_record_for_display(
209 | record: dns.rdtypes.IN.SRV.SRV,
210 | answers: AnswerTuple,
211 | answers_list: list[AnswerTuple],
212 | ) -> RecordForDisplay:
213 | notes = list[NoteForDisplay]()
214 |
215 | if answers.tls_type == TlsType.DIRECT_TLS:
216 | notes.append(NoteForDisplay(NoteType.DIRECT_TLS, "This is a Direct TLS port."))
217 |
218 | # This note isn't displayed for Direct TLS ports because
219 | # Direct TLS isn't formally standardized so there is no
220 | # standard port (though, yes, historically 5223 has been
221 | # used for client connections and 5270 has been used for
222 | # server connections).
223 | standard_port = STANDARD_PORTS[(answers.client_or_server, answers.tls_type)]
224 | if answers.tls_type == TlsType.STARTTLS and record.port != standard_port:
225 | notes.append(NoteForDisplay(NoteType.NON_STANDARD_PORT, "Non-standard port."))
226 |
227 | # Look for the same hostname and port in the responses from the
228 | # other queries.
229 | for other_answers in answers_list:
230 | if other_answers == answers:
231 | # This is the same set of answers that this record came
232 | # from. No need to check for collisions.
233 | continue
234 |
235 | if _has_record_for_host_and_port(other_answers, record.target, record.port):
236 | if other_answers.client_or_server == answers.client_or_server:
237 | note_type = NoteType.PORT_REUSED_WITH_DIFFERENT_TLS_TYPE
238 | elif other_answers.tls_type == answers.tls_type:
239 | note_type = NoteType.PORT_REUSED_WITH_DIFFERENT_CLIENT_OR_SERVER_TYPE
240 | else:
241 | note_type = NoteType.PORT_REUSED_WITH_BOTH_TYPES_DIFFERENT
242 | notes.append(
243 | NoteForDisplay(
244 | note_type,
245 | f"This host+port is also advertised as a {other_answers.tls_type.value} "
246 | f"record for {other_answers.client_or_server.value}s.",
247 | )
248 | )
249 |
250 | return RecordForDisplay(
251 | port=record.port,
252 | priority=record.priority,
253 | # Remove the trailing period for display.
254 | target=str(record.target).rstrip("."),
255 | weight=record.weight,
256 | notes=notes,
257 | )
258 |
259 |
260 | async def _get_authoritative_name_servers_for_domain(
261 | domain: str,
262 | ) -> collections.abc.Sequence[str | dns.nameserver.Nameserver] | None:
263 | """Return a list of strings containing IP addresses of the name servers
264 | that are considered to be authoritative for the given domain.
265 |
266 | This iteratively queries the name server responsible for each piece of the
267 | FQDN directly, so as to avoid any caching of results.
268 | """
269 |
270 | # Create a DNS resolver to use for these requests
271 | dns_resolver = dns.asyncresolver.Resolver()
272 |
273 | # Set a 2.5 second timeout on the resolver
274 | dns_resolver.lifetime = 2.5
275 |
276 | # Iterate through the pieces of the hostname from broad to narrow. For
277 | # each piece, ask which name servers are authoritative for the next
278 | # narrower piece. Note that we don't bother doing this for the broadest
279 | # piece of the hostname (e.g. 'com' or 'net') because the list of root
280 | # servers should rarely change and changes shouldn't affect the outcome
281 | # of these queries.
282 | pieces = domain.split(".")
283 | for i in range(len(pieces) - 1, 0, -1):
284 | broader_domain = ".".join(pieces[i - 1 :])
285 | try:
286 | answer = await dns_resolver.resolve(broader_domain, dns.rdatatype.NS)
287 | except dns.exception.SyntaxError:
288 | # TODO: Show "invalid hostname" for this?
289 | return None
290 | except dns.resolver.NXDOMAIN:
291 | # TODO: Show an error message about this. "Unable to determine
292 | # authoritative name servers for domain X. These results might be
293 | # stale, up to the lifetime of the TTL."
294 | return None
295 | except dns.resolver.NoAnswer:
296 | # TODO: Show an error message about this. "Unable to determine
297 | # authoritative name servers for domain X. These results might be
298 | # stale, up to the lifetime of the TTL."
299 | return None
300 | except dns.resolver.NoNameservers:
301 | # TODO: Show an error message about this. "Unable to determine
302 | # authoritative name servers for domain X. These results might be
303 | # stale, up to the lifetime of the TTL."
304 | return None
305 | except dns.resolver.Timeout:
306 | # TODO: Show an error message about this. "Unable to determine
307 | # authoritative name servers for domain X. These results might be
308 | # stale, up to the lifetime of the TTL."
309 | return None
310 |
311 | new_nameservers = list[str]()
312 | for record in answer:
313 | if record.rdtype == dns.rdatatype.NS:
314 | # Got the hostname of a nameserver. Resolve it to an IP.
315 | # TODO: Don't do this if the nameserver we just queried gave us
316 | # an additional record that includes the IP.
317 | try:
318 | answer2 = await dns_resolver.resolve(record.to_text())
319 | except dns.exception.SyntaxError:
320 | # TODO: Show "invalid hostname" for this?
321 | return None
322 | except dns.resolver.NXDOMAIN:
323 | # TODO: Show an error message about this. "Unable to determine
324 | # authoritative name servers for domain X. These results might be
325 | # stale, up to the lifetime of the TTL."
326 | return None
327 | except dns.resolver.NoAnswer:
328 | # TODO: Show an error message about this. "Unable to determine
329 | # authoritative name servers for domain X. These results might be
330 | # stale, up to the lifetime of the TTL."
331 | return None
332 | except dns.resolver.NoNameservers:
333 | # TODO: Show an error message about this. "Unable to determine
334 | # authoritative name servers for domain X. These results might be
335 | # stale, up to the lifetime of the TTL."
336 | return None
337 | except dns.resolver.Timeout:
338 | # TODO: Show an error message about this. "Unable to determine
339 | # authoritative name servers for domain X. These results might be
340 | # stale, up to the lifetime of the TTL."
341 | return None
342 | for record2 in answer2:
343 | if isinstance(record2, (dns.rdtypes.IN.A.A, dns.rdtypes.IN.AAAA.AAAA)):
344 | # Add the IP to the list of IPs.
345 | new_nameservers.append(record2.address)
346 | else:
347 | # Unexpected record type
348 | # TODO: Log something?
349 | pass
350 | else:
351 | # Unexpected record type
352 | # TODO: Log something?
353 | pass
354 |
355 | dns_resolver.nameservers = new_nameservers
356 |
357 | return dns_resolver.nameservers
358 |
359 |
360 | async def _resolve_srv(dns_resolver: dns.asyncresolver.Resolver, qname: str) -> dns.resolver.Answer | None:
361 | try:
362 | records = await dns_resolver.resolve(qname, rdtype=dns.rdatatype.SRV)
363 | except dns.exception.SyntaxError:
364 | # TODO: Show "invalid hostname" for this
365 | records = None
366 | except dns.resolver.NXDOMAIN:
367 | records = None
368 | except dns.resolver.NoAnswer:
369 | # TODO: Show a specific message for this
370 | records = None
371 | except dns.resolver.NoNameservers:
372 | # TODO: Show a specific message for this
373 | records = None
374 | except dns.resolver.Timeout:
375 | # TODO: Show a specific message for this
376 | records = None
377 | return records
378 |
379 |
380 | async def _look_up_records(hostname: str) -> str:
381 | """Looks up the DNS records for the given hostname and returns a str containing the full HTML
382 | response body with the results.
383 | """
384 | await _append_to_request_ledger(hostname)
385 |
386 | # Sanity check hostname
387 | if ".." in hostname:
388 | return await (
389 | _get_jinja2_env()
390 | .get_template("index_with_lookup_error.html.jinja")
391 | .render_async(hostname=hostname, error_message="Invalid hostname")
392 | )
393 |
394 | # Look up the list of authoritative name servers for this domain and query
395 | # them directly when looking up XMPP SRV records. We do this to avoid any
396 | # potential caching from intermediate name servers.
397 | authoritative_nameservers = await _get_authoritative_name_servers_for_domain(hostname)
398 | if not authoritative_nameservers:
399 | # Could not determine authoritative name servers for domain.
400 | return await (
401 | _get_jinja2_env()
402 | .get_template("index_with_lookup_error.html.jinja")
403 | .render_async(
404 | hostname=hostname, error_message="Could not determine the authoritative name servers for this domain."
405 | )
406 | )
407 |
408 | # Create a DNS resolver to use for this request
409 | dns_resolver = dns.asyncresolver.Resolver()
410 |
411 | # Set a 2.5 second timeout on the resolver
412 | dns_resolver.lifetime = 2.5
413 |
414 | dns_resolver.nameservers = authoritative_nameservers
415 |
416 | # Look up records using four queries.
417 | answers = list[AnswerTuple]()
418 | answers.append(
419 | AnswerTuple(
420 | await _resolve_srv(dns_resolver, "_xmpp-client._tcp.%s" % hostname),
421 | ClientOrServerType.CLIENT,
422 | TlsType.STARTTLS,
423 | )
424 | )
425 | answers.append(
426 | AnswerTuple(
427 | await _resolve_srv(dns_resolver, "_xmpps-client._tcp.%s" % hostname),
428 | ClientOrServerType.CLIENT,
429 | TlsType.DIRECT_TLS,
430 | )
431 | )
432 | answers.append(
433 | AnswerTuple(
434 | await _resolve_srv(dns_resolver, "_xmpp-server._tcp.%s" % hostname),
435 | ClientOrServerType.SERVER,
436 | TlsType.STARTTLS,
437 | )
438 | )
439 | answers.append(
440 | AnswerTuple(
441 | await _resolve_srv(dns_resolver, "_xmpps-server._tcp.%s" % hostname),
442 | ClientOrServerType.SERVER,
443 | TlsType.DIRECT_TLS,
444 | )
445 | )
446 |
447 | # Convert the DNS responses into data that's easier to insert
448 | # into a template.
449 | (
450 | client_records_for_display,
451 | client_record_note_types_to_footnote_indexes,
452 | ) = _build_records_for_display(answers, ClientOrServerType.CLIENT)
453 | (
454 | server_records_for_display,
455 | server_record_note_types_to_footnote_indexes,
456 | ) = _build_records_for_display(answers, ClientOrServerType.SERVER)
457 |
458 | # Render the template.
459 | return await (
460 | _get_jinja2_env()
461 | .get_template("index_with_successful_lookup.html.jinja")
462 | .render_async(
463 | hostname=hostname,
464 | client_records=client_records_for_display,
465 | client_record_note_types_to_footnote_indexes=client_record_note_types_to_footnote_indexes,
466 | server_records=server_records_for_display,
467 | server_record_note_types_to_footnote_indexes=server_record_note_types_to_footnote_indexes,
468 | )
469 | )
470 |
471 |
472 | async def _handle_root(request: starlette.requests.Request) -> starlette.responses.HTMLResponse:
473 | try:
474 | hostname = request.query_params.get("h")
475 | if hostname:
476 | response_body = await _look_up_records(hostname)
477 | else:
478 | response_body = await _get_jinja2_env().get_template("index_base.html.jinja").render_async(hostname="")
479 |
480 | return starlette.responses.HTMLResponse(response_body, headers={"Cache-Control": "no-cache"})
481 | except Exception:
482 | logger.exception("Unknown error handling request.")
483 | raise
484 |
485 |
486 | @contextlib.asynccontextmanager
487 | async def _lifespan(_app: starlette.applications.Starlette) -> collections.abc.AsyncGenerator[None, None]:
488 | logging.basicConfig(level="INFO")
489 |
490 | # Initialize the jinja2_env global.
491 | _get_jinja2_env()
492 |
493 | yield
494 |
495 |
496 | def application() -> starlette.applications.Starlette:
497 | return starlette.applications.Starlette(
498 | lifespan=_lifespan,
499 | routes=[
500 | starlette.routing.Route("/", _handle_root),
501 | ],
502 | )
503 |
--------------------------------------------------------------------------------
/poetry.lock:
--------------------------------------------------------------------------------
1 | # This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand.
2 |
3 | [[package]]
4 | name = "anyio"
5 | version = "4.8.0"
6 | description = "High level compatibility layer for multiple asynchronous event loop implementations"
7 | optional = false
8 | python-versions = ">=3.9"
9 | groups = ["main"]
10 | files = [
11 | {file = "anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a"},
12 | {file = "anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a"},
13 | ]
14 |
15 | [package.dependencies]
16 | exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""}
17 | idna = ">=2.8"
18 | sniffio = ">=1.1"
19 | typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""}
20 |
21 | [package.extras]
22 | doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"]
23 | test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""]
24 | trio = ["trio (>=0.26.1)"]
25 |
26 | [[package]]
27 | name = "click"
28 | version = "8.1.8"
29 | description = "Composable command line interface toolkit"
30 | optional = false
31 | python-versions = ">=3.7"
32 | groups = ["main"]
33 | files = [
34 | {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"},
35 | {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"},
36 | ]
37 |
38 | [package.dependencies]
39 | colorama = {version = "*", markers = "platform_system == \"Windows\""}
40 |
41 | [[package]]
42 | name = "colorama"
43 | version = "0.4.6"
44 | description = "Cross-platform colored terminal text."
45 | optional = false
46 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
47 | groups = ["main"]
48 | markers = "platform_system == \"Windows\""
49 | files = [
50 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
51 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
52 | ]
53 |
54 | [[package]]
55 | name = "dnspython"
56 | version = "2.7.0"
57 | description = "DNS toolkit"
58 | optional = false
59 | python-versions = ">=3.9"
60 | groups = ["main"]
61 | files = [
62 | {file = "dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86"},
63 | {file = "dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1"},
64 | ]
65 |
66 | [package.extras]
67 | dev = ["black (>=23.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "hypercorn (>=0.16.0)", "mypy (>=1.8)", "pylint (>=3)", "pytest (>=7.4)", "pytest-cov (>=4.1.0)", "quart-trio (>=0.11.0)", "sphinx (>=7.2.0)", "sphinx-rtd-theme (>=2.0.0)", "twine (>=4.0.0)", "wheel (>=0.42.0)"]
68 | dnssec = ["cryptography (>=43)"]
69 | doh = ["h2 (>=4.1.0)", "httpcore (>=1.0.0)", "httpx (>=0.26.0)"]
70 | doq = ["aioquic (>=1.0.0)"]
71 | idna = ["idna (>=3.7)"]
72 | trio = ["trio (>=0.23)"]
73 | wmi = ["wmi (>=1.5.1)"]
74 |
75 | [[package]]
76 | name = "exceptiongroup"
77 | version = "1.2.2"
78 | description = "Backport of PEP 654 (exception groups)"
79 | optional = false
80 | python-versions = ">=3.7"
81 | groups = ["main"]
82 | markers = "python_version < \"3.11\""
83 | files = [
84 | {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"},
85 | {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"},
86 | ]
87 |
88 | [package.extras]
89 | test = ["pytest (>=6)"]
90 |
91 | [[package]]
92 | name = "h11"
93 | version = "0.16.0"
94 | description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
95 | optional = false
96 | python-versions = ">=3.8"
97 | groups = ["main"]
98 | files = [
99 | {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
100 | {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
101 | ]
102 |
103 | [[package]]
104 | name = "idna"
105 | version = "3.10"
106 | description = "Internationalized Domain Names in Applications (IDNA)"
107 | optional = false
108 | python-versions = ">=3.6"
109 | groups = ["main"]
110 | files = [
111 | {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
112 | {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
113 | ]
114 |
115 | [package.extras]
116 | all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
117 |
118 | [[package]]
119 | name = "jinja2"
120 | version = "3.1.6"
121 | description = "A very fast and expressive template engine."
122 | optional = false
123 | python-versions = ">=3.7"
124 | groups = ["main"]
125 | files = [
126 | {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"},
127 | {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"},
128 | ]
129 |
130 | [package.dependencies]
131 | MarkupSafe = ">=2.0"
132 |
133 | [package.extras]
134 | i18n = ["Babel (>=2.7)"]
135 |
136 | [[package]]
137 | name = "markupsafe"
138 | version = "3.0.2"
139 | description = "Safely add untrusted strings to HTML/XML markup."
140 | optional = false
141 | python-versions = ">=3.9"
142 | groups = ["main"]
143 | files = [
144 | {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"},
145 | {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"},
146 | {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"},
147 | {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"},
148 | {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"},
149 | {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"},
150 | {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"},
151 | {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"},
152 | {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"},
153 | {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"},
154 | {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"},
155 | {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"},
156 | {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"},
157 | {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"},
158 | {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"},
159 | {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"},
160 | {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"},
161 | {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"},
162 | {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"},
163 | {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"},
164 | {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"},
165 | {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"},
166 | {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"},
167 | {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"},
168 | {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"},
169 | {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"},
170 | {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"},
171 | {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"},
172 | {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"},
173 | {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"},
174 | {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"},
175 | {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"},
176 | {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"},
177 | {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"},
178 | {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"},
179 | {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"},
180 | {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"},
181 | {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"},
182 | {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"},
183 | {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"},
184 | {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"},
185 | {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"},
186 | {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"},
187 | {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"},
188 | {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"},
189 | {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"},
190 | {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"},
191 | {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"},
192 | {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"},
193 | {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"},
194 | {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"},
195 | {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"},
196 | {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"},
197 | {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"},
198 | {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"},
199 | {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"},
200 | {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"},
201 | {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"},
202 | {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"},
203 | {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"},
204 | {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"},
205 | ]
206 |
207 | [[package]]
208 | name = "mypy"
209 | version = "1.15.0"
210 | description = "Optional static typing for Python"
211 | optional = false
212 | python-versions = ">=3.9"
213 | groups = ["dev"]
214 | files = [
215 | {file = "mypy-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:979e4e1a006511dacf628e36fadfecbcc0160a8af6ca7dad2f5025529e082c13"},
216 | {file = "mypy-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c4bb0e1bd29f7d34efcccd71cf733580191e9a264a2202b0239da95984c5b559"},
217 | {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be68172e9fd9ad8fb876c6389f16d1c1b5f100ffa779f77b1fb2176fcc9ab95b"},
218 | {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7be1e46525adfa0d97681432ee9fcd61a3964c2446795714699a998d193f1a3"},
219 | {file = "mypy-1.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2e2c2e6d3593f6451b18588848e66260ff62ccca522dd231cd4dd59b0160668b"},
220 | {file = "mypy-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:6983aae8b2f653e098edb77f893f7b6aca69f6cffb19b2cc7443f23cce5f4828"},
221 | {file = "mypy-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2922d42e16d6de288022e5ca321cd0618b238cfc5570e0263e5ba0a77dbef56f"},
222 | {file = "mypy-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2ee2d57e01a7c35de00f4634ba1bbf015185b219e4dc5909e281016df43f5ee5"},
223 | {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973500e0774b85d9689715feeffcc980193086551110fd678ebe1f4342fb7c5e"},
224 | {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a95fb17c13e29d2d5195869262f8125dfdb5c134dc8d9a9d0aecf7525b10c2c"},
225 | {file = "mypy-1.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1905f494bfd7d85a23a88c5d97840888a7bd516545fc5aaedff0267e0bb54e2f"},
226 | {file = "mypy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f"},
227 | {file = "mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd"},
228 | {file = "mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f"},
229 | {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464"},
230 | {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee"},
231 | {file = "mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e"},
232 | {file = "mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22"},
233 | {file = "mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445"},
234 | {file = "mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d"},
235 | {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5"},
236 | {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036"},
237 | {file = "mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357"},
238 | {file = "mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf"},
239 | {file = "mypy-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e601a7fa172c2131bff456bb3ee08a88360760d0d2f8cbd7a75a65497e2df078"},
240 | {file = "mypy-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:712e962a6357634fef20412699a3655c610110e01cdaa6180acec7fc9f8513ba"},
241 | {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95579473af29ab73a10bada2f9722856792a36ec5af5399b653aa28360290a5"},
242 | {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f8722560a14cde92fdb1e31597760dc35f9f5524cce17836c0d22841830fd5b"},
243 | {file = "mypy-1.15.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fbb8da62dc352133d7d7ca90ed2fb0e9d42bb1a32724c287d3c76c58cbaa9c2"},
244 | {file = "mypy-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:d10d994b41fb3497719bbf866f227b3489048ea4bbbb5015357db306249f7980"},
245 | {file = "mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e"},
246 | {file = "mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43"},
247 | ]
248 |
249 | [package.dependencies]
250 | mypy_extensions = ">=1.0.0"
251 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
252 | typing_extensions = ">=4.6.0"
253 |
254 | [package.extras]
255 | dmypy = ["psutil (>=4.0)"]
256 | faster-cache = ["orjson"]
257 | install-types = ["pip"]
258 | mypyc = ["setuptools (>=50)"]
259 | reports = ["lxml"]
260 |
261 | [[package]]
262 | name = "mypy-extensions"
263 | version = "1.0.0"
264 | description = "Type system extensions for programs checked with the mypy type checker."
265 | optional = false
266 | python-versions = ">=3.5"
267 | groups = ["dev"]
268 | files = [
269 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
270 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
271 | ]
272 |
273 | [[package]]
274 | name = "ruff"
275 | version = "0.11.0"
276 | description = "An extremely fast Python linter and code formatter, written in Rust."
277 | optional = false
278 | python-versions = ">=3.7"
279 | groups = ["dev"]
280 | files = [
281 | {file = "ruff-0.11.0-py3-none-linux_armv6l.whl", hash = "sha256:dc67e32bc3b29557513eb7eeabb23efdb25753684b913bebb8a0c62495095acb"},
282 | {file = "ruff-0.11.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:38c23fd9bdec4eb437b4c1e3595905a0a8edfccd63a790f818b28c78fe345639"},
283 | {file = "ruff-0.11.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7c8661b0be91a38bd56db593e9331beaf9064a79028adee2d5f392674bbc5e88"},
284 | {file = "ruff-0.11.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b6c0e8d3d2db7e9f6efd884f44b8dc542d5b6b590fc4bb334fdbc624d93a29a2"},
285 | {file = "ruff-0.11.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c3156d3f4b42e57247275a0a7e15a851c165a4fc89c5e8fa30ea6da4f7407b8"},
286 | {file = "ruff-0.11.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:490b1e147c1260545f6d041c4092483e3f6d8eba81dc2875eaebcf9140b53905"},
287 | {file = "ruff-0.11.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1bc09a7419e09662983b1312f6fa5dab829d6ab5d11f18c3760be7ca521c9329"},
288 | {file = "ruff-0.11.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bcfa478daf61ac8002214eb2ca5f3e9365048506a9d52b11bea3ecea822bb844"},
289 | {file = "ruff-0.11.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6fbb2aed66fe742a6a3a0075ed467a459b7cedc5ae01008340075909d819df1e"},
290 | {file = "ruff-0.11.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:92c0c1ff014351c0b0cdfdb1e35fa83b780f1e065667167bb9502d47ca41e6db"},
291 | {file = "ruff-0.11.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e4fd5ff5de5f83e0458a138e8a869c7c5e907541aec32b707f57cf9a5e124445"},
292 | {file = "ruff-0.11.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:96bc89a5c5fd21a04939773f9e0e276308be0935de06845110f43fd5c2e4ead7"},
293 | {file = "ruff-0.11.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a9352b9d767889ec5df1483f94870564e8102d4d7e99da52ebf564b882cdc2c7"},
294 | {file = "ruff-0.11.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:049a191969a10897fe052ef9cc7491b3ef6de79acd7790af7d7897b7a9bfbcb6"},
295 | {file = "ruff-0.11.0-py3-none-win32.whl", hash = "sha256:3191e9116b6b5bbe187447656f0c8526f0d36b6fd89ad78ccaad6bdc2fad7df2"},
296 | {file = "ruff-0.11.0-py3-none-win_amd64.whl", hash = "sha256:c58bfa00e740ca0a6c43d41fb004cd22d165302f360aaa56f7126d544db31a21"},
297 | {file = "ruff-0.11.0-py3-none-win_arm64.whl", hash = "sha256:868364fc23f5aa122b00c6f794211e85f7e78f5dffdf7c590ab90b8c4e69b657"},
298 | {file = "ruff-0.11.0.tar.gz", hash = "sha256:e55c620690a4a7ee6f1cccb256ec2157dc597d109400ae75bbf944fc9d6462e2"},
299 | ]
300 |
301 | [[package]]
302 | name = "sniffio"
303 | version = "1.3.1"
304 | description = "Sniff out which async library your code is running under"
305 | optional = false
306 | python-versions = ">=3.7"
307 | groups = ["main"]
308 | files = [
309 | {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
310 | {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
311 | ]
312 |
313 | [[package]]
314 | name = "starlette"
315 | version = "0.46.1"
316 | description = "The little ASGI library that shines."
317 | optional = false
318 | python-versions = ">=3.9"
319 | groups = ["main"]
320 | files = [
321 | {file = "starlette-0.46.1-py3-none-any.whl", hash = "sha256:77c74ed9d2720138b25875133f3a2dae6d854af2ec37dceb56aef370c1d8a227"},
322 | {file = "starlette-0.46.1.tar.gz", hash = "sha256:3c88d58ee4bd1bb807c0d1acb381838afc7752f9ddaec81bbe4383611d833230"},
323 | ]
324 |
325 | [package.dependencies]
326 | anyio = ">=3.6.2,<5"
327 |
328 | [package.extras]
329 | full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"]
330 |
331 | [[package]]
332 | name = "tomli"
333 | version = "2.2.1"
334 | description = "A lil' TOML parser"
335 | optional = false
336 | python-versions = ">=3.8"
337 | groups = ["dev"]
338 | markers = "python_version < \"3.11\""
339 | files = [
340 | {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"},
341 | {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"},
342 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"},
343 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"},
344 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"},
345 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"},
346 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"},
347 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"},
348 | {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"},
349 | {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"},
350 | {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"},
351 | {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"},
352 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"},
353 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"},
354 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"},
355 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"},
356 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"},
357 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"},
358 | {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"},
359 | {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"},
360 | {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"},
361 | {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"},
362 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"},
363 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"},
364 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"},
365 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"},
366 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"},
367 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"},
368 | {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"},
369 | {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"},
370 | {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"},
371 | {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"},
372 | ]
373 |
374 | [[package]]
375 | name = "typing-extensions"
376 | version = "4.12.2"
377 | description = "Backported and Experimental Type Hints for Python 3.8+"
378 | optional = false
379 | python-versions = ">=3.8"
380 | groups = ["main", "dev"]
381 | files = [
382 | {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
383 | {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
384 | ]
385 | markers = {main = "python_version < \"3.13\""}
386 |
387 | [[package]]
388 | name = "uvicorn"
389 | version = "0.34.0"
390 | description = "The lightning-fast ASGI server."
391 | optional = false
392 | python-versions = ">=3.9"
393 | groups = ["main"]
394 | files = [
395 | {file = "uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4"},
396 | {file = "uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9"},
397 | ]
398 |
399 | [package.dependencies]
400 | click = ">=7.0"
401 | h11 = ">=0.8"
402 | typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""}
403 |
404 | [package.extras]
405 | standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"]
406 |
407 | [metadata]
408 | lock-version = "2.1"
409 | python-versions = "^3.10.0"
410 | content-hash = "6c6ff93ef50f98d435ae6928273399969595566252eb9b560efdd3047b69a391"
411 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "check-xmpp-dns"
3 | description = "Python cgi script that prints the DNS SRV records used for XMPP."
4 | readme = "README.md"
5 | requires-python = ">=3.10"
6 |
7 | [tool.poetry]
8 | package-mode = false
9 |
10 | [tool.poetry.dependencies]
11 | anyio = "^4.8.0"
12 | dnspython = "^2.7.0"
13 | jinja2 = "^3.1.6"
14 | python = "^3.10.0"
15 | starlette = "^0.46.1"
16 | uvicorn = "^0.34.0"
17 |
18 | [tool.poetry.group.dev.dependencies]
19 | mypy = "^1.15.0"
20 | ruff = "^0.11.0"
21 |
22 | [tool.ruff]
23 | line-length = 120
24 |
25 | [tool.ruff.lint]
26 | select = [
27 | "F",
28 | "E",
29 | "W",
30 | "I",
31 | "ASYNC",
32 | "S",
33 | "BLE",
34 | "B",
35 | "A",
36 | "COM818",
37 | "COM819",
38 | "C4",
39 | "T10",
40 | "EM",
41 | "EXE",
42 | "FA",
43 | "ISC002",
44 | "ISC003",
45 | "ICN",
46 | "LOG",
47 | "G",
48 | "INP",
49 | "PIE",
50 | "T20",
51 | "PYI",
52 | "Q",
53 | "RSE",
54 | "RET",
55 | "SLF",
56 | "SLOT",
57 | "SIM",
58 | "TID",
59 | "TC",
60 | "INT",
61 | "ARG",
62 | "PTH",
63 | "PLC",
64 | "PLE",
65 | "PERF",
66 | "FURB",
67 | "RUF",
68 | ]
69 |
--------------------------------------------------------------------------------
/templates/index_base.html.jinja:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Check DNS SRV records for XMPP
7 |
8 |
105 |
106 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
138 |
139 | {%- block results -%}
140 | {%- endblock %}
141 |
142 |
143 |
144 |
About
145 |
XMPP clients can determine what host and port to connect to by
146 | looking up DNS SRV records. These records are described in
147 | section
148 | 3.2.1 of the XMPP Core RFC and in
149 | XEP-0368. It can
150 | be tricky to configure these records. Use this page as a tool to check
151 | that your DNS SRV records are correct.
152 |
153 |
You can also fetch these records yourself with any of the following
154 | commands. Change example.com
to your domain. Change
155 | client
to server
to look up the port that
156 | servers should connect to. Change xmpp
to
157 | xmpps
to look up the port for direct TLS aka XMPP over
158 | SSL or XMPP over TLS.
159 |
160 |
161 | Linux, macOS> host -t SRV _xmpp-client._tcp.example.com
162 | Linux, macOS> dig _xmpp-client._tcp.example.com SRV
163 | Linux, macOS, Windows> nslookup -querytype=SRV _xmpp-client._tcp.example.com
164 |
165 |
166 |
This Page
167 | Created by Mark Doliner
168 | Source available on GitHub
169 |
170 |
171 |
Other Resources
172 | Prosody IM's DNS configuration info
173 | XMPP Status Checker - Check XMPP connectivity of a domain.
174 |
175 |
176 |
177 |
178 |
179 |
--------------------------------------------------------------------------------
/templates/index_with_lookup_error.html.jinja:
--------------------------------------------------------------------------------
1 | {% extends "index_base.html.jinja" %}
2 |
3 | {% block results %}
4 |
5 | {{ error_message }}
6 |
7 | {% endblock %}
8 |
--------------------------------------------------------------------------------
/templates/index_with_successful_lookup.html.jinja:
--------------------------------------------------------------------------------
1 | {% extends "index_base.html.jinja" %}
2 |
3 | {%- macro show_records(description, records, note_types_to_footnote_indexes, peer_name, record_type, standard_port) -%}
4 | {{ description }}
5 |
6 |
7 | Target | Port | Priority | Weight | {%- if note_types_to_footnote_indexes -%}Notes | {%- endif -%}
8 | {% for record in records -%}
9 | {{ show_record(record, note_types_to_footnote_indexes) }}
10 | {% endfor -%}
11 |
12 |
13 | {%- if note_types_to_footnote_indexes %}
14 | Footnotes
15 | {% endif -%}
16 | {%- for note_type, footnote_number in note_types_to_footnote_indexes.items() %}
17 |
30 | {% endfor -%}
31 |
32 | {%- endmacro -%}
33 |
34 | {%- macro show_record(record, note_types_to_footnote_indexes) -%}
35 |
36 | {{ record.target }} |
37 | {{ record.port }} |
38 | {{ record.priority }} |
39 | {{ record.weight }} |
40 | {% if note_types_to_footnote_indexes -%}
41 |
42 | {%- for (note_type, note) in record.notes %}
43 | {{ show_note(note_type, note, note_types_to_footnote_indexes) }}
44 | {%- endfor %}
45 | |
46 | {% endif -%}
47 |
48 | {%- endmacro -%}
49 |
50 | {%- macro show_note(note_type, note, note_types_to_footnote_indexes) -%}
51 |
52 | {%- if note_type in [
53 | NoteType.PORT_REUSED_WITH_BOTH_TYPES_DIFFERENT,
54 | NoteType.PORT_REUSED_WITH_DIFFERENT_CLIENT_OR_SERVER_TYPE,
55 | NoteType.PORT_REUSED_WITH_DIFFERENT_TLS_TYPE,
56 | ]
57 | -%}
58 | ERROR:
59 | {%- endif -%}
60 | {{- note -}}{{- note_types_to_footnote_indexes[note_type] -}}
61 |
62 | {%- endmacro -%}
63 |
64 | {% block results %}
65 |
66 |
67 |
Client records for {{ hostname }}
68 | {% if client_records -%}
69 | {{ show_records('XMPP clients will use these when logging in.',
70 | client_records,
71 | client_record_note_types_to_footnote_indexes,
72 | 'clients',
73 | 'client-to-server',
74 | 5222) -}}
75 | {% else %}
76 |
ERROR: No xmpp-client DNS SRV records found! XMPP clients will try to login to {{ hostname }} on port 5222. If this is incorrect then logging in will fail.
77 | {% endif %}
78 |
79 |
80 |
81 |
Server records for {{ hostname }}
82 | {% if server_records -%}
83 | {{ show_records('Other XMPP servers will use these when peering with this domain.',
84 | server_records,
85 | server_record_note_types_to_footnote_indexes,
86 | 'servers',
87 | 'server-to-server',
88 | 5269) }}
89 | {% else %}
90 |
ERROR: No xmpp-server DNS SRV records found! Other XMPP servers will try to peer with {{ hostname }} on port 5269. If this is incorrect then peering won't work correctly.
91 | {% endif %}
92 |
93 | {%- endblock %}
94 |
--------------------------------------------------------------------------------