├── .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 |

Check DNS SRV records for XMPP

126 | 127 |
128 |
129 | 130 | 131 |
132 |
133 | ex: jabber.org
134 | ex: xmpp.org
135 | ex: tigase.im
136 |
137 |
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 | {%- if note_types_to_footnote_indexes -%}{%- endif -%} 8 | {% for record in records -%} 9 | {{ show_record(record, note_types_to_footnote_indexes) }} 10 | {% endfor -%} 11 |
TargetPortPriorityWeightNotes
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 |

18 | {%- if note_type == NoteType.DIRECT_TLS -%} 19 | {{ footnote_number }}. The standard method for TLS encryption of XMPP connections is for the connection to begin in plaintext and then switch to TLS via a STARTTLS negotiation. This is described in section 5 of RFC 6120. However, some XMPP servers support an alternative method: Clients connect to an alternative port and do TLS negotiation immediately. This is referred to as "Direct TLS." It's incompatible with the STARTTLS approach so a different port must be used (ages ago it may have been common to use 5223 for client connections and 5270 for server connections). XEP-0368 describes the DNS SRV records for Direct TLS ports and how clients should decide which record to use (also see sections 4 and 6 for some advantages of Direct TLS). This is all totally fine—we just wanted to point it out and share some info. 20 | {%- elif note_type == NoteType.NON_STANDARD_PORT -%} 21 | {{ footnote_number }}. The customary port for {{ record_type }} connections is {{ standard_port }}. Using a different port isn't necessarily bad—{{ peer_name }} that correctly use DNS SRV records will happily connect to this port—but we thought it was worth pointing out in case it was an accident. And there may be advantages to using a non-standard port. For example very restrictive firewalls might disallow outbound connections to port 5222 but still allow connections to 443 (also used by https). 22 | {%- elif note_type == NoteType.PORT_REUSED_WITH_BOTH_TYPES_DIFFERENT -%} 23 | {{ footnote_number }}. XMPP clients and servers use different handshakes when connecting to servers so it is not possible for a single hostname+port combination to accept traffic from clients and from other servers (at least, I think that's true—please correct me if I'm wrong!). Additionally, the STARTTLS method and the Direct TLS method are not compatible with each other. It is not possible for a single hostname+port to be used for both. One of them should be changed or removed. 24 | {%- elif note_type == NoteType.PORT_REUSED_WITH_DIFFERENT_CLIENT_OR_SERVER_TYPE -%} 25 | {{ footnote_number }}. XMPP clients and servers use different handshakes when connecting to servers so it is not possible for a single hostname+port combination to accept traffic from clients and from other servers (at least, I think that's true—please correct me if I'm wrong!). 26 | {%- elif note_type == NoteType.PORT_REUSED_WITH_DIFFERENT_TLS_TYPE -%} 27 | {{ footnote_number }}. The STARTTLS method and the Direct TLS method are not compatible with each other. It is not possible for a single hostname+port to be used for both. One of them should be changed or removed. 28 | {%- endif -%} 29 |

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 | --------------------------------------------------------------------------------