pax_global_header00006660000000000000000000000064147706113300014514gustar00rootroot0000000000000052 comment=e2624f533f1dd1da67cede0db365e9d6efb2c26a sshuttle-1.3.1/000077500000000000000000000000001477061133000133715ustar00rootroot00000000000000sshuttle-1.3.1/.envrc000066400000000000000000000000141477061133000145020ustar00rootroot00000000000000use flake . sshuttle-1.3.1/.github/000077500000000000000000000000001477061133000147315ustar00rootroot00000000000000sshuttle-1.3.1/.github/dependabot.yml000066400000000000000000000004151477061133000175610ustar00rootroot00000000000000version: 2 enable-beta-ecosystems: true updates: - package-ecosystem: uv directory: "/" schedule: interval: daily open-pull-requests-limit: 10 - package-ecosystem: github-actions directory: "/" schedule: interval: daily open-pull-requests-limit: 10 sshuttle-1.3.1/.github/workflows/000077500000000000000000000000001477061133000167665ustar00rootroot00000000000000sshuttle-1.3.1/.github/workflows/codeql.yml000066400000000000000000000044421477061133000207640ustar00rootroot00000000000000# For most projects, this workflow file will not need changing; you simply need # to commit it to your repository. # # You may wish to alter this file to override the set of languages analyzed, # or to provide custom queries or build logic. # # ******** NOTE ******** # We have attempted to detect the languages in your repository. Please check # the `language` matrix defined below to confirm you have the correct set of # supported CodeQL languages. # name: "CodeQL" on: push: branches: [ master ] pull_request: # The branches below must be a subset of the branches above branches: [ master ] schedule: - cron: '31 21 * * 3' jobs: analyze: name: Analyze runs-on: ubuntu-latest permissions: actions: read contents: read security-events: write strategy: fail-fast: false matrix: language: [ 'python' ] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] # Learn more about CodeQL language support at https://git.io/codeql-language-support steps: - name: Checkout repository uses: actions/checkout@v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. # queries: ./path/to/local/query, your-org/your-repo/queries@main # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild uses: github/codeql-action/autobuild@v3 # â„šī¸ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines # and modify them (or add more) to build your code if your project # uses a compiled language #- run: | # make bootstrap # make release - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 sshuttle-1.3.1/.github/workflows/pythonpackage.yml000066400000000000000000000021301477061133000223420ustar00rootroot00000000000000# This workflow will install Python dependencies, run tests and lint with a variety of Python versions # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions name: Python package on: push: branches: [ master ] pull_request: branches: [ master ] workflow_dispatch: {} jobs: build: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.9", "3.10", "3.11", "3.12"] poetry-version: ["main"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install uv uses: astral-sh/setup-uv@v5 with: version: "0.4.30" enable-cache: true cache-dependency-glob: "uv.lock" - name: Install the project run: uv sync --all-extras --dev - name: Lint with flake8 run: uv run flake8 sshuttle tests --count --show-source --statistics - name: Run the automated tests run: uv run pytest -v sshuttle-1.3.1/.github/workflows/release-please.yml000066400000000000000000000031611477061133000224010ustar00rootroot00000000000000on: push: branches: - master name: release-please jobs: release-please: runs-on: ubuntu-latest permissions: contents: write pull-requests: write outputs: release_created: ${{ steps.release.outputs.release_created }} tag_name: ${{ steps.release.outputs.tag_name }} steps: - uses: googleapis/release-please-action@v4 id: release with: token: ${{ secrets.MY_RELEASE_PLEASE_TOKEN }} release-type: python build-pypi: name: Build for pypi needs: [release-please] if: ${{ needs.release-please.outputs.release_created == 'true' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python 3.12 uses: actions/setup-python@v5 with: python-version: 3.12 - name: Install uv uses: astral-sh/setup-uv@v5 with: version: "0.4.30" enable-cache: true cache-dependency-glob: "uv.lock" - name: Build project run: uv build - name: Store the distribution packages uses: actions/upload-artifact@v4 with: name: python-package-distributions path: dist/ upload-pypi: name: Upload to pypi needs: [build-pypi] runs-on: ubuntu-latest environment: name: pypi url: https://pypi.org/p/sshuttle permissions: id-token: write steps: - name: Download all the dists uses: actions/download-artifact@v4 with: name: python-package-distributions path: dist/ - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 sshuttle-1.3.1/.gitignore000066400000000000000000000003051477061133000153570ustar00rootroot00000000000000/tmp/ /.coverage /.cache/ /.eggs/ /.tox/ /build/ /dist/ /sshuttle.egg-info/ /docs/_build/ *.pyc *~ *.8 /.do_built /.do_built.dir /.redo /.pytest_cache/ /.python-version /.direnv/ /result /.vscode/ sshuttle-1.3.1/.prospector.yml000066400000000000000000000006271477061133000163770ustar00rootroot00000000000000strictness: medium pylint: disable: - too-many-statements - too-many-locals - too-many-function-args - too-many-arguments - too-many-branches - bare-except - protected-access - no-else-return - unused-argument - method-hidden - arguments-differ - wrong-import-position - raising-bad-type pep8: options: max-line-length: 79 mccabe: run: false sshuttle-1.3.1/.readthedocs.yaml000066400000000000000000000004111477061133000166140ustar00rootroot00000000000000version: 2 build: os: ubuntu-20.04 tools: python: "3.10" jobs: post_install: - pip install uv - UV_PROJECT_ENVIRONMENT=$READTHEDOCS_VIRTUALENV_PATH uv sync --all-extras --group docs --link-mode=copy sphinx: configuration: docs/conf.py sshuttle-1.3.1/.tool-versions000066400000000000000000000000161477061133000162120ustar00rootroot00000000000000python 3.10.6 sshuttle-1.3.1/CHANGELOG.md000066400000000000000000000067111477061133000152070ustar00rootroot00000000000000# Changelog ## [1.3.1](https://github.com/sshuttle/sshuttle/compare/v1.3.0...v1.3.1) (2025-03-25) ### Bug Fixes * add pycodestyle config ([5942376](https://github.com/sshuttle/sshuttle/commit/5942376090395d0a8dfe38fe012a519268199341)) * add python lint tools ([ae3c022](https://github.com/sshuttle/sshuttle/commit/ae3c022d1d67de92f1c4712d06eb8ae76c970624)) * correct bad version number at runtime ([7b66253](https://github.com/sshuttle/sshuttle/commit/7b662536ba92d724ed8f86a32a21282fea66047c)) * Restore "nft" method ([375810a](https://github.com/sshuttle/sshuttle/commit/375810a9a8910a51db22c9fe4c0658c39b16c9e7)) ## [1.3.0](https://github.com/sshuttle/sshuttle/compare/v1.2.0...v1.3.0) (2025-02-23) ### Features * switch to a network namespace on Linux ([8a123d9](https://github.com/sshuttle/sshuttle/commit/8a123d9762b84f168a8ca8c75f73e590954e122d)) ### Bug Fixes * prevent UnicodeDecodeError parsing iptables rule with comments ([cbe3d1e](https://github.com/sshuttle/sshuttle/commit/cbe3d1e402cac9d3fbc818fe0cb8a87be2e94348)) * remove temp build hack ([1f5e6ce](https://github.com/sshuttle/sshuttle/commit/1f5e6cea703db33761fb1c3f999b9624cf3bc7ad)) * support ':' sign in password ([7fa927e](https://github.com/sshuttle/sshuttle/commit/7fa927ef8ceea6b1b2848ca433b8b3e3b63f0509)) ### Documentation * replace nix-env with nix-shell ([340ccc7](https://github.com/sshuttle/sshuttle/commit/340ccc705ebd9499f14f799fcef0b5d2a8055fb4)) * update installation instructions ([a2d405a](https://github.com/sshuttle/sshuttle/commit/a2d405a6a7f9d1a301311a109f8411f2fe8deb37)) ## [1.2.0](https://github.com/sshuttle/sshuttle/compare/v1.1.2...v1.2.0) (2025-02-07) ### Features * Add release-please to build workflow ([d910b64](https://github.com/sshuttle/sshuttle/commit/d910b64be77fd7ef2a5f169b780bfda95e67318d)) ### Bug Fixes * Add support for Python 3.11 and Python 3.11 ([a3396a4](https://github.com/sshuttle/sshuttle/commit/a3396a443df14d3bafc3d25909d9221aa182b8fc)) * bad file descriptor error in windows, fix pytest errors ([d4d0fa9](https://github.com/sshuttle/sshuttle/commit/d4d0fa945d50606360aa7c5f026a0f190b026c68)) * drop Python 3.8 support ([1084c0f](https://github.com/sshuttle/sshuttle/commit/1084c0f2458c1595b00963b3bd54bd667e4cfc9f)) * ensure poetry works for Python 3.9 ([693ee40](https://github.com/sshuttle/sshuttle/commit/693ee40c485c70f353326eb0e8f721f984850f5c)) * fix broken workflow_dispatch CI rule ([4b6f7c6](https://github.com/sshuttle/sshuttle/commit/4b6f7c6a656a752552295863092d3b8af0b42b31)) * Remove more references to legacy Python versions ([339b522](https://github.com/sshuttle/sshuttle/commit/339b5221bc33254329f79f2374f6114be6f30aed)) * replace requirements.txt files with poetry ([85dc319](https://github.com/sshuttle/sshuttle/commit/85dc3199a332f9f9f0e4c6037c883a8f88dc09ca)) * replace requirements.txt files with poetry (2) ([d08f78a](https://github.com/sshuttle/sshuttle/commit/d08f78a2d9777951d7e18f6eaebbcdd279d7683a)) * replace requirements.txt files with poetry (3) ([62da705](https://github.com/sshuttle/sshuttle/commit/62da70510e8a1f93e8b38870fdebdbace965cd8e)) * replace requirements.txt files with poetry (4) ([9bcedf1](https://github.com/sshuttle/sshuttle/commit/9bcedf19049e5b3a8ae26818299cc518ec03a926)) * update nix flake to fix problems ([cda60a5](https://github.com/sshuttle/sshuttle/commit/cda60a52331c7102cff892b9b77c8321e276680a)) * use Python >= 3.10 for docs ([bf29464](https://github.com/sshuttle/sshuttle/commit/bf294643e283cef9fb285d44e307e958686caf46)) sshuttle-1.3.1/CHANGES.rst000066400000000000000000000205431477061133000151770ustar00rootroot00000000000000========== Change log ========== Release notes now moved to https://github.com/sshuttle/sshuttle/releases/ These are the old release notes. 1.0.5 - 2020-12-29 ------------------ Added ~~~~~ * IPv6 support in nft method. * Intercept DNS requests sent by systemd-resolved. * Set default tmark. * Fix python2 server compatibility. * Python 3.9 support. Fixed ~~~~~ * Change license text to LGPL-2.1 * Fix #494 sshuttle caught in infinite select() loop. * Include sshuttle version in verbose output. * Add psutil as dependency in setup.py * When subnets and excludes are specified with hostnames, use all IPs. * Update/document client's handling of IPv4 and IPv6. * Update sdnotify.py documentation. * Allow no remote to work. * Make prefixes in verbose output more consistent. * Make nat and nft rules consistent; improve rule ordering. * Make server and client handle resolv.conf differently. * Fix handling OSError in FirewallClient#__init__ * Refactor automatic method selection. Removed ~~~~~~~ * Drop testing of Python 3.5 1.0.4 - 2020-08-24 ------------------ Fixed ~~~~~ * Allow Mux() flush/fill to work with python < 3.5 * Fix parse_hostport to always return string for host. * Require -r/--remote parameter. * Add missing package in OpenWRT documentation. * Fix doc about --listen option. * README: add Ubuntu. * Increase IP4 ttl to 63 hops instead of 42. * Fix formatting in installation.rst 1.0.3 - 2020-07-12 ------------------ Fixed ~~~~~ * Ask setuptools to require Python 3.5 and above. * Add missing import. * Fix formatting typos in usage docs 1.0.2 - 2020-06-18 ------------------ Fixed ~~~~~ * Leave use of default port to ssh command. * Remove unwanted references to Python 2.7 in docs. * Replace usage of deprecated imp. * Fix connection with @ sign in username. 1.0.1 - 2020-06-05 ------------------ Fixed ~~~~~ * Errors in python long_documentation. 1.0.0 - 2020-06-05 ------------------ Added ~~~~~ * Python 3.8 support. * sshpass support. * Auto sudoers file (#269). * option for latency control buffer size. * Docs: FreeBSD'. * Docs: Nix'. * Docs: openwrt'. * Docs: install instructions for Fedora'. * Docs: install instructions for Arch Linux'. * Docs: 'My VPN broke and need a solution fast'. Removed ~~~~~~~ * Python 2.6 support. * Python 2.7 support. Fixed ~~~~~ * Remove debug message for getpeername failure. * Fix crash triggered by port scans closing socket. * Added "Running as a service" to docs. * Systemd integration. * Trap UnicodeError to handle cases where hostnames returned by DNS are invalid. * Formatting error in CHANGES.rst * Various errors in documentation. * Nftables based method. * Make hostwatch locale-independent (#379). * Add tproxy udp port mark filter that was missed in #144, fixes #367. * Capturing of local DNS servers. * Crashing on ECONNABORTED. * Size of pf_rule, which grew in OpenBSD 6.4. * Use prompt for sudo, not needed for doas. * Arch linux installation instructions. * tests for existing PR-312 (#337). * Hyphen in hostname. * Assembler import (#319). 0.78.5 - 2019-01-28 ------------------- Added ~~~~~ * doas support as replacement for sudo on OpenBSD. * Added ChromeOS section to documentation (#262) * Add --no-sudo-pythonpath option Fixed ~~~~~ * Fix forwarding to a single port. * Various updates to documentation. * Don't crash if we can't look up peername * Fix missing string formatting argument * Moved sshuttle/tests into tests. * Updated bandit config. * Replace path /dev/null by os.devnull. * Added coverage report to tests. * Fixes support for OpenBSD (6.1+) (#282). * Close stdin, stdout, and stderr when using syslog or forking to daemon (#283). * Changes pf exclusion rules precedence. * Fix deadlock with iptables with large ruleset. * docs: document --ns-hosts --to-ns and update --dns. * Use subprocess.check_output instead of run. * Fix potential deadlock condition in nft_get_handle. * auto-nets: retrieve routes only if using auto-nets. 0.78.4 - 2018-04-02 ------------------- Added ~~~~~ * Add homebrew instructions. * Route traffic by linux user. * Add nat-like method using nftables instead of iptables. Changed ~~~~~~~ * Talk to custom DNS server on pod, instead of the ones in /etc/resolv.conf. * Add new option for overriding destination DNS server. * Changed subnet parsing. Previously 10/8 become 10.0.0.0/8. Now it gets parsed as 0.0.0.10/8. * Make hostwatch find both fqdn and hostname. * Use versions of python3 greater than 3.5 when available (e.g. 3.6). Removed ~~~~~~~ * Remove Python 2.6 from automatic tests. Fixed ~~~~~ * Fix case where there is no --dns. * [pf] Avoid port forwarding from loopback address. * Use getaddrinfo to obtain a correct sockaddr. * Skip empty lines on incoming routes data. * Just skip empty lines of routes data instead of stopping processing. * [pf] Load pf kernel module when enabling pf. * [pf] Test double restore (ipv4, ipv6) disables only once; test kldload. * Fixes UDP and DNS proxies binding to the same socket address. * Mock socket bind to avoid depending on local IPs being available in test box. * Fix no value passed for argument auto_hosts in hw_main call. * Fixed incorrect license information in setup.py. * Preserve peer and port properly. * Make --to-dns and --ns-host work well together. * Remove test that fails under OSX. * Specify pip requirements for tests. * Use flake8 to find Python syntax errors or undefined names. * Fix compatibility with the sudoers file. * Stop using SO_REUSEADDR on sockets. * Declare 'verbosity' as global variable to placate linters. * Adds 'cd sshuttle' after 'git' to README and docs. * Documentation for loading options from configuration file. * Load options from a file. * Fix firewall.py. * Move sdnotify after setting up firewall rules. * Fix tests on Macos. 0.78.3 - 2017-07-09 ------------------- The "I should have done a git pull" first release. Fixed ~~~~~ * Order first by port range and only then by swidth 0.78.2 - 2017-07-09 ------------------- Added ~~~~~ * Adds support for tunneling specific port ranges (#144). * Add support for iproute2. * Allow remote hosts with colons in the username. * Re-introduce ipfw support for sshuttle on FreeBSD with support for --DNS option as well. * Add support for PfSense. * Tests and documentation for systemd integration. * Allow subnets to be given only by file (-s). Fixed ~~~~~ * Work around non tabular headers in BSD netstat. * Fix UDP and DNS support on Python 2.7 with tproxy method. * Fixed tests after adding support for iproute2. * Small refactoring of netstat/iproute parsing. * Set started_by_sshuttle False after disabling pf. * Fix punctuation and explain Type=notify. * Move pytest-runner to tests_require. * Fix warning: closed channel got=STOP_SENDING. * Support sdnotify for better systemd integration. * Fix #117 to allow for no subnets via file (-s). * Fix argument splitting for multi-word arguments. * requirements.rst: Fix mistakes. * Fix typo, space not required here. * Update installation instructions. * Support using run from different directory. * Ensure we update sshuttle/version.py in run. * Don't print python version in run. * Add CWD to PYTHONPATH in run. 0.78.1 - 2016-08-06 ------------------- * Fix readthedocs versioning. * Don't crash on ENETUNREACH. * Various bug fixes. * Improvements to BSD and OSX support. 0.78.0 - 2016-04-08 ------------------- * Don't force IPv6 if IPv6 nameservers supplied. Fixes #74. * Call /bin/sh as users shell may not be POSIX compliant. Fixes #77. * Use argparse for command line processing. Fixes #75. * Remove useless --server option. * Support multiple -s (subnet) options. Fixes #86. * Make server parts work with old versions of Python. Fixes #81. 0.77.2 - 2016-03-07 ------------------- * Accidentally switched LGPL2 license with GPL2 license in 0.77.1 - now fixed. 0.77.1 - 2016-03-07 ------------------- * Use semantic versioning. http://semver.org/ * Update GPL 2 license text. * New release to fix PyPI. 0.77 - 2016-03-03 ----------------- * Various bug fixes. * Fix Documentation. * Add fix for MacOS X issue. * Add support for OpenBSD. 0.76 - 2016-01-17 ----------------- * Add option to disable IPv6 support. * Update documentation. * Move documentation, including man page, to Sphinx. * Use setuptools-scm for automatic versioning. 0.75 - 2016-01-12 ----------------- * Revert change that broke sshuttle entry point. 0.74 - 2016-01-10 ----------------- * Add CHANGES.rst file. * Numerous bug fixes. * Python 3.5 fixes. * PF fixes, especially for BSD. sshuttle-1.3.1/LICENSE000066400000000000000000000636421477061133000144110ustar00rootroot00000000000000 GNU LESSER GENERAL PUBLIC LICENSE Version 2.1, February 1999 Copyright (C) 1991, 1999 Free Software Foundation, Inc. 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. [This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] Preamble The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. GNU LESSER GENERAL PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) "Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. 1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. 2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: a) The modified work must itself be a software library. b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. (For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. 3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. This option is useful when you wish to copy part of the code of the Library into a program that is not a library. 4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. 5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. 6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. 7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. 8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. 9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. 10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. 11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. 12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. 13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. 14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. NO WARRANTY 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Libraries If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: Yoyodyne, Inc., hereby disclaims all copyright interest in the library `Frob' (a library for tweaking knobs) written by James Random Hacker. , 1 April 1990 Ty Coon, President of Vice That's all there is to it! sshuttle-1.3.1/MANIFEST.in000066400000000000000000000004731477061133000151330ustar00rootroot00000000000000include *.txt include *.rst include *.py include MANIFEST.in include LICENSE include run include tox.ini exclude sshuttle/version.py recursive-include docs *.bat recursive-include docs *.py recursive-include docs *.rst recursive-include docs Makefile recursive-include sshuttle *.py recursive-exclude docs/_build * sshuttle-1.3.1/README.rst000066400000000000000000000030751477061133000150650ustar00rootroot00000000000000sshuttle: where transparent proxy meets VPN meets ssh ===================================================== As far as I know, sshuttle is the only program that solves the following common case: - Your client machine (or router) is Linux, FreeBSD, MacOS or Windows. - You have access to a remote network via ssh. - You don't necessarily have admin access on the remote network. - The remote network has no VPN, or only stupid/complex VPN protocols (IPsec, PPTP, etc). Or maybe you *are* the admin and you just got frustrated with the awful state of VPN tools. - You don't want to create an ssh port forward for every single host/port on the remote network. - You hate openssh's port forwarding because it's randomly slow and/or stupid. - You can't use openssh's PermitTunnel feature because it's disabled by default on openssh servers; plus it does TCP-over-TCP, which has `terrible performance`_. .. _terrible performance: https://sshuttle.readthedocs.io/en/stable/how-it-works.html Obtaining sshuttle ------------------ Please see the documentation_. .. _Documentation: https://sshuttle.readthedocs.io/en/stable/installation.html Documentation ------------- The documentation for the stable version is available at: https://sshuttle.readthedocs.org/ The documentation for the latest development version is available at: https://sshuttle.readthedocs.org/en/latest/ Running as a service -------------------- Sshuttle can also be run as a service and configured using a config management system: https://medium.com/@mike.reider/using-sshuttle-as-a-service-bec2684a65fe sshuttle-1.3.1/bandit.yml000066400000000000000000000001251477061133000153530ustar00rootroot00000000000000exclude_dirs: - tests skips: - B101 - B104 - B404 - B603 - B606 - B607 sshuttle-1.3.1/docs/000077500000000000000000000000001477061133000143215ustar00rootroot00000000000000sshuttle-1.3.1/docs/Makefile000066400000000000000000000151621477061133000157660ustar00rootroot00000000000000# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = _build # User-friendly check for sphinx-build ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) endif # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" @echo " text to make text files" @echo " man to make manual pages" @echo " texinfo to make Texinfo files" @echo " info to make Texinfo files and run them through makeinfo" @echo " gettext to make PO message catalogs" @echo " changes to make an overview of all changed/added/deprecated items" @echo " xml to make Docutils-native XML files" @echo " pseudoxml to make pseudoxml-XML files for display purposes" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" clean: rm -rf $(BUILDDIR)/* html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/sshuttle.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/sshuttle.qhc" devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/sshuttle" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/sshuttle" @echo "# devhelp" epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." latexpdfja: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through platex and dvipdfmx..." $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." texinfo: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Run \`make' in that directory to run these through makeinfo" \ "(use \`make info' here to do that automatically)." info: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo "Running Texinfo files through makeinfo..." make -C $(BUILDDIR)/texinfo info @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." gettext: $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale @echo @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." xml: $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml @echo @echo "Build finished. The XML files are in $(BUILDDIR)/xml." pseudoxml: $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml @echo @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." sshuttle-1.3.1/docs/changes.rst000066400000000000000000000000341477061133000164600ustar00rootroot00000000000000.. include:: ../CHANGES.rst sshuttle-1.3.1/docs/chromeos.rst000066400000000000000000000006301477061133000166710ustar00rootroot00000000000000Google ChromeOS =============== Currently there is no built in support for running sshuttle directly on Google ChromeOS/Chromebooks. What we can really do is to create a Linux VM with Crostini. In the default stretch/Debian 9 VM, you can then install sshuttle as on any Linux box and it just works, as do xterms and ssvncviewer etc. https://www.reddit.com/r/Crostini/wiki/getstarted/crostini-setup-guide sshuttle-1.3.1/docs/conf.py000066400000000000000000000202471477061133000156250ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # # sshuttle documentation build configuration file, created by # sphinx-quickstart on Sun Jan 17 12:13:47 2016. # # This file is execfile()d with the current directory set to its # containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. import sys import os sys.path.insert(0, os.path.abspath('..')) import sshuttle # NOQA # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # sys.path.insert(0, os.path.abspath('.')) # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ 'sphinx.ext.todo', ] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # The suffix of source filenames. source_suffix = '.rst' # The encoding of source files. # source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' # General information about the project. project = 'sshuttle' copyright = '2016, Brian May' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The full version, including alpha/beta/rc tags. release = sshuttle.__version__ # The short X.Y version. version = '.'.join(release.split('.')[:2]) # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: # today = '' # Else, today_fmt is used as the format for a strftime call. # today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = ['_build'] # The reST default role (used for this markup: `text`) to use for all # documents. # default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. # add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). # add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. # show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. # keep_warnings = False # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_theme = 'furo' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. # html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". # html_title = None # A shorter title for the navigation bar. Default is the same as html_title. # html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. # html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. # html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. # html_extra_path = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. # html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. # html_use_smartypants = True # Custom sidebar templates, maps document names to template names. # html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. # html_additional_pages = {} # If false, no module index is generated. # html_domain_indices = True # If false, no index is generated. # html_use_index = True # If true, the index is split into individual pages for each letter. # html_split_index = False # If true, links to the reST sources are added to the pages. # html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. # html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. # html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. # html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). # html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = 'sshuttledoc' # -- Options for LaTeX output --------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. # 'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ ('index', 'sshuttle.tex', 'sshuttle documentation', 'Brian May', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of # the title page. # latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. # latex_use_parts = False # If true, show page references after internal links. # latex_show_pagerefs = False # If true, show URL addresses after external links. # latex_show_urls = False # Documents to append as an appendix to all manuals. # latex_appendices = [] # If false, no module index is generated. # latex_domain_indices = True # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ ('manpage', 'sshuttle', 'sshuttle documentation', ['Brian May'], 1) ] # If true, show URL addresses after external links. # man_show_urls = False # -- Options for Texinfo output ------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ('index', 'sshuttle', 'sshuttle documentation', 'Brian May', 'sshuttle', 'A transparent proxy-based VPN using ssh', 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. # texinfo_appendices = [] # If false, no module index is generated. # texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. # texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. # texinfo_no_detailmenu = False sshuttle-1.3.1/docs/how-it-works.rst000066400000000000000000000035471477061133000174360ustar00rootroot00000000000000How it works ============ sshuttle is not exactly a VPN, and not exactly port forwarding. It's kind of both, and kind of neither. It's like a VPN, since it can forward every port on an entire network, not just ports you specify. Conveniently, it lets you use the "real" IP addresses of each host rather than faking port numbers on localhost. On the other hand, the way it *works* is more like ssh port forwarding than a VPN. Normally, a VPN forwards your data one packet at a time, and doesn't care about individual connections; ie. it's "stateless" with respect to the traffic. sshuttle is the opposite of stateless; it tracks every single connection. You could compare sshuttle to something like the old `Slirp `_ program, which was a userspace TCP/IP implementation that did something similar. But it operated on a packet-by-packet basis on the client side, reassembling the packets on the server side. That worked okay back in the "real live serial port" days, because serial ports had predictable latency and buffering. But you can't safely just forward TCP packets over a TCP session (like ssh), because TCP's performance depends fundamentally on packet loss; it *must* experience packet loss in order to know when to slow down! At the same time, the outer TCP session (ssh, in this case) is a reliable transport, which means that what you forward through the tunnel *never* experiences packet loss. The ssh session itself experiences packet loss, of course, but TCP fixes it up and ssh (and thus you) never know the difference. But neither does your inner TCP session, and extremely screwy performance ensues. sshuttle assembles the TCP stream locally, multiplexes it statefully over an ssh session, and disassembles it back into packets at the other end. So it never ends up doing TCP-over-TCP. It's just data-over-TCP, which is safe. sshuttle-1.3.1/docs/index.rst000066400000000000000000000006141477061133000161630ustar00rootroot00000000000000sshuttle: where transparent proxy meets VPN meets ssh ===================================================== :Date: |today| :Version: |version| Contents: .. toctree:: :maxdepth: 2 overview requirements installation usage platform Man Page how-it-works support trivia changes Indices and tables ================== * :ref:`genindex` * :ref:`search` sshuttle-1.3.1/docs/installation.rst000066400000000000000000000023721477061133000175600ustar00rootroot00000000000000Installation ============ - Ubuntu 16.04 or later:: apt-get install sshuttle - Debian stretch or later:: apt-get install sshuttle - Arch Linux:: pacman -S sshuttle - Fedora:: dnf install sshuttle - openSUSE:: zypper in sshuttle - Gentoo:: emerge -av net-proxy/sshuttle - NixOS:: nix-env -iA nixos.sshuttle - From PyPI:: sudo pip install sshuttle - Clone:: git clone https://github.com/sshuttle/sshuttle.git cd sshuttle sudo ./setup.py install - FreeBSD:: # ports cd /usr/ports/net/py-sshuttle && make install clean # pkg pkg install py39-sshuttle - OpenBSD:: pkg_add sshuttle - macOS, via MacPorts:: sudo port selfupdate sudo port install sshuttle It is also possible to install into a virtualenv as a non-root user. - From PyPI:: python3 -m venv /tmp/sshuttle . /tmp/sshuttle/bin/activate pip install sshuttle - Clone:: git clone https://github.com/sshuttle/sshuttle.git cd sshuttle python3 -m venv /tmp/sshuttle . /tmp/sshuttle/bin/activate python -m pip install . - Homebrew:: brew install sshuttle - Nix:: nix-shell -p sshuttle - Windows:: pip install sshuttle sshuttle-1.3.1/docs/make.bat000066400000000000000000000150611477061133000157310ustar00rootroot00000000000000@ECHO OFF REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set BUILDDIR=_build set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . set I18NSPHINXOPTS=%SPHINXOPTS% . if NOT "%PAPER%" == "" ( set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% ) if "%1" == "" goto help if "%1" == "help" ( :help echo.Please use `make ^` where ^ is one of echo. html to make standalone HTML files echo. dirhtml to make HTML files named index.html in directories echo. singlehtml to make a single large HTML file echo. pickle to make pickle files echo. json to make JSON files echo. htmlhelp to make HTML files and a HTML help project echo. qthelp to make HTML files and a qthelp project echo. devhelp to make HTML files and a Devhelp project echo. epub to make an epub echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter echo. text to make text files echo. man to make manual pages echo. texinfo to make Texinfo files echo. gettext to make PO message catalogs echo. changes to make an overview over all changed/added/deprecated items echo. xml to make Docutils-native XML files echo. pseudoxml to make pseudoxml-XML files for display purposes echo. linkcheck to check all external links for integrity echo. doctest to run all doctests embedded in the documentation if enabled goto end ) if "%1" == "clean" ( for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i del /q /s %BUILDDIR%\* goto end ) %SPHINXBUILD% 2> nul if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) if "%1" == "html" ( %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/html. goto end ) if "%1" == "dirhtml" ( %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. goto end ) if "%1" == "singlehtml" ( %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. goto end ) if "%1" == "pickle" ( %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can process the pickle files. goto end ) if "%1" == "json" ( %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can process the JSON files. goto end ) if "%1" == "htmlhelp" ( %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can run HTML Help Workshop with the ^ .hhp project file in %BUILDDIR%/htmlhelp. goto end ) if "%1" == "qthelp" ( %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can run "qcollectiongenerator" with the ^ .qhcp project file in %BUILDDIR%/qthelp, like this: echo.^> qcollectiongenerator %BUILDDIR%\qthelp\sshuttle.qhcp echo.To view the help file: echo.^> assistant -collectionFile %BUILDDIR%\qthelp\sshuttle.ghc goto end ) if "%1" == "devhelp" ( %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp if errorlevel 1 exit /b 1 echo. echo.Build finished. goto end ) if "%1" == "epub" ( %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub if errorlevel 1 exit /b 1 echo. echo.Build finished. The epub file is in %BUILDDIR%/epub. goto end ) if "%1" == "latex" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex if errorlevel 1 exit /b 1 echo. echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. goto end ) if "%1" == "latexpdf" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex cd %BUILDDIR%/latex make all-pdf cd %BUILDDIR%/.. echo. echo.Build finished; the PDF files are in %BUILDDIR%/latex. goto end ) if "%1" == "latexpdfja" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex cd %BUILDDIR%/latex make all-pdf-ja cd %BUILDDIR%/.. echo. echo.Build finished; the PDF files are in %BUILDDIR%/latex. goto end ) if "%1" == "text" ( %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text if errorlevel 1 exit /b 1 echo. echo.Build finished. The text files are in %BUILDDIR%/text. goto end ) if "%1" == "man" ( %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man if errorlevel 1 exit /b 1 echo. echo.Build finished. The manual pages are in %BUILDDIR%/man. goto end ) if "%1" == "texinfo" ( %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo if errorlevel 1 exit /b 1 echo. echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. goto end ) if "%1" == "gettext" ( %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale if errorlevel 1 exit /b 1 echo. echo.Build finished. The message catalogs are in %BUILDDIR%/locale. goto end ) if "%1" == "changes" ( %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes if errorlevel 1 exit /b 1 echo. echo.The overview file is in %BUILDDIR%/changes. goto end ) if "%1" == "linkcheck" ( %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck if errorlevel 1 exit /b 1 echo. echo.Link check complete; look for any errors in the above output ^ or in %BUILDDIR%/linkcheck/output.txt. goto end ) if "%1" == "doctest" ( %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest if errorlevel 1 exit /b 1 echo. echo.Testing of doctests in the sources finished, look at the ^ results in %BUILDDIR%/doctest/output.txt. goto end ) if "%1" == "xml" ( %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml if errorlevel 1 exit /b 1 echo. echo.Build finished. The XML files are in %BUILDDIR%/xml. goto end ) if "%1" == "pseudoxml" ( %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml if errorlevel 1 exit /b 1 echo. echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. goto end ) :end sshuttle-1.3.1/docs/manpage.rst000066400000000000000000000456501477061133000164750ustar00rootroot00000000000000sshuttle ======== Synopsis -------- **sshuttle** [*options*] **-r** *[username@]sshserver[:port]* \<*subnets* ...\> Description ----------- :program:`sshuttle` allows you to create a VPN connection from your machine to any remote server that you can connect to via ssh, as long as that server has a sufficiently new Python installation. To work, you must have root access on the local machine, but you can have a normal account on the server. It's valid to run :program:`sshuttle` more than once simultaneously on a single client machine, connecting to a different server every time, so you can be on more than one VPN at once. If run on a router, :program:`sshuttle` can forward traffic for your entire subnet to the VPN. Options ------- .. program:: sshuttle .. option:: A list of subnets to route over the VPN, in the form ``a.b.c.d[/width][port[-port]]``. Valid examples are 1.2.3.4 (a single IP address) and 1.2.3.4/32 (equivalent to 1.2.3.4), 1.2.3.0/24 (a 24-bit subnet, ie. with a 255.255.255.0 netmask). Specify subnets 0/0 to match all IPv4 addresses and ::/0 to match all IPv6 addresses. Any of the previous examples are also valid if you append a port or a port range, so 1.2.3.4:8000 will only tunnel traffic that has as the destination port 8000 of 1.2.3.4 and 1.2.3.0/24:8000-9000 will tunnel traffic going to any port between 8000 and 9000 (inclusive) for all IPs in the 1.2.3.0/24 subnet. A hostname can be provided instead of an IP address. If the hostname resolves to multiple IPs, all of the IPs are included. If a width is provided with a hostname, the width is applied to all of the hostnames IPs (if they are all either IPv4 or IPv6). Widths cannot be supplied to hostnames that resolve to both IPv4 and IPv6. Valid examples are example.com, example.com:8000, example.com/24, example.com/24:8000 and example.com:8000-9000. .. option:: --method Which firewall method should sshuttle use? For auto, sshuttle attempts to guess the appropriate method depending on what it can find in PATH. The default value is auto. .. option:: -l <[ip:]port>, --listen=<[ip:]port> Use this ip address and port number as the transparent proxy port. By default :program:`sshuttle` finds an available port automatically and listens on IP 127.0.0.1 (localhost), so you don't need to override it, and connections are only proxied from the local machine, not from outside machines. If you want to accept connections from other machines on your network (ie. to run :program:`sshuttle` on a router) try enabling IP Forwarding in your kernel, then using ``--listen 0.0.0.0:0``. You can use any name resolving to an IP address of the machine running :program:`sshuttle`, e.g. ``--listen localhost``. For the nft, tproxy and pf methods this can be an IPv6 address. Use this option with comma separated values if required, to provide both IPv4 and IPv6 addresses, e.g. ``--listen 127.0.0.1:0,[::1]:0``. .. option:: -H, --auto-hosts Scan for remote hostnames and update the local /etc/hosts file with matching entries for as long as the VPN is open. This is nicer than changing your system's DNS (/etc/resolv.conf) settings, for several reasons. First, hostnames are added without domain names attached, so you can ``ssh thatserver`` without worrying if your local domain matches the remote one. Second, if you :program:`sshuttle` into more than one VPN at a time, it's impossible to use more than one DNS server at once anyway, but :program:`sshuttle` correctly merges /etc/hosts entries between all running copies. Third, if you're only routing a few subnets over the VPN, you probably would prefer to keep using your local DNS server for everything else. :program:`sshuttle` tries to store a cache of the hostnames in ~/.sshuttle.hosts on the remote host. Similarly, it tries to read the file when you later reconnect to the host with --auto-hosts enabled to quickly populate the host list. When troubleshooting this feature, try removing this file on the remote host when sshuttle is not running. .. option:: -N, --auto-nets In addition to the subnets provided on the command line, ask the server which subnets it thinks we should route, and route those automatically. The suggestions are taken automatically from the server's routing table. This feature does not detect IPv6 routes. Specify IPv6 subnets manually. For example, specify the ``::/0`` subnet on the command line to route all IPv6 traffic. .. option:: --dns Capture local DNS requests and forward to the remote DNS server. All queries to any of the local system's DNS servers (/etc/resolv.conf and, if it exists, /run/systemd/resolve/resolv.conf) will be intercepted and resolved on the remote side of the tunnel instead, there using the DNS specified via the :option:`--to-ns` option, if specified. Only plain DNS traffic sent to these servers on port 53 are captured. .. option:: --ns-hosts= Capture local DNS requests to the specified server(s) and forward to the remote DNS server. Contrary to the :option:`--dns` option, this flag allows to specify the DNS server(s) the queries to which to intercept, instead of intercepting all DNS traffic on the local machine. This can be useful when only certain DNS requests should be resolved on the remote side of the tunnel, e.g. in combination with dnsmasq. .. option:: --to-ns= The DNS to forward requests to when remote DNS resolution is enabled. If not given, sshuttle will simply resolve using the system configured resolver on the remote side (via /etc/resolv.conf on the remote side). .. option:: --python Specify the name/path of the remote python interpreter. The default is to use ``python3`` (or ``python``, if ``python3`` fails) in the remote system's PATH. .. option:: -r <[username@]sshserver[:port]>, --remote=<[username@]sshserver[:port]> The remote hostname and optional username and ssh port number to use for connecting to the remote server. For example, example.com, testuser@example.com, testuser@example.com:2222, or example.com:2244. This hostname is passed to ssh, so it will recognize any aliases and settings you may have configured in ~/.ssh/config. .. option:: -x , --exclude= Explicitly exclude this subnet from forwarding. The format of this option is the same as the ```` option. To exclude more than one subnet, specify the ``-x`` option more than once. You can say something like ``0/0 -x 1.2.3.0/24`` to forward everything except the local subnet over the VPN, for example. .. option:: -X , --exclude-from= Exclude the subnets specified in a file, one subnet per line. Useful when you have lots of subnets to exclude. .. option:: -v, --verbose Print more information about the session. This option can be used more than once for increased verbosity. By default, :program:`sshuttle` prints only error messages. .. option:: -e, --ssh-cmd The command to use to connect to the remote server. The default is just ``ssh``. Use this if your ssh client is in a non-standard location or you want to provide extra options to the ssh command, for example, ``-e 'ssh -v'``. .. option:: --remote-shell For Windows targets, specify configured remote shell program alternative to defacto posix shell. It would be either ``cmd`` or ``powershell`` unless something like git-bash is in use. .. option:: --no-cmd-delimiter Do not add a double dash (--) delimiter before invoking Python on the remote host. This option is useful when the ssh command used to connect is a custom command that does not interpret this delimiter correctly. .. option:: --seed-hosts A comma-separated list of hostnames to use to initialize the :option:`--auto-hosts` scan algorithm. :option:`--auto-hosts` does things like poll netstat output for lists of local hostnames, but can speed things up if you use this option to give it a few names to start from. If this option is used *without* :option:`--auto-hosts`, then the listed hostnames will be scanned and added, but no further hostnames will be added. .. option:: --no-latency-control Sacrifice latency to improve bandwidth benchmarks. ssh uses really big socket buffers, which can overload the connection if you start doing large file transfers, thus making all your other sessions inside the same tunnel go slowly. Normally, :program:`sshuttle` tries to avoid this problem using a "fullness check" that allows only a certain amount of outstanding data to be buffered at a time. But on high-bandwidth links, this can leave a lot of your bandwidth underutilized. It also makes :program:`sshuttle` seem slow in bandwidth benchmarks (benchmarks rarely test ping latency, which is what :program:`sshuttle` is trying to control). This option disables the latency control feature, maximizing bandwidth usage. Use at your own risk. .. option:: --latency-buffer-size Set the size of the buffer used in latency control. The default is ``32768``. Changing this option allows a compromise to be made between latency and bandwidth without completely disabling latency control (with :option:`--no-latency-control`). .. option:: -D, --daemon Automatically fork into the background after connecting to the remote server. Implies :option:`--syslog`. .. option:: -s , --subnets= Include the subnets specified in a file instead of on the command line. One subnet per line. .. option:: --syslog after connecting, send all log messages to the :manpage:`syslog(3)` service instead of stderr. This is implicit if you use :option:`--daemon`. .. option:: --pidfile= when using :option:`--daemon`, save :program:`sshuttle`'s pid to *pidfilename*. The default is ``sshuttle.pid`` in the current directory. .. option:: --disable-ipv6 Disable IPv6 support for methods that support it (nat, nft, tproxy, and pf). .. option:: --firewall (internal use only) run the firewall manager. This is the only part of :program:`sshuttle` that must run as root. If you start :program:`sshuttle` as a non-root user, it will automatically run ``sudo`` or ``su`` to start the firewall manager, but the core of :program:`sshuttle` still runs as a normal user. .. option:: --hostwatch (internal use only) run the hostwatch daemon. This process runs on the server side and collects hostnames for the :option:`--auto-hosts` option. Using this option by itself makes it a lot easier to debug and test the :option:`--auto-hosts` feature. .. option:: --sudoers-no-modify sshuttle prints a configuration to stdout which allows a user to run sshuttle without a password. This option is INSECURE because, with some cleverness, it also allows the user to run any command as root without a password. The output also includes a suggested method for you to install the configuration. Use --sudoers-user to modify the user that it applies to. .. option:: --sudoers-user Set the user name or group with %group_name for passwordless operation. Default is the current user. Set to ALL for all users (NOT RECOMMENDED: See note about security in --sudoers-no-modify documentation above). Only works with the --sudoers-no-modify option. .. option:: -t , --tmark= An option used by the tproxy method: Use the specified traffic mark. The mark must be a hexadecimal value. Defaults to 0x01. .. option:: --version Print program version. Configuration File ------------------ All the options described above can optionally be specified in a configuration file. To run :program:`sshuttle` with options defined in, e.g., `/etc/sshuttle.conf` just pass the path to the file preceded by the `@` character, e.g. `@/etc/sshuttle.conf`. When running :program:`sshuttle` with options defined in a configuration file, options can still be passed via the command line in addition to what is defined in the file. If a given option is defined both in the file and in the command line, the value in the command line will take precedence. Arguments read from a file must be one per line, as shown below:: value --option1 value1 --option2 value2 The configuration file supports comments for human-readable annotations. For example:: # company-internal API 8.8.8.8/32 # home IoT 192.168.63.0/24 Environment Variable -------------------- You can specify command line options with the `SSHUTTLE_ARGS` environment variable. If a given option is defined in both the environment variable and command line, the value on the command line will take precedence. For example:: SSHUTTLE_ARGS="-e 'ssh -v' --dns" sshuttle -r example.com 0/0 Examples -------- Use the following command to route all IPv4 TCP traffic through remote (-r) host example.com (and possibly other traffic too, depending on the selected --method). The 0/0 subnet, short for 0.0.0.0/0, matches all IPv4 addresses. The ::/0 subnet, matching all IPv6 addresses could be added to the example. We also exclude (-x) example.com:22 so that we can establish ssh connections from our local machine to the remote host without them being routed through sshuttle. Excluding the remote host may be necessary on some machines for sshuttle to work properly. Press Ctrl+C to exit. To also route DNS queries through sshuttle, try adding --dns. Add or remove -v options to see more or less information:: $ sshuttle -r example.com -x example.com:22 0/0 Starting sshuttle proxy (version ...). [local sudo] Password: fw: Starting firewall with Python version 3.9.5 fw: ready method name nat. c : IPv6 disabled since it isn't supported by method nat. c : Method: nat c : IPv4: on c : IPv6: off (not available with nat method) c : UDP : off (not available with nat method) c : DNS : off (available) c : User: off (available) c : Subnets to forward through remote host (type, IP, cidr mask width, startPort, endPort): c : (, '0.0.0.0', 0, 0, 0) c : Subnets to exclude from forwarding: c : (, '...', 32, 22, 22) c : (, '127.0.0.1', 32, 0, 0) c : TCP redirector listening on ('127.0.0.1', 12299). c : Starting client with Python version 3.9.5 c : Connecting to server... user@example.com's password: s: Starting server with Python version 3.6.8 s: latency control setting = True s: auto-nets:False c : Connected to server. fw: setting up. fw: iptables -w -t nat -N sshuttle-12299 fw: iptables -w -t nat -F sshuttle-12299 ... Accept: 192.168.42.121:60554 -> 77.141.99.22:22. ^C c : Keyboard interrupt: exiting. c : SW'unknown':Mux#1: deleting (1 remain) c : SW#7:192.168.42.121:60554: deleting (0 remain) Connect to a remote server, with automatic hostname and subnet guessing:: $ sshuttle -vNHr example.com -x example.com:22 Starting sshuttle proxy (version ...). [local sudo] Password: fw: Starting firewall with Python version 3.9.5 fw: ready method name nat. c : IPv6 disabled since it isn't supported by method nat. c : Method: nat c : IPv4: on c : IPv6: off (not available with nat method) c : UDP : off (not available with nat method) c : DNS : off (available) c : User: off (available) c : Subnets to forward through remote host (type, IP, cidr mask width, startPort, endPort): c : NOTE: Additional subnets to forward may be added below by --auto-nets. c : Subnets to exclude from forwarding: c : (, '...', 32, 22, 22) c : (, '127.0.0.1', 32, 0, 0) c : TCP redirector listening on ('127.0.0.1', 12300). c : Starting client with Python version 3.9.5 c : Connecting to server... user@example.com's password: s: Starting server with Python version 3.6.8 s: latency control setting = True s: auto-nets:True c : Connected to server. c : seed_hosts: [] s: available routes: s: 77.141.99.0/24 fw: setting up. fw: iptables -w -t nat -N sshuttle-12300 fw: iptables -w -t nat -F sshuttle-12300 ... c : Accept: 192.168.42.121:60554 -> 77.141.99.22:22. ^C c : Keyboard interrupt: exiting. c : SW'unknown':Mux#1: deleting (1 remain) c : SW#7:192.168.42.121:60554: deleting (0 remain) Run :program:`sshuttle` with a `/etc/sshuttle.conf` configuration file:: $ sshuttle @/etc/sshuttle.conf Use the options defined in `/etc/sshuttle.conf` but be more verbose:: $ sshuttle @/etc/sshuttle.conf -vvv Override the remote server defined in `/etc/sshuttle.conf`:: $ sshuttle @/etc/sshuttle.conf -r otheruser@test.example.com Example configuration file:: 192.168.0.0/16 --remote user@example.com Discussion ---------- When it starts, :program:`sshuttle` creates an ssh session to the server specified by the ``-r`` option. After connecting to the remote server, :program:`sshuttle` uploads its (python) source code to the remote end and executes it there. Thus, you don't need to install :program:`sshuttle` on the remote server, and there are never :program:`sshuttle` version conflicts between client and server. Unlike most VPNs, :program:`sshuttle` forwards sessions, not packets. That is, it uses kernel transparent proxying (`iptables REDIRECT` rules on Linux) to capture outgoing TCP sessions, then creates entirely separate TCP sessions out to the original destination at the other end of the tunnel. Packet-level forwarding (eg. using the tun/tap devices on Linux) seems elegant at first, but it results in several problems, notably the 'tcp over tcp' problem. The tcp protocol depends fundamentally on packets being dropped in order to implement its congestion control algorithm; if you pass tcp packets through a tcp-based tunnel (such as ssh), the inner tcp packets will never be dropped, and so the inner tcp stream's congestion control will be completely broken, and performance will be terrible. Thus, packet-based VPNs (such as IPsec and openvpn) cannot use tcp-based encrypted streams like ssh or ssl, and have to implement their own encryption from scratch, which is very complex and error prone. :program:`sshuttle`'s simplicity comes from the fact that it can safely use the existing ssh encrypted tunnel without incurring a performance penalty. It does this by letting the client-side kernel manage the incoming tcp stream, and the server-side kernel manage the outgoing tcp stream; there is no need for congestion control to be shared between the two separate streams, so a tcp-based tunnel is fine. .. seealso:: :manpage:`ssh(1)`, :manpage:`python(1)` sshuttle-1.3.1/docs/openwrt.rst000066400000000000000000000003311477061133000165460ustar00rootroot00000000000000OpenWRT ======== Run:: opkg install python3 python3-pip iptables-mod-extra iptables-mod-nat-extra iptables-mod-ipopt python3 /usr/bin/pip3 install sshuttle sshuttle -l 0.0.0.0 -r -x 192.168.1.1 0/0 sshuttle-1.3.1/docs/overview.rst000066400000000000000000000015171477061133000167250ustar00rootroot00000000000000Overview ======== As far as I know, sshuttle is the only program that solves the following common case: - Your client machine (or router) is Linux, MacOS, FreeBSD, OpenBSD or pfSense. - You have access to a remote network via ssh. - You don't necessarily have admin access on the remote network. - The remote network has no VPN, or only stupid/complex VPN protocols (IPsec, PPTP, etc). Or maybe you *are* the admin and you just got frustrated with the awful state of VPN tools. - You don't want to create an ssh port forward for every single host/port on the remote network. - You hate openssh's port forwarding because it's randomly slow and/or stupid. - You can't use openssh's PermitTunnel feature because it's disabled by default on openssh servers; plus it does TCP-over-TCP, which has terrible performance (see below). sshuttle-1.3.1/docs/platform.rst000066400000000000000000000002061477061133000166750ustar00rootroot00000000000000Platform Specific Notes ======================= Contents: .. toctree:: :maxdepth: 2 chromeos tproxy windows openwrt sshuttle-1.3.1/docs/requirements.rst000066400000000000000000000030621477061133000175770ustar00rootroot00000000000000Requirements ============ Client side Requirements ------------------------ - sudo, or root access on your client machine. (The server doesn't need admin access.) - Python 3.9 or greater. Linux with NAT method ~~~~~~~~~~~~~~~~~~~~~ Supports: * IPv4 TCP * IPv4 DNS * IPv6 TCP * IPv6 DNS Requires: * iptables DNAT and REDIRECT modules. ip6tables for IPv6. Linux with nft method ~~~~~~~~~~~~~~~~~~~~~ Supports * IPv4 TCP * IPv4 DNS * IPv6 TCP * IPv6 DNS Requires: * nftables Linux with TPROXY method ~~~~~~~~~~~~~~~~~~~~~~~~ Supports: * IPv4 TCP * IPv4 UDP * IPv4 DNS * IPv6 TCP * IPv6 UDP * IPv6 DNS MacOS / FreeBSD / OpenBSD / pfSense ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Method: pf Supports: * IPv4 TCP * IPv4 DNS * IPv6 TCP * IPv6 DNS Requires: * You need to have the pfctl command. Windows ~~~~~~~ Experimental built-in support available. See :doc:`windows` for more information. Server side Requirements ------------------------ - Python 3.9 or greater. Additional Suggested Software ----------------------------- - If you are using systemd, sshuttle can notify it when the connection to the remote end is established and the firewall rules are installed. For this feature to work you must configure the process start-up type for the sshuttle service unit to notify, as shown in the example below. .. code-block:: ini :emphasize-lines: 6 [Unit] Description=sshuttle After=network.target [Service] Type=notify ExecStart=/usr/bin/sshuttle --dns --remote @ [Install] WantedBy=multi-user.target sshuttle-1.3.1/docs/support.rst000066400000000000000000000003761477061133000165750ustar00rootroot00000000000000Support ======= Mailing list: * Subscribe by sending a message to * List archives are at: http://groups.google.com/group/sshuttle Issue tracker and pull requests at github: * https://github.com/sshuttle/sshuttle sshuttle-1.3.1/docs/tproxy.rst000066400000000000000000000030751477061133000164250ustar00rootroot00000000000000TPROXY ====== TPROXY is the only method that supports UDP. There are some things you need to consider for TPROXY to work: - The following commands need to be run first as root. This only needs to be done once after booting up:: ip route add local default dev lo table 100 ip rule add fwmark {TMARK} lookup 100 ip -6 route add local default dev lo table 100 ip -6 rule add fwmark {TMARK} lookup 100 where {TMARK} is the identifier mark passed with -t or --tmark flag as a hexadecimal string (default value is '0x01'). - The ``--auto-nets`` feature does not detect IPv6 routes automatically. Add IPv6 routes manually. e.g. by adding ``'::/0'`` to the end of the command line. - The client needs to be run as root. e.g.:: sudo SSH_AUTH_SOCK="$SSH_AUTH_SOCK" $HOME/tree/sshuttle.tproxy/sshuttle --method=tproxy ... - You may need to exclude the IP address of the server you are connecting to. Otherwise sshuttle may attempt to intercept the ssh packets, which will not work. Use the ``--exclude`` parameter for this. - You need the ``--method=tproxy`` parameter, as above. - The routes for the outgoing packets must already exist. For example, if your connection does not have IPv6 support, no IPv6 routes will exist, IPv6 packets will not be generated and sshuttle cannot intercept them:: telnet -6 www.google.com 80 Trying 2404:6800:4001:805::1010... telnet: Unable to connect to remote host: Network is unreachable Add some dummy routes to external interfaces. Make sure they get removed however after sshuttle exits. sshuttle-1.3.1/docs/trivia.rst000066400000000000000000000036221477061133000163540ustar00rootroot00000000000000Useless Trivia ============== This section written by the original author, Avery Pennarun . Back in 1998, I released the first version of `Tunnel Vision `_, a semi-intelligent VPN client for Linux. Unfortunately, I made two big mistakes: I implemented the key exchange myself (oops), and I ended up doing TCP-over-TCP (double oops). The resulting program worked okay - and people used it for years - but the performance was always a bit funny. And nobody ever found any security flaws in my key exchange, either, but that doesn't mean anything. :) The same year, dcoombs and I also released Fast Forward, a proxy server supporting transparent proxying. Among other things, we used it for automatically splitting traffic across more than one Internet connection (a tool we called "Double Vision"). I was still in university at the time. A couple years after that, one of my professors was working with some graduate students on the technology that would eventually become `Slipstream Internet Acceleration `_. He asked me to do a contract for him to build an initial prototype of a transparent proxy server for mobile networks. The idea was similar to sshuttle: if you reassemble and then disassemble the TCP packets, you can reduce latency and improve performance vs. just forwarding the packets over a plain VPN or mobile network. (It's unlikely that any of my code has persisted in the Slipstream product today, but the concept is still pretty cool. I'm still horrified that people use plain TCP on complex mobile networks with crazily variable latency, for which it was never really intended.) That project I did for Slipstream was what first gave me the idea to merge the concepts of Fast Forward, Double Vision, and Tunnel Vision into a single program that was the best of all worlds. And here we are, at last. You're welcome. sshuttle-1.3.1/docs/usage.rst000066400000000000000000000071331477061133000161630ustar00rootroot00000000000000Usage ===== .. note:: For information on usage with Windows, see the :doc:`windows` section. For information on using the TProxy method, see the :doc:`tproxy` section. Forward all traffic:: sshuttle -r username@sshserver 0.0.0.0/0 - Use the :option:`sshuttle -r` parameter to specify a remote server. On some systems, you may also need to use the :option:`sshuttle -x` parameter to exclude sshserver or sshserver:22 so that your local machine can communicate directly to sshserver without it being redirected by sshuttle. - By default sshuttle will automatically choose a method to use. Override with the :option:`sshuttle --method` parameter. - There is a shortcut for 0.0.0.0/0 for those that value their wrists:: sshuttle -r username@sshserver 0/0 - For 'My VPN broke and need a temporary solution FAST to access local IPv4 addresses':: sshuttle --dns -NHr username@sshserver 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 If you would also like your DNS queries to be proxied through the DNS server of the server you are connect to:: sshuttle --dns -r username@sshserver 0/0 The above is probably what you want to use to prevent local network attacks such as Firesheep and friends. See the documentation for the :option:`sshuttle --dns` parameter. (You may be prompted for one or more passwords; first, the local password to become root using sudo, and then the remote ssh password. Or you might have sudo and ssh set up to not require passwords, in which case you won't be prompted at all.) Usage Notes ----------- That's it! Now your local machine can access the remote network as if you were right there. And if your "client" machine is a router, everyone on your local network can make connections to your remote network. You don't need to install sshuttle on the remote server; the remote server just needs to have python available. sshuttle will automatically upload and run its source code to the remote python interpreter. This creates a transparent proxy server on your local machine for all IP addresses that match 0.0.0.0/0. (You can use more specific IP addresses if you want; use any number of IP addresses or subnets to change which addresses get proxied. Using 0.0.0.0/0 proxies *everything*, which is interesting if you don't trust the people on your local network.) Any TCP session you initiate to one of the proxied IP addresses will be captured by sshuttle and sent over an ssh session to the remote copy of sshuttle, which will then regenerate the connection on that end, and funnel the data back and forth through ssh. Fun, right? A poor man's instant VPN, and you don't even have to have admin access on the server. Sudoers File ------------ sshuttle can generate a sudoers.d file for Linux and MacOS. This allows one or more users to run sshuttle without entering the local sudo password. **WARNING:** This option is *insecure* because, with some cleverness, it also allows these users to run any command (via the --ssh-cmd option) as root without a password. To print a sudo configuration file and see a suggested way to install it, run:: sshuttle --sudoers-no-modify A custom user or group can be set with the :option:`sshuttle --sudoers-no-modify --sudoers-user {user_descriptor}` option. Valid values for this vary based on how your system is configured. Values such as usernames, groups prepended with `%` and sudoers user aliases will work. See the sudoers manual for more information on valid user-specified actions. The option must be used with `--sudoers-no-modify`:: sshuttle --sudoers-no-modify --sudoers-user mike sshuttle --sudoers-no-modify --sudoers-user %sudo sshuttle-1.3.1/docs/windows.rst000066400000000000000000000022011477061133000165400ustar00rootroot00000000000000Microsoft Windows ================= Experimental native support:: Experimental built-in support for Windows is available through `windivert` method. You have to install https://pypi.org/project/pydivert package. You need Administrator privileges to use windivert method Notes - sshuttle should be executed from admin shell (Automatic firewall process admin elevation is not available) - TCP/IPv4 supported (IPv6/UDP/DNS are not available) Use Linux VM on Windows:: What we can really do is to create a Linux VM with Vagrant (or simply Virtualbox if you like). In the Vagrant settings, remember to turn on bridged NIC. Then, run sshuttle inside the VM like below:: sshuttle -l 0.0.0.0 -x 10.0.0.0/8 -x 192.168.0.0/16 0/0 10.0.0.0/8 excludes NAT traffic of Vagrant and 192.168.0.0/16 excludes traffic to local area network (assuming that we're using 192.168.0.0 subnet). Assuming the VM has the IP 192.168.1.200 obtained on the bridge NIC (we can configure that in Vagrant), we can then ask Windows to route all its traffic via the VM by running the following in cmd.exe with admin right:: route add 0.0.0.0 mask 0.0.0.0 192.168.1.200 sshuttle-1.3.1/flake.lock000066400000000000000000000064251477061133000153340ustar00rootroot00000000000000{ "nodes": { "flake-utils": { "inputs": { "systems": "systems" }, "locked": { "lastModified": 1731533236, "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", "owner": "numtide", "repo": "flake-utils", "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", "type": "github" }, "original": { "owner": "numtide", "repo": "flake-utils", "type": "github" } }, "nixpkgs": { "locked": { "lastModified": 1740743217, "narHash": "sha256-brsCRzLqimpyhORma84c3W2xPbIidZlIc3JGIuQVSNI=", "owner": "NixOS", "repo": "nixpkgs", "rev": "b27ba4eb322d9d2bf2dc9ada9fd59442f50c8d7c", "type": "github" }, "original": { "owner": "NixOS", "ref": "nixos-24.11", "repo": "nixpkgs", "type": "github" } }, "pyproject-build-systems": { "inputs": { "nixpkgs": [ "nixpkgs" ], "pyproject-nix": [ "pyproject-nix" ], "uv2nix": [ "uv2nix" ] }, "locked": { "lastModified": 1740362541, "narHash": "sha256-S8Mno07MspggOv/xIz5g8hB2b/C5HPiX8E+rXzKY+5U=", "owner": "pyproject-nix", "repo": "build-system-pkgs", "rev": "e151741c848ba92331af91f4e47640a1fb82be19", "type": "github" }, "original": { "owner": "pyproject-nix", "repo": "build-system-pkgs", "type": "github" } }, "pyproject-nix": { "inputs": { "nixpkgs": [ "nixpkgs" ] }, "locked": { "lastModified": 1739758351, "narHash": "sha256-Aoa4dEoC7Hf6+gFVk/SDquZTMFlmlfsgdTWuqQxzePs=", "owner": "pyproject-nix", "repo": "pyproject.nix", "rev": "1329712f7f9af3a8b270764ba338a455b7323811", "type": "github" }, "original": { "owner": "pyproject-nix", "repo": "pyproject.nix", "type": "github" } }, "root": { "inputs": { "flake-utils": "flake-utils", "nixpkgs": "nixpkgs", "pyproject-build-systems": "pyproject-build-systems", "pyproject-nix": "pyproject-nix", "uv2nix": "uv2nix" } }, "systems": { "locked": { "lastModified": 1681028828, "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", "owner": "nix-systems", "repo": "default", "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", "type": "github" }, "original": { "owner": "nix-systems", "repo": "default", "type": "github" } }, "uv2nix": { "inputs": { "nixpkgs": [ "nixpkgs" ], "pyproject-nix": [ "pyproject-nix" ] }, "locked": { "lastModified": 1740497536, "narHash": "sha256-K+8wsVooqhaqyxuvew3+62mgOfRLJ7whv7woqPU3Ypo=", "owner": "pyproject-nix", "repo": "uv2nix", "rev": "d01fd3a141755ad5d5b93dd9fcbd76d6401f5bac", "type": "github" }, "original": { "owner": "pyproject-nix", "repo": "uv2nix", "type": "github" } } }, "root": "root", "version": 7 } sshuttle-1.3.1/flake.nix000066400000000000000000000072441477061133000152020ustar00rootroot00000000000000{ description = "Transparent proxy server that works as a poor man's VPN. Forwards over ssh. Doesn't require admin. Works with Linux and MacOS. Supports DNS tunneling."; inputs = { flake-utils.url = "github:numtide/flake-utils"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; pyproject-nix = { url = "github:pyproject-nix/pyproject.nix"; inputs.nixpkgs.follows = "nixpkgs"; }; uv2nix = { url = "github:pyproject-nix/uv2nix"; inputs.pyproject-nix.follows = "pyproject-nix"; inputs.nixpkgs.follows = "nixpkgs"; }; pyproject-build-systems = { url = "github:pyproject-nix/build-system-pkgs"; inputs.pyproject-nix.follows = "pyproject-nix"; inputs.uv2nix.follows = "uv2nix"; inputs.nixpkgs.follows = "nixpkgs"; }; }; outputs = { self, nixpkgs, flake-utils, pyproject-nix, uv2nix, pyproject-build-systems, }: flake-utils.lib.eachDefaultSystem ( system: let inherit (nixpkgs) lib; pkgs = nixpkgs.legacyPackages.${system}; python = pkgs.python312; workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ./.; }; # Create package overlay from workspace. overlay = workspace.mkPyprojectOverlay { sourcePreference = "sdist"; }; # Extend generated overlay with build fixups # # Uv2nix can only work with what it has, and uv.lock is missing essential metadata to perform some builds. # This is an additional overlay implementing build fixups. # See: # - https://pyproject-nix.github.io/uv2nix/FAQ.html pyprojectOverrides = final: prev: # Implement build fixups here. # Note that uv2nix is _not_ using Nixpkgs buildPythonPackage. # It's using https://pyproject-nix.github.io/pyproject.nix/build.html let inherit (final) resolveBuildSystem; inherit (builtins) mapAttrs; # Build system dependencies specified in the shape expected by resolveBuildSystem # The empty lists below are lists of optional dependencies. # # A package `foo` with specification written as: # `setuptools-scm[toml]` in pyproject.toml would be written as # `foo.setuptools-scm = [ "toml" ]` in Nix buildSystemOverrides = { chardet.setuptools = [ ]; colorlog.setuptools = [ ]; python-debian.setuptools = [ ]; pluggy.setuptools = [ ]; pathspec.flit-core = [ ]; packaging.flit-core = [ ]; }; in mapAttrs ( name: spec: prev.${name}.overrideAttrs (old: { nativeBuildInputs = old.nativeBuildInputs ++ resolveBuildSystem spec; }) ) buildSystemOverrides; pythonSet = (pkgs.callPackage pyproject-nix.build.packages { inherit python; }).overrideScope ( lib.composeManyExtensions [ pyproject-build-systems.overlays.default overlay pyprojectOverrides ] ); inherit (pkgs.callPackages pyproject-nix.build.util { }) mkApplication; package = mkApplication { venv = pythonSet.mkVirtualEnv "sshuttle" workspace.deps.default; package = pythonSet.sshuttle; }; in { packages = { sshuttle = package; default = package; }; devShells.default = pkgs.mkShell { packages = [ pkgs.uv ]; }; } ); } sshuttle-1.3.1/pyproject.toml000066400000000000000000000027711477061133000163140ustar00rootroot00000000000000[project] authors = [ {name = "Brian May", email = "brian@linuxpenguins.xyz"}, ] license = {text = "LGPL-2.1"} requires-python = "<4.0,>=3.9" dependencies = [] name = "sshuttle" version = "1.3.1" description = "Transparent proxy server that works as a poor man's VPN. Forwards over ssh. Doesn't require admin. Works with Linux and MacOS. Supports DNS tunneling." readme = "README.rst" classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Intended Audience :: End Users/Desktop", "License :: OSI Approved :: GNU Lesser General Public License v2 or later (LGPLv2+)", "Operating System :: OS Independent", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Topic :: System :: Networking", ] [project.scripts] sshuttle = "sshuttle.cmdline:main" [dependency-groups] dev = [ "pytest<9.0.0,>=8.0.1", "pytest-cov<7.0,>=4.1", "flake8<8.0.0,>=7.0.0", "pyflakes<4.0.0,>=3.2.0", "bump2version<2.0.0,>=1.0.1", "twine<7,>=5", "black>=25.1.0", "jedi-language-server>=0.44.0", "pylsp-mypy>=0.7.0", "python-lsp-server>=1.12.2", "ruff>=0.11.2", ] docs = [ "sphinx==8.1.3; python_version ~= \"3.10\"", "furo==2024.8.6", ] [tool.uv] default-groups = [] [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.sdist] exclude = [ "/.jj" ] sshuttle-1.3.1/run000077500000000000000000000005371477061133000141300ustar00rootroot00000000000000#!/usr/bin/env sh set -e export PYTHONPATH="$(dirname "$0"):$PYTHONPATH" export PATH="$(dirname "$0")/bin:$PATH" python_best_version() { if [ -x "$(command -v python3)" ] && python3 -c "import sys; sys.exit(not sys.version_info > (3, 5))"; then exec python3 "$@" else exec python "$@" fi } python_best_version -m "sshuttle" "$@" sshuttle-1.3.1/scripts/000077500000000000000000000000001477061133000150605ustar00rootroot00000000000000sshuttle-1.3.1/scripts/Containerfile000066400000000000000000000025671477061133000175770ustar00rootroot00000000000000# https://hub.docker.com/r/linuxserver/openssh-server/ ARG BASE_IMAGE=docker.io/linuxserver/openssh-server:version-9.3_p2-r1 FROM ${BASE_IMAGE} as pyenv # https://github.com/pyenv/pyenv/wiki#suggested-build-environment RUN apk add --no-cache build-base git libffi-dev openssl-dev bzip2-dev zlib-dev readline-dev sqlite-dev ENV PYENV_ROOT=/pyenv RUN curl https://raw.githubusercontent.com/pyenv/pyenv-installer/master/bin/pyenv-installer | bash RUN /pyenv/bin/pyenv install 3.10 RUN /pyenv/bin/pyenv install 3.11 RUN /pyenv/bin/pyenv install 3.12 RUN bash -xc 'rm -rf /pyenv/{.git,plugins} /pyenv/versions/*/lib/*/{test,config,config-*linux-gnu}' && \ find /pyenv -type d -name __pycache__ -exec rm -rf {} + && \ find /pyenv -type f -name '*.py[co]' -delete FROM ${BASE_IMAGE} RUN apk add --no-cache bash nginx iperf3 # pyenv setup ENV PYENV_ROOT=/pyenv ENV PATH=/pyenv/shims:/pyenv/bin:$PATH COPY --from=pyenv /pyenv /pyenv # OpenSSH Server variables ENV PUID=1000 ENV PGID=1000 ENV PASSWORD_ACCESS=true ENV USER_NAME=test ENV USER_PASSWORD=test ENV LOG_STDOUT=true # suppress linuxserver.io logo printing, chnage sshd config RUN sed -i '1 a exec &>/dev/null' /etc/s6-overlay/s6-rc.d/init-adduser/run # https://www.linuxserver.io/blog/2019-09-14-customizing-our-containers # To customize the container and start other components COPY container.setup.sh /custom-cont-init.d/setup.sh sshuttle-1.3.1/scripts/README.md000066400000000000000000000016751477061133000163500ustar00rootroot00000000000000# Container based test bed for sshuttle ```bash test-bed up -d # start containers exec-sshuttle [--copy-id] [--server-py=2.7|3.10] [--client-py=2.7|3.10] [--sshuttle-bin=/path/to/sshuttle] [sshuttle-args...] # --copy-id -> optionally do ssh-copy-id to make it passwordless for future runs # --sshuttle-bin -> use another sshuttle binary instead of one from dev setup # --server-py -> Python version to use in server. (manged by pyenv) # --client-py -> Python version to use in client (manged by pyenv) exec-sshuttle node-1 # start sshuttle to connect to node-1 exec-tool curl node-1 # curl to nginx instance running on node1 via IP that is only reachable via sshuttle exec-tool iperf3 node-1 # measure throughput to node-1 run-benchmark node-1 --client-py=3.10 ``` sshuttle-1.3.1/scripts/compose.yml000066400000000000000000000013271477061133000172530ustar00rootroot00000000000000name: sshuttle-testbed services: node-1: image: ghcr.io/sshuttle/sshuttle-testbed container_name: sshuttle-testbed-node-1 hostname: node-1 cap_add: - "NET_ADMIN" environment: - ADD_IP_ADDRESSES=10.55.1.77/24 networks: default: ipv6_address: 2001:0DB8::551 node-2: image: ghcr.io/sshuttle/sshuttle-testbed container_name: sshuttle-testbed-node-2 hostname: node-2 cap_add: - "NET_ADMIN" environment: - ADD_IP_ADDRESSES=10.55.2.77/32 networks: default: ipv6_address: 2001:0DB8::552 networks: default: driver: bridge enable_ipv6: true ipam: config: - subnet: 2001:0DB8::/112 # internal: truesshuttle-1.3.1/scripts/container.setup.sh000077500000000000000000000025261477061133000205450ustar00rootroot00000000000000#!/usr/bin/with-contenv bash # shellcheck shell=bash set -e function with_set_x() { set -x "$@" { ec=$? set +x return $ec } 2>/dev/null } function log() { echo "$*" >&2 } log ">>> Setting up $(hostname) | id: $(id)\nIP:\n$(ip a)\nRoutes:\n$(ip r)\npyenv:\n$(pyenv versions)" echo " AcceptEnv PYENV_VERSION " >> /etc/ssh/sshd_config iface="$(ip route | awk '/default/ { print $5 }')" default_gw="$(ip route | awk '/default/ { print $3 }')" for addr in ${ADD_IP_ADDRESSES//,/ }; do log ">>> Adding $addr to interface $iface" net_addr=$(ipcalc -n "$addr" | awk -F= '{print $2}') with_set_x ip addr add "$addr" dev "$iface" with_set_x ip route add "$net_addr" via "$default_gw" dev "$iface" # so that sshuttle -N can discover routes done log ">>> Starting iperf3 server" iperf3 --server --port 5001 & mkdir -p /www echo "
Hello from $(hostname)
ip address
$(ip address)
ip route
$(ip route)
" >/www/index.html echo " daemon off; worker_processes 1; error_log /dev/stdout info; events { worker_connections 1024; } http { include /etc/nginx/mime.types; server { access_log /dev/stdout; listen 8080 default_server; listen [::]:8080 default_server; root /www; } }" >/etc/nginx/nginx.conf log ">>> Starting nginx" nginx & sshuttle-1.3.1/scripts/exec-sshuttle000077500000000000000000000077061477061133000176150ustar00rootroot00000000000000#!/usr/bin/env bash set -e export MSYS_NO_PATHCONV=1 function with_set_x() { set -x "$@" { ec=$? set +x return $ec } 2>/dev/null } function log() { echo "$*" >&2 } ssh_cmd='ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null' ssh_copy_id=false args=() subnet_args=() while [[ $# -gt 0 ]]; do arg=$1 shift case "$arg" in -v|-vv*) ssh_cmd+=" -v" args+=("$arg") ;; -r) args+=("-r" "$1") shift ;; --copy-id) ssh_copy_id=true ;; --server-py=*) server_pyenv_ver="${arg#*=}" ;; --client-py=*) client_pyenv_ver="${arg#*=}" ;; -6) ipv6_only=true ;; --sshuttle-bin=*) sshuttle_bin="${arg#*=}" ;; -N|*/*) subnet_args+=("$arg") ;; -*) args+=("$arg") ;; *) if [[ -z "$target" ]]; then target=$arg else args+=("$arg") fi ;; esac done if [[ ${#subnet_args[@]} -eq 0 ]]; then subnet_args=("-N") fi if [[ $target == node-* ]]; then log "Target is a a test-bed node" port="2222" user_part="test:test" host=$("$(dirname "$0")/test-bed" get-ip "$target") index=${target#node-} if [[ $ipv6_only == true ]]; then args+=("2001:0DB8::/112") else args+=("10.55.$index.0/24") fi target="$user_part@$host:$port" if ! command -v sshpass >/dev/null; then log "sshpass is not found. You might have to manually enter ssh password: 'test'" fi if [[ -z $server_pyenv_ver ]]; then log "server-py argumwnt is not specified. Setting it to 3.8" server_pyenv_ver="3.8" fi fi if [[ -n $server_pyenv_ver ]]; then log "Would pass PYENV_VERRSION=$server_pyenv_ver to server. pyenv is required on server to make it work" pycmd="/pyenv/shims/python" ssh_cmd+=" -o SetEnv=PYENV_VERSION=${server_pyenv_ver:-'3'}" args=("--python=$pycmd" "${args[@]}") fi if [[ $ssh_copy_id == true ]]; then log "Trying to make it passwordless" if [[ $target == *@* ]]; then user_part="${target%%@*}" host_part="${target#*@}" else user_part="$(whoami)" host_part="$target" fi if [[ $host_part == *:* ]]; then host="${host_part%:*}" port="${host_part#*:}" else host="$host_part" port="22" fi if [[ $user_part == *:* ]]; then user="${user_part%:*}" password="${user_part#*:}" else user="$user_part" password="" fi cmd=(ssh-copy-id -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p "$port" "$user@$host") if [[ -n $password ]] && command -v sshpass >/dev/null; then cmd=(sshpass -p "$password" "${cmd[@]}") fi with_set_x "${cmd[@]}" fi if [[ -z $sshuttle_bin || "$sshuttle_bin" == dev ]]; then cd "$(dirname "$0")/.." export PYTHONPATH="." if [[ -n $client_pyenv_ver ]]; then log "Using pyenv version: $client_pyenv_ver" command -v pyenv &>/dev/null || log "You have to install pyenv to use --client-py" && exit 1 sshuttle_cmd=(/usr/bin/env PYENV_VERSION="$client_pyenv_ver" pyenv exec python -m sshuttle) else log "Using best python version availble" if [ -x "$(command -v python3)" ] && python3 -c "import sys; sys.exit(not sys.version_info > (3, 5))"; then sshuttle_cmd=(python3 -m sshuttle) else sshuttle_cmd=(python -m sshuttle) fi fi else [[ -n $client_pyenv_ver ]] && log "Can't specify --client-py when --sshuttle-bin is specified" && exit 1 sshuttle_cmd=("$sshuttle_bin") fi if [[ " ${args[*]} " != *" --ssh-cmd "* ]]; then args=("--ssh-cmd" "$ssh_cmd" "${args[@]}") fi if [[ " ${args[*]} " != *" -r "* ]]; then args=("-r" "$target" "${args[@]}") fi set -x "${sshuttle_cmd[@]}" --version exec "${sshuttle_cmd[@]}" "${args[@]}" "${subnet_args[@]}" sshuttle-1.3.1/scripts/exec-tool000077500000000000000000000032001477061133000167000ustar00rootroot00000000000000#!/usr/bin/env bash set -e function with_set_x() { set -x "$@" { ec=$? set +x return $ec } 2>/dev/null } function log() { echo "$*" >&2 } args=() while [[ $# -gt 0 ]]; do arg=$1 shift case "$arg" in -6) ipv6_only=true continue ;; -*) ;; *) if [[ -z $tool ]]; then tool=$arg continue elif [[ -z $node ]]; then node=$arg continue fi ;; esac args+=("$arg") done tool=${tool?:"tool argument missing. should be one of iperf3,ping,curl,ab"} node=${node?:"node argument missing. should be 'node-1' , 'node-2' etc"} if [[ $node == node-* ]]; then index=${node#node-} if [[ $ipv6_only == true ]]; then host="2001:0DB8::55$index" else host="10.55.$index.77" fi else host=$node fi connect_timeout_sec=3 case "$tool" in ping) with_set_x exec ping -W $connect_timeout_sec "${args[@]}" "$host" ;; iperf3) port=5001 with_set_x exec iperf3 --client "$host" --port=$port --connect-timeout=$((connect_timeout_sec * 1000)) "${args[@]}" ;; curl) port=8080 if [[ $host = *:* ]]; then host="[$host]" args+=(--ipv6) fi with_set_x exec curl "http://$host:$port/" -v --connect-timeout $connect_timeout_sec "${args[@]}" ;; ab) port=8080 if [[ " ${args[*]}" != *" -n "* && " ${args[*]}" != *" -c "* ]]; then args+=(-n 500 -c 50 "${args[@]}") fi with_set_x exec ab -s $connect_timeout_sec "${args[@]}" "http://$host:$port/" ;; *) log "Unknown tool: $tool" exit 2 ;; esac sshuttle-1.3.1/scripts/run-benchmark000077500000000000000000000014731477061133000175470ustar00rootroot00000000000000#!/usr/bin/env bash set -e cd "$(dirname "$0")" function with_set_x() { set -x "$@" { ec=$? set +x return $ec } 2>/dev/null } function log() { echo "$*" >&2 } ./test-bed up -d benchmark() { log -e "\n======== Benchmarking sshuttle | Args: [$*] ========" local node=$1 shift with_set_x ./exec-sshuttle "$node" --listen 55771 "$@" & sshuttle_pid=$! trap 'kill -0 $sshuttle_pid &>/dev/null && kill -15 $sshuttle_pid' EXIT while ! nc -z localhost 55771; do sleep 0.1; done sleep 1 ./exec-tool iperf3 "$node" --time=4 with_set_x kill -15 $sshuttle_pid wait $sshuttle_pid || true } if [[ $# -gt 0 ]]; then benchmark "${@}" else benchmark node-1 --sshuttle-bin="${SSHUTTLE_BIN:-sshuttle}" benchmark node-1 --sshuttle-bin=dev fi sshuttle-1.3.1/scripts/run-checks000077500000000000000000000002041477061133000170440ustar00rootroot00000000000000#!/usr/bin/env bash set -e cd "$(dirname "$0")/.." export PYTHONPATH=. set -x python -m flake8 sshuttle tests python -m pytest . sshuttle-1.3.1/scripts/test-bed000077500000000000000000000014271477061133000165210ustar00rootroot00000000000000#!/usr/bin/env bash set -e cd "$(dirname "$0")" if [[ -z $1 || $1 = -* ]]; then set -- up "$@" fi function with_set_x() { set -x "$@" { ec=$? set +x return $ec } 2>/dev/null } function build() { # podman build -t ghcr.io/sshuttle/sshuttle-testbed . with_set_x docker build --progress=plain -t ghcr.io/sshuttle/sshuttle-testbed -f Containerfile . } function compose() { # podman-compose "$@" with_set_x docker compose "$@" } function get-ip() { local container_name=sshuttle-testbed-"$1" docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$container_name" } if [[ $1 == get-ip ]]; then shift get-ip "$@" else if [[ $* = *--build* ]]; then build fi compose "$@" fi sshuttle-1.3.1/setup.cfg000066400000000000000000000006761477061133000152230ustar00rootroot00000000000000[bumpversion] current_version = 1.3.1 [bumpversion:file:setup.py] [bumpversion:file:pyproject.toml] [bumpversion:file:sshuttle/version.py] [aliases] test = pytest [bdist_wheel] universal = 1 [upload] sign = true identity = 0x1784577F811F6EAC [flake8] count = true show-source = true statistics = true max-line-length = 128 [pycodestyle] max-line-length = 128 [tool:pytest] addopts = --cov=sshuttle --cov-branch --cov-report=term-missing sshuttle-1.3.1/sshuttle/000077500000000000000000000000001477061133000152445ustar00rootroot00000000000000sshuttle-1.3.1/sshuttle/__init__.py000066400000000000000000000000261477061133000173530ustar00rootroot00000000000000__version__ = "1.3.1" sshuttle-1.3.1/sshuttle/__main__.py000066400000000000000000000005341477061133000173400ustar00rootroot00000000000000"""Coverage.py's main entry point.""" import sys import os from sshuttle.cmdline import main from sshuttle.helpers import debug3 debug3("Start: (pid=%s, ppid=%s) %r" % (os.getpid(), os.getppid(), sys.argv)) exit_code = main() debug3("Exit: (pid=%s, ppid=%s, code=%s) cmd %r" % (os.getpid(), os.getppid(), exit_code, sys.argv)) sys.exit(exit_code) sshuttle-1.3.1/sshuttle/assembler.py000066400000000000000000000033711477061133000175770ustar00rootroot00000000000000import sys import zlib import types import platform stdin = stdin # type: typing.BinaryIO # noqa: F821 must be a previously defined global verbosity = verbosity # type: int # noqa: F821 must be a previously defined global if verbosity > 0: sys.stderr.write(' s: Running server on remote host with %s (version %s)\n' % (sys.executable, platform.python_version())) z = zlib.decompressobj() while 1: name = stdin.readline().strip() if name: # python2 compat: in python2 stdin.readline().strip() -> str # in python3 stdin.readline().strip() -> bytes # (see #481) if sys.version_info >= (3, 0): name = name.decode("ASCII") nbytes = int(stdin.readline()) if verbosity >= 2: sys.stderr.write(' s: assembling %r (%d bytes)\n' % (name, nbytes)) content = z.decompress(stdin.read(nbytes)) module = types.ModuleType(name) parents = name.rsplit(".", 1) if len(parents) == 2: parent, parent_name = parents setattr(sys.modules[parent], parent_name, module) code = compile(content, name, "exec") exec(code, module.__dict__) # nosec sys.modules[name] = module else: break sys.stderr.flush() sys.stdout.flush() # import can only happen once the code has been transferred to # the server. 'noqa: E402' excludes these lines from QA checks. import sshuttle.helpers # noqa: E402 sshuttle.helpers.verbose = verbosity import sshuttle.cmdline_options as options # noqa: E402 from sshuttle.server import main # noqa: E402 main(options.latency_control, options.latency_buffer_size, options.auto_hosts, options.to_nameserver, options.auto_nets) sshuttle-1.3.1/sshuttle/client.py000066400000000000000000001256371477061133000171120ustar00rootroot00000000000000import errno import re import signal import time import subprocess as ssubprocess import os import sys import base64 import platform import sshuttle.helpers as helpers import sshuttle.ssnet as ssnet import sshuttle.ssh as ssh import sshuttle.ssyslog as ssyslog import sshuttle.sdnotify as sdnotify from sshuttle.ssnet import SockWrapper, Handler, Proxy, Mux, MuxWrapper from sshuttle.helpers import log, debug1, debug2, debug3, Fatal, islocal, \ resolvconf_nameservers, which, is_admin_user, RWPair from sshuttle.methods import get_method, Features from sshuttle import __version__ try: from pwd import getpwnam except ImportError: getpwnam = None try: from grp import getgrnam except ImportError: getgrnam = None import socket _extra_fd = os.open(os.devnull, os.O_RDONLY) def got_signal(signum, frame): log('exiting on signal %d' % signum) sys.exit(1) # Filename of the pidfile created by the sshuttle client. _pidname = None def check_daemon(pidfile): global _pidname _pidname = os.path.abspath(pidfile) try: oldpid = open(_pidname).read(1024) except IOError as e: if e.errno == errno.ENOENT: return # no pidfile, ok else: raise Fatal("can't read %s: %s" % (_pidname, e)) if not oldpid: os.unlink(_pidname) return # invalid pidfile, ok oldpid = int(oldpid.strip() or 0) if oldpid <= 0: os.unlink(_pidname) return # invalid pidfile, ok try: os.kill(oldpid, 0) except OSError as e: if e.errno == errno.ESRCH: os.unlink(_pidname) return # outdated pidfile, ok elif e.errno == errno.EPERM: pass else: raise raise Fatal("%s: sshuttle is already running (pid=%d)" % (_pidname, oldpid)) def daemonize(): # Try to open the pidfile prior to forking. If there is a problem, # the client can then exit with a proper exit status code and # message. try: outfd = os.open(_pidname, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o666) except PermissionError: # User will have to look in syslog for error message since # --daemon implies --syslog, all output gets redirected to # syslog. raise Fatal("failed to create/write pidfile %s" % _pidname) # Create a daemon process with a new session id. if os.fork(): os._exit(0) os.setsid() if os.fork(): os._exit(0) # Write pid to the pidfile. try: os.write(outfd, b'%d\n' % os.getpid()) finally: os.close(outfd) os.chdir("/") # Normal exit when killed, or try/finally won't work and the pidfile won't # be deleted. signal.signal(signal.SIGTERM, got_signal) si = open(os.devnull, 'r+') os.dup2(si.fileno(), 0) os.dup2(si.fileno(), 1) si.close() def daemon_cleanup(): try: os.unlink(_pidname) except OSError as e: if e.errno == errno.ENOENT: pass else: raise class MultiListener: def __init__(self, kind=socket.SOCK_STREAM, proto=0): self.type = kind self.proto = proto self.v6 = None self.v4 = None self.bind_called = False def setsockopt(self, level, optname, value): assert self.bind_called if self.v6: self.v6.setsockopt(level, optname, value) if self.v4: self.v4.setsockopt(level, optname, value) def add_handler(self, handlers, callback, method, mux): assert self.bind_called socks = [] if self.v6: socks.append(self.v6) if self.v4: socks.append(self.v4) handlers.append( Handler( socks, lambda sock: callback(sock, method, mux, handlers) ) ) def listen(self, backlog): assert self.bind_called if self.v6: self.v6.listen(backlog) if self.v4: try: self.v4.listen(backlog) except socket.error as e: # on some systems v4 bind will fail if the v6 succeeded, # in this case the v6 socket will receive v4 too. if e.errno == errno.EADDRINUSE and self.v6: self.v4 = None else: raise e def bind(self, address_v6, address_v4): assert not self.bind_called self.bind_called = True if address_v6 is not None: self.v6 = socket.socket(socket.AF_INET6, self.type, self.proto) try: self.v6.bind(address_v6) except OSError as e: if e.errno == errno.EADDRNOTAVAIL: # On an IPv6 Linux machine, this situation occurs # if you run the following prior to running # sshuttle: # # echo 1 > /proc/sys/net/ipv6/conf/all/disable_ipv6 # echo 1 > /proc/sys/net/ipv6/conf/default/disable_ipv6 raise Fatal("Could not bind to an IPv6 socket with " "address %s and port %s. " "Potential workaround: Run sshuttle " "with '--disable-ipv6'." % (str(address_v6[0]), str(address_v6[1]))) raise e else: self.v6 = None if address_v4 is not None: self.v4 = socket.socket(socket.AF_INET, self.type, self.proto) self.v4.bind(address_v4) else: self.v4 = None def print_listening(self, what): assert self.bind_called if self.v6: listenip = self.v6.getsockname() debug1('%s listening on %r.' % (what, listenip)) debug2('%s listening with %r.' % (what, self.v6)) if self.v4: listenip = self.v4.getsockname() debug1('%s listening on %r.' % (what, listenip)) debug2('%s listening with %r.' % (what, self.v4)) class FirewallClient: def __init__(self, method_name, sudo_pythonpath): self.auto_nets = [] argv0 = sys.argv[0] # argv0 is either be a normal Python file or an executable. # After installed as a package, sshuttle command points to an .exe in Windows and Python shebang script elsewhere. argvbase = (([sys.executable, sys.argv[0]] if argv0.endswith('.py') else [argv0]) + ['-v'] * (helpers.verbose or 0) + ['--method', method_name] + ['--firewall']) if ssyslog._p: argvbase += ['--syslog'] # A list of commands that we can try to run to start the firewall. argv_tries = [] if is_admin_user(): # No need to elevate privileges argv_tries.append(argvbase) else: if sys.platform == 'win32': # runas_path = which("runas") # if runas_path: # argv_tries.append([runas_path , '/noprofile', '/user:Administrator', 'python']) # XXX: Attempt to elevate privilege using 'runas' in windows seems not working. # Because underlying ShellExecute() Windows api does not allow child process to inherit stdio. # TODO(nom3ad): Try to implement another way to achieve this. raise Fatal("Privilege elevation for Windows is not yet implemented. Please run from an administrator shell") # Linux typically uses sudo; OpenBSD uses doas. However, some # Linux distributions are starting to use doas. sudo_cmd = ['sudo', '-p', '[local sudo] Password: '] doas_cmd = ['doas'] # For clarity, try to replace executable name with the # full path. doas_path = which("doas") if doas_path: doas_cmd[0] = doas_path sudo_path = which("sudo") if sudo_path: sudo_cmd[0] = sudo_path # sudo_pythonpath indicates if we should set the # PYTHONPATH environment variable when elevating # privileges. This can be adjusted with the # --no-sudo-pythonpath option. if sudo_pythonpath: pp_prefix = ['/usr/bin/env', 'PYTHONPATH=%s' % os.path.dirname(os.path.dirname(__file__))] sudo_cmd = sudo_cmd + pp_prefix doas_cmd = doas_cmd + pp_prefix # Final order should be: sudo/doas command, env # pythonpath, and then argvbase (sshuttle command). sudo_cmd = sudo_cmd + argvbase doas_cmd = doas_cmd + argvbase # If we can find doas and not sudo or if we are on # OpenBSD, try using doas first. if (doas_path and not sudo_path) or platform.platform().startswith('OpenBSD'): argv_tries = [doas_cmd, sudo_cmd, argvbase] else: argv_tries = [sudo_cmd, doas_cmd, argvbase] # Try all commands in argv_tries in order. If a command # produces an error, try the next one. If command is # successful, set 'success' variable and break. success = False for argv in argv_tries: if sys.platform != 'win32': # we can't use stdin/stdout=subprocess.PIPE here, as we # normally would, because stupid Linux 'su' requires that # stdin be attached to a tty. Instead, attach a # *bidirectional* socket to its stdout, and use that for # talking in both directions. (s1, s2) = socket.socketpair() pstdout = s1 pstdin = s1 penv = None def preexec_fn(): # run in the child process s2.close() def get_pfile(): s1.close() return s2.makefile('rwb') else: # In Windows CPython, BSD sockets are not supported as subprocess stdio. # if client (and firewall) processes is running as admin user, pipe based stdio can be used for communication. # But if firewall process is spwaned in elevated mode by non-admin client process, access to stdio is lost. # To work around this, we can use a socketpair. # But socket need to be "shared" to child process as it can't be directly set as stdio. can_use_stdio = is_admin_user() preexec_fn = None penv = os.environ.copy() if can_use_stdio: pstdout = ssubprocess.PIPE pstdin = ssubprocess.PIPE def get_pfile(): return RWPair(self.p.stdout, self.p.stdin) penv['SSHUTTLE_FW_COM_CHANNEL'] = 'stdio' else: pstdout = None pstdin = None (s1, s2) = socket.socketpair() socket_share_data = s1.share(self.p.pid) socket_share_data_b64 = base64.b64encode(socket_share_data) penv['SSHUTTLE_FW_COM_CHANNEL'] = socket_share_data_b64 def get_pfile(): s1.close() return s2.makefile('rwb') try: debug1("Starting firewall manager with command: %r" % argv) self.p = ssubprocess.Popen(argv, stdout=pstdout, stdin=pstdin, env=penv, preexec_fn=preexec_fn) # No env: Talking to `FirewallClient.start`, which has no i18n. except OSError as e: # This exception will occur if the program isn't # present or isn't executable. debug1('Unable to start firewall manager. Popen failed. ' 'Command=%r Exception=%s' % (argv, e)) continue self.argv = argv self.pfile = get_pfile() try: line = self.pfile.readline() except IOError: # happens when firewall subprocess exists line = '' rv = self.p.poll() # Check if process is still running if rv: # We might get here if program runs and exits before # outputting anything. For example, someone might have # entered the wrong password to elevate privileges. debug1('Unable to start firewall manager. ' 'Process exited too early. ' '%r returned %d' % (self.argv, rv)) continue # Normally, READY will be the first text on the first # line. However, if an administrator replaced sudo with a # shell script that echos a message to stdout and then # runs sudo, READY won't be on the first line. To # workaround this problem, we read a limited number of # lines until we encounter "READY". Store all of the text # we skipped in case we need it for an error message. # # A proper way to print a sudo warning message is to use # sudo's lecture feature. sshuttle works correctly without # this hack if sudo's lecture feature is used instead. skipped_text = line for i in range(100): if line[0:5] == b'READY': break line = self.pfile.readline() skipped_text += line if line[0:5] != b'READY': debug1('Unable to start firewall manager. ' 'Expected READY, got %r. ' 'Command=%r' % (skipped_text, self.argv)) continue method_name = line[6:-1] self.method = get_method(method_name.decode("ASCII")) self.method.set_firewall(self) success = True break if not success: raise Fatal("All attempts to run firewall client process with elevated privileges were failed.") def setup(self, subnets_include, subnets_exclude, nslist, redirectport_v6, redirectport_v4, dnsport_v6, dnsport_v4, udp, user, group, tmark): self.subnets_include = subnets_include self.subnets_exclude = subnets_exclude self.nslist = nslist self.redirectport_v6 = redirectport_v6 self.redirectport_v4 = redirectport_v4 self.dnsport_v6 = dnsport_v6 self.dnsport_v4 = dnsport_v4 self.udp = udp self.user = user self.group = group self.tmark = tmark def check(self): rv = self.p.poll() if rv: raise Fatal('%r returned %d' % (self.argv, rv)) def start(self): self.pfile.write(b'ROUTES\n') for (family, ip, width, fport, lport) \ in self.subnets_include + self.auto_nets: self.pfile.write(b'%d,%d,0,%s,%d,%d\n' % (family, width, ip.encode("ASCII"), fport, lport)) for (family, ip, width, fport, lport) in self.subnets_exclude: self.pfile.write(b'%d,%d,1,%s,%d,%d\n' % (family, width, ip.encode("ASCII"), fport, lport)) self.pfile.write(b'NSLIST\n') for (family, ip) in self.nslist: self.pfile.write(b'%d,%s\n' % (family, ip.encode("ASCII"))) self.pfile.write( b'PORTS %d,%d,%d,%d\n' % (self.redirectport_v6, self.redirectport_v4, self.dnsport_v6, self.dnsport_v4)) udp = 0 if self.udp: udp = 1 if self.user is None: user = b'-' elif isinstance(self.user, str): user = bytes(self.user, 'utf-8') else: user = b'%d' % self.user if self.group is None: group = b'-' elif isinstance(self.group, str): group = bytes(self.group, 'utf-8') else: group = b'%d' % self.group self.pfile.write(b'GO %d %s %s %s %d\n' % (udp, user, group, bytes(self.tmark, 'ascii'), os.getpid())) self.pfile.flush() line = self.pfile.readline() self.check() if line != b'STARTED\n': raise Fatal('%r expected STARTED, got %r' % (self.argv, line)) def sethostip(self, hostname, ip): assert not re.search(br'[^-\w\.]', hostname) assert not re.search(br'[^0-9.]', ip) self.pfile.write(b'HOST %s,%s\n' % (hostname, ip)) self.pfile.flush() def done(self): self.pfile.close() rv = self.p.wait() if rv: raise Fatal('cleanup: %r returned %d' % (self.argv, rv)) dnsreqs = {} udp_by_src = {} def expire_connections(now, mux): remove = [] for chan, timeout in dnsreqs.items(): if timeout < now: debug3('expiring dnsreqs channel=%d' % chan) remove.append(chan) del mux.channels[chan] for chan in remove: del dnsreqs[chan] debug3('Remaining DNS requests: %d' % len(dnsreqs)) remove = [] for peer, (chan, timeout) in udp_by_src.items(): if timeout < now: debug3('expiring UDP channel channel=%d peer=%r' % (chan, peer)) mux.send(chan, ssnet.CMD_UDP_CLOSE, b'') remove.append(peer) del mux.channels[chan] for peer in remove: del udp_by_src[peer] debug3('Remaining UDP channels: %d' % len(udp_by_src)) def onaccept_tcp(listener, method, mux, handlers): global _extra_fd try: sock, srcip = listener.accept() except socket.error as e: if e.args[0] in [errno.EMFILE, errno.ENFILE]: debug1('Rejected incoming connection: too many open files!') # free up an fd so we can eat the connection os.close(_extra_fd) try: sock, srcip = listener.accept() sock.close() finally: _extra_fd = os.open(os.devnull, os.O_RDONLY) return else: raise dstip = method.get_tcp_dstip(sock) debug1('Accept TCP: %s:%r -> %s:%r.' % (srcip[0], srcip[1], dstip[0], dstip[1])) if dstip[1] == sock.getsockname()[1] and islocal(dstip[0], sock.family): debug1("-- ignored: that's my address!") sock.close() return chan = mux.next_channel() if not chan: log('warning: too many open channels. Discarded connection.') sock.close() return mux.send(chan, ssnet.CMD_TCP_CONNECT, b'%d,%s,%d' % (sock.family, dstip[0].encode("ASCII"), dstip[1])) outwrap = MuxWrapper(mux, chan) handlers.append(Proxy(SockWrapper(sock, sock), outwrap)) expire_connections(time.time(), mux) def udp_done(chan, data, method, sock, dstip): (src, srcport, data) = data.split(b",", 2) srcip = (src, int(srcport)) debug3('doing send from %r to %r' % (srcip, dstip,)) method.send_udp(sock, srcip, dstip, data) def onaccept_udp(listener, method, mux, handlers): now = time.time() t = method.recv_udp(listener, 4096) if t is None: return srcip, dstip, data = t debug1('Accept UDP: %r -> %r.' % (srcip, dstip,)) if srcip in udp_by_src: chan, _ = udp_by_src[srcip] else: chan = mux.next_channel() mux.channels[chan] = lambda cmd, data: udp_done( chan, data, method, listener, dstip=srcip) mux.send(chan, ssnet.CMD_UDP_OPEN, b"%d" % listener.family) udp_by_src[srcip] = chan, now + 30 hdr = b"%s,%d," % (dstip[0].encode("ASCII"), dstip[1]) mux.send(chan, ssnet.CMD_UDP_DATA, hdr + data) expire_connections(now, mux) def dns_done(chan, data, method, sock, srcip, dstip, mux): debug3('dns_done: channel=%d src=%r dst=%r' % (chan, srcip, dstip)) del mux.channels[chan] del dnsreqs[chan] method.send_udp(sock, srcip, dstip, data) def ondns(listener, method, mux, handlers): now = time.time() t = method.recv_udp(listener, 4096) if t is None: return srcip, dstip, data = t # dstip is None if we are using a method where we can't determine # the destination IP of the DNS request that we captured from the client. if dstip is None: debug1('DNS request from %r: %d bytes' % (srcip, len(data))) else: debug1('DNS request from %r to %r: %d bytes' % (srcip, dstip, len(data))) chan = mux.next_channel() dnsreqs[chan] = now + 30 mux.send(chan, ssnet.CMD_DNS_REQ, data) mux.channels[chan] = lambda cmd, data: dns_done( chan, data, method, listener, srcip=dstip, dstip=srcip, mux=mux) expire_connections(now, mux) def _main(tcp_listener, udp_listener, fw, ssh_cmd, remotename, python, latency_control, latency_buffer_size, dns_listener, seed_hosts, auto_hosts, auto_nets, daemon, to_nameserver, add_cmd_delimiter, remote_shell): helpers.logprefix = 'c : ' debug1('Starting client with Python version %s' % platform.python_version()) method = fw.method handlers = [] debug1('Connecting to server...') try: (serverproc, rfile, wfile) = ssh.connect( ssh_cmd, remotename, python, stderr=ssyslog._p and ssyslog._p.stdin, add_cmd_delimiter=add_cmd_delimiter, remote_shell=remote_shell, options=dict(latency_control=latency_control, latency_buffer_size=latency_buffer_size, auto_hosts=auto_hosts, to_nameserver=to_nameserver, auto_nets=auto_nets)) except socket.error as e: if e.args[0] == errno.EPIPE: debug3('Error: EPIPE: ' + repr(e)) raise Fatal("failed to establish ssh session (1)") else: raise mux = Mux(rfile, wfile) handlers.append(mux) expected = b'SSHUTTLE0001' try: v = 'x' while v and v != b'\0': v = rfile.read(1) v = 'x' while v and v != b'\0': v = rfile.read(1) initstring = rfile.read(len(expected)) except socket.error as e: if e.args[0] == errno.ECONNRESET: debug3('Error: ECONNRESET ' + repr(e)) raise Fatal("failed to establish ssh session (2)") else: raise # Returns None if process is still running (or returns exit code) rv = serverproc.poll() if rv is not None: errmsg = "server died with error code %d\n" % rv # Our fatal exceptions return exit code 99 if rv == 99: errmsg += "This error code likely means that python started and " \ "the sshuttle server started. However, the sshuttle server " \ "may have raised a 'Fatal' exception after it started." elif rv == 98: errmsg += "This error code likely means that we were able to " \ "run python on the server, but that the program continued " \ "to the line after we call python's exec() to execute " \ "sshuttle's server code. Try specifying the python " \ "executable to user on the server by passing --python " \ "to sshuttle." # This error should only be possible when --python is not specified. elif rv == 97 and not python: errmsg += "This error code likely means that either we " \ "couldn't find python3 or python in the PATH on the " \ "server or that we do not have permission to run 'exec' in " \ "the /bin/sh shell on the server. Try specifying the " \ "python executable to use on the server by passing " \ "--python to sshuttle." # POSIX sh standards says error code 127 is used when you try # to execute a program that does not exist. See section 2.8.2 # of # https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_08 elif rv == 127: if python: errmsg += "This error code likely means that we were not " \ "able to execute the python executable that specified " \ "with --python. You specified '%s'.\n" % python if python.startswith("/"): errmsg += "\nTip for users in a restricted shell on the " \ "server: The server may refuse to run programs " \ "specified with an absolute path. Try specifying " \ "just the name of the python executable. However, " \ "if python is not in your PATH and you cannot " \ "run programs specified with an absolute path, " \ "it is possible that sshuttle will not work." else: errmsg += "This error code likely means that we were unable " \ "to execute /bin/sh on the remote server. This can " \ "happen if /bin/sh does not exist on the server or if " \ "you are in a restricted shell that does not allow you " \ "to run programs specified with an absolute path. " \ "Try rerunning sshuttle with the --python parameter." # When the redirected subnet includes the remote ssh host, the # firewall rules can interrupt the ssh connection to the # remote machine. This issue impacts some Linux machines. The # user sees that the server dies with a broken pipe error and # code 255. # # The solution to this problem is to exclude the remote # server. # # There are many github issues from users encountering this # problem. Most of the discussion on the topic is here: # https://github.com/sshuttle/sshuttle/issues/191 elif rv == 255: errmsg += "It might be possible to resolve this error by " \ "excluding the server that you are ssh'ing to. For example, " \ "if you are running 'sshuttle -v -r example.com 0/0' to " \ "redirect all traffic through example.com, then try " \ "'sshuttle -v -r example.com -x example.com 0/0' to " \ "exclude redirecting the connection to example.com itself " \ "(i.e., sshuttle's firewall rules may be breaking the " \ "ssh connection that it previously established). " \ "Alternatively, you may be able to use 'sshuttle -v -r " \ "example.com -x example.com:22 0/0' to redirect " \ "everything except ssh connections between your machine " \ "and example.com." raise Fatal(errmsg) if initstring != expected: raise Fatal('expected server init string %r; got %r' % (expected, initstring)) log('Connected to server.') sys.stdout.flush() if daemon: daemonize() log('daemonizing (%s).' % _pidname) def onroutes(routestr): if auto_nets: for line in routestr.strip().split(b'\n'): if not line: continue (family, ip, width) = line.split(b',', 2) family = int(family) width = int(width) ip = ip.decode("ASCII") if family == socket.AF_INET6 and tcp_listener.v6 is None: debug2("Ignored auto net %d/%s/%d" % (family, ip, width)) if family == socket.AF_INET and tcp_listener.v4 is None: debug2("Ignored auto net %d/%s/%d" % (family, ip, width)) else: debug2("Adding auto net %d/%s/%d" % (family, ip, width)) fw.auto_nets.append((family, ip, width, 0, 0)) # we definitely want to do this *after* starting ssh, or we might end # up intercepting the ssh connection! # # Moreover, now that we have the --auto-nets option, we have to wait # for the server to send us that message anyway. Even if we haven't # set --auto-nets, we might as well wait for the message first, then # ignore its contents. mux.got_routes = None serverready() mux.got_routes = onroutes def serverready(): fw.start() sdnotify.send(sdnotify.ready(), sdnotify.status('Connected')) def onhostlist(hostlist): debug2('got host list: %r' % hostlist) for line in hostlist.strip().split(): if line: name, ip = line.split(b',', 1) fw.sethostip(name, ip) mux.got_host_list = onhostlist tcp_listener.add_handler(handlers, onaccept_tcp, method, mux) if udp_listener: udp_listener.add_handler(handlers, onaccept_udp, method, mux) if dns_listener: dns_listener.add_handler(handlers, ondns, method, mux) if seed_hosts is not None: debug1('seed_hosts: %r' % seed_hosts) mux.send(0, ssnet.CMD_HOST_REQ, str.encode('\n'.join(seed_hosts))) def check_ssh_alive(): if daemon: # poll() won't tell us when process exited since the # process is no longer our child (it returns 0 all the # time). try: os.kill(serverproc.pid, 0) except OSError: raise Fatal('ssh connection to server (pid %d) exited.' % serverproc.pid) else: rv = serverproc.poll() # poll returns None if process hasn't exited. if rv is not None: raise Fatal('ssh connection to server (pid %d) exited ' 'with returncode %d' % (serverproc.pid, rv)) while 1: check_ssh_alive() ssnet.runonce(handlers, mux) if latency_control: mux.check_fullness() def main(listenip_v6, listenip_v4, ssh_cmd, remotename, python, latency_control, latency_buffer_size, dns, nslist, method_name, seed_hosts, auto_hosts, auto_nets, subnets_include, subnets_exclude, daemon, to_nameserver, pidfile, user, group, sudo_pythonpath, add_cmd_delimiter, remote_shell, tmark): if not remotename: raise Fatal("You must use -r/--remote to specify a remote " "host to route traffic through.") if daemon: try: check_daemon(pidfile) except Fatal as e: log("%s" % e) return 5 debug1('Starting sshuttle proxy (version %s).' % __version__) helpers.logprefix = 'c : ' fw = FirewallClient(method_name, sudo_pythonpath) # nslist is the list of name severs to intercept. If --dns is # used, we add all DNS servers in resolv.conf. Otherwise, the list # can be populated with the --ns-hosts option (which is already # stored in nslist). This list is used to setup the firewall so it # can redirect packets outgoing to this server to the remote host # instead. if dns: nslist += resolvconf_nameservers(True) # If we are intercepting DNS requests, we tell the remote host # where it should send the DNS requests to with the --to-ns # option. if len(nslist) > 0: if to_nameserver is not None: to_nameserver = "%s@%s" % tuple(to_nameserver[1:]) else: # if we are not intercepting DNS traffic # ...and the user specified a server to send DNS traffic to. if to_nameserver and len(to_nameserver) > 0: print("WARNING: --to-ns option is ignored unless " "--dns or --ns-hosts is used.") to_nameserver = None # Get family specific subnet lists. Also, the user may not specify # any subnets if they use --auto-nets. In this case, our subnets # list will be empty and the forwarded subnets will be determined # later by the server. subnets_v4 = [i for i in subnets_include if i[0] == socket.AF_INET] subnets_v6 = [i for i in subnets_include if i[0] == socket.AF_INET6] nslist_v4 = [i for i in nslist if i[0] == socket.AF_INET] nslist_v6 = [i for i in nslist if i[0] == socket.AF_INET6] # Get available features from the firewall method avail = fw.method.get_supported_features() # A feature is "required" if the user supplies us parameters which # implies that the feature is needed. required = Features() # Select the default addresses to bind to / listen to. # Assume IPv4 is always available and should always be enabled. If # a method doesn't provide IPv4 support or if we wish to run # ipv6-only, changes to this code are required. assert avail.ipv4 required.ipv4 = True # listenip_v4 contains user specified value or it is set to "auto". if listenip_v4 == "auto": listenip_v4 = ('127.0.0.1' if avail.loopback_proxy_port else '0.0.0.0', 0) debug1("Using default IPv4 listen address " + listenip_v4[0]) # listenip_v6 is... # None when IPv6 is disabled. # "auto" when listen address is unspecified. # The user specified address if provided by user if listenip_v6 is None: debug1("IPv6 disabled by --disable-ipv6") if listenip_v6 == "auto": if avail.ipv6: listenip_v6 = ('::1' if avail.loopback_proxy_port else '::', 0) debug1("IPv6 enabled: Using default IPv6 listen address " + listenip_v6[0]) else: debug1("IPv6 disabled since it isn't supported by method " "%s." % fw.method.name) listenip_v6 = None # Make final decision about enabling IPv6: required.ipv6 = False if listenip_v6: required.ipv6 = True # If we get here, it is possible that listenip_v6 was user # specified but not supported by the current method. if required.ipv6 and not avail.ipv6: raise Fatal("An IPv6 listen address was supplied, but IPv6 is " "disabled at your request or is unsupported by the %s " "method." % fw.method.name) if user is not None: if getpwnam is None: raise Fatal("Routing by user not available on this system.") try: user = getpwnam(user).pw_uid except KeyError: raise Fatal("User %s does not exist." % user) required.user = False if user is None else True if group is not None: if getgrnam is None: raise Fatal("Routing by group not available on this system.") try: group = getgrnam(group).gr_gid except KeyError: raise Fatal("Group %s does not exist." % user) required.group = False if group is None else True if not required.ipv6 and len(subnets_v6) > 0: print("WARNING: IPv6 subnets were ignored because IPv6 is disabled " "in sshuttle.") subnets_v6 = [] subnets_include = subnets_v4 required.udp = avail.udp # automatically enable UDP if it is available required.dns = len(nslist) > 0 # Remove DNS servers using IPv6. if required.dns: if not required.ipv6 and len(nslist_v6) > 0: print("WARNING: Your system is configured to use an IPv6 DNS " "server but sshuttle is not using IPv6. Therefore DNS " "traffic your system sends to the IPv6 DNS server won't " "be redirected via sshuttle to the remote machine.") nslist_v6 = [] nslist = nslist_v4 if len(nslist) == 0: raise Fatal("Can't redirect DNS traffic since IPv6 is not " "enabled in sshuttle and all of the system DNS " "servers are IPv6.") # If we aren't using IPv6, we can safely ignore excluded IPv6 subnets. if not required.ipv6: orig_len = len(subnets_exclude) subnets_exclude = [i for i in subnets_exclude if i[0] == socket.AF_INET] if len(subnets_exclude) < orig_len: print("WARNING: Ignoring one or more excluded IPv6 subnets " "because IPv6 is not enabled.") # This will print error messages if we required a feature that # isn't available by the current method. fw.method.assert_features(required) # display features enabled def feature_status(label, enabled, available): msg = label + ": " if enabled: msg += "on" else: msg += "off " if available: msg += "(available)" else: msg += "(not available with %s method)" % fw.method.name debug1(msg) debug1("Method: %s" % fw.method.name) feature_status("IPv4", required.ipv4, avail.ipv4) feature_status("IPv6", required.ipv6, avail.ipv6) feature_status("UDP ", required.udp, avail.udp) feature_status("DNS ", required.dns, avail.dns) feature_status("User", required.user, avail.user) # Exclude traffic destined to our listen addresses. if required.ipv4 and \ not any(listenip_v4[0] == sex[1] for sex in subnets_v4): subnets_exclude.append((socket.AF_INET, listenip_v4[0], 32, 0, 0)) if required.ipv6 and \ not any(listenip_v6[0] == sex[1] for sex in subnets_v6): subnets_exclude.append((socket.AF_INET6, listenip_v6[0], 128, 0, 0)) # We don't print the IP+port of where we are listening here # because we do that below when we have identified the ports to # listen on. debug1("Subnets to forward through remote host (type, IP, cidr mask " "width, startPort, endPort):") for i in subnets_include: debug1(" "+str(i)) if auto_nets: debug1("NOTE: Additional subnets to forward may be added below by " "--auto-nets.") debug1("Subnets to exclude from forwarding:") for i in subnets_exclude: debug1(" "+str(i)) if required.dns: debug1("DNS requests normally directed at these servers will be " "redirected to remote:") for i in nslist: debug1(" "+str(i)) if listenip_v6 and listenip_v6[1] and listenip_v4 and listenip_v4[1]: # if both ports given, no need to search for a spare port ports = [0, ] else: # if at least one port missing, we have to search ports = range(12300, 9000, -1) # keep track of failed bindings and used ports to avoid trying to # bind to the same socket address twice in different listeners used_ports = [] # search for free ports and try to bind last_e = None redirectport_v6 = 0 redirectport_v4 = 0 bound = False for port in ports: debug2('Trying to bind redirector on port %d' % port) tcp_listener = MultiListener() if required.udp: udp_listener = MultiListener(socket.SOCK_DGRAM) else: udp_listener = None if listenip_v6 and listenip_v6[1]: lv6 = listenip_v6 redirectport_v6 = lv6[1] elif listenip_v6: lv6 = (listenip_v6[0], port) redirectport_v6 = port else: lv6 = None redirectport_v6 = 0 if listenip_v4 and listenip_v4[1]: lv4 = listenip_v4 redirectport_v4 = lv4[1] elif listenip_v4: lv4 = (listenip_v4[0], port) redirectport_v4 = port else: lv4 = None redirectport_v4 = 0 try: tcp_listener.bind(lv6, lv4) if udp_listener: udp_listener.bind(lv6, lv4) bound = True used_ports.append(port) break except socket.error as e: if e.errno == errno.EADDRINUSE: last_e = e used_ports.append(port) else: raise e if not bound: assert last_e raise last_e tcp_listener.listen(10) tcp_listener.print_listening("TCP redirector") if udp_listener: udp_listener.print_listening("UDP redirector") bound = False if required.dns: # search for spare port for DNS ports = range(12300, 9000, -1) for port in ports: debug2('Trying to bind DNS redirector on port %d' % port) if port in used_ports: continue dns_listener = MultiListener(socket.SOCK_DGRAM) if listenip_v6: lv6 = (listenip_v6[0], port) dnsport_v6 = port else: lv6 = None dnsport_v6 = 0 if listenip_v4: lv4 = (listenip_v4[0], port) dnsport_v4 = port else: lv4 = None dnsport_v4 = 0 try: dns_listener.bind(lv6, lv4) bound = True used_ports.append(port) break except socket.error as e: if e.errno == errno.EADDRINUSE: last_e = e used_ports.append(port) else: raise e dns_listener.print_listening("DNS") if not bound: assert last_e raise last_e else: dnsport_v6 = 0 dnsport_v4 = 0 dns_listener = None # Last minute sanity checks. # These should never fail. # If these do fail, something is broken above. if subnets_v6: assert required.ipv6 if redirectport_v6 == 0: raise Fatal("IPv6 subnets defined but not listening") if nslist_v6: assert required.dns assert required.ipv6 if dnsport_v6 == 0: raise Fatal("IPv6 ns servers defined but not listening") if subnets_v4: if redirectport_v4 == 0: raise Fatal("IPv4 subnets defined but not listening") if nslist_v4: if dnsport_v4 == 0: raise Fatal("IPv4 ns servers defined but not listening") # setup method specific stuff on listeners fw.method.setup_tcp_listener(tcp_listener) if udp_listener: fw.method.setup_udp_listener(udp_listener) if dns_listener: fw.method.setup_udp_listener(dns_listener) # start the firewall fw.setup(subnets_include, subnets_exclude, nslist, redirectport_v6, redirectport_v4, dnsport_v6, dnsport_v4, required.udp, user, group, tmark) # start the client process try: return _main(tcp_listener, udp_listener, fw, ssh_cmd, remotename, python, latency_control, latency_buffer_size, dns_listener, seed_hosts, auto_hosts, auto_nets, daemon, to_nameserver, add_cmd_delimiter, remote_shell) finally: try: if daemon: # it's not our child anymore; can't waitpid fw.p.returncode = 0 fw.done() sdnotify.send(sdnotify.stop()) finally: if daemon: daemon_cleanup() sshuttle-1.3.1/sshuttle/cmdline.py000066400000000000000000000131231477061133000172310ustar00rootroot00000000000000import os import re import shlex import socket import sys import sshuttle.helpers as helpers import sshuttle.client as client import sshuttle.firewall as firewall import sshuttle.hostwatch as hostwatch import sshuttle.ssyslog as ssyslog from sshuttle.options import parser, parse_ipport from sshuttle.helpers import family_ip_tuple, log, Fatal from sshuttle.sudoers import sudoers from sshuttle.namespace import enter_namespace def main(): if 'SSHUTTLE_ARGS' in os.environ: env_args = shlex.split(os.environ['SSHUTTLE_ARGS']) else: env_args = [] args = [*env_args, *sys.argv[1:]] opt = parser.parse_args(args) if opt.sudoers_no_modify: # sudoers() calls exit() when it completes sudoers(user_name=opt.sudoers_user) if opt.daemon: opt.syslog = 1 if opt.wrap: import sshuttle.ssnet as ssnet ssnet.MAX_CHANNEL = opt.wrap if opt.latency_buffer_size: import sshuttle.ssnet as ssnet ssnet.LATENCY_BUFFER_SIZE = opt.latency_buffer_size helpers.verbose = opt.verbose try: # Since namespace and namespace-pid options are only available # in linux, we must check if it exists with getattr namespace = getattr(opt, 'namespace', None) namespace_pid = getattr(opt, 'namespace_pid', None) if namespace or namespace_pid: prefix = helpers.logprefix helpers.logprefix = 'ns: ' enter_namespace(namespace, namespace_pid) helpers.logprefix = prefix if opt.firewall: if opt.subnets or opt.subnets_file: parser.error('exactly zero arguments expected') return firewall.main(opt.method, opt.syslog) elif opt.hostwatch: hostwatch.hw_main(opt.subnets, opt.auto_hosts) return 0 else: # parse_subnetports() is used to create a list of includes # and excludes. It is called once for each parameter and # returns a list of one or more items for each subnet (it # can return more than one item when a hostname in the # parameter resolves to multiple IP addresses. Here, we # flatten these lists. includes = [item for sublist in opt.subnets+opt.subnets_file for item in sublist] excludes = [item for sublist in opt.exclude for item in sublist] if not includes and not opt.auto_nets: parser.error('at least one subnet, subnet file, ' 'or -N expected') remotename = opt.remote if remotename == '' or remotename == '-': remotename = None nslist = [family_ip_tuple(ns) for ns in opt.ns_hosts] if opt.seed_hosts: sh = re.split(r'[\s,]+', (opt.seed_hosts or "").strip()) elif opt.auto_hosts: sh = [] else: sh = None if opt.listen: ipport_v6 = None ipport_v4 = None lst = opt.listen.split(",") for ip in lst: family, ip, port = parse_ipport(ip) if family == socket.AF_INET6: ipport_v6 = (ip, port) else: ipport_v4 = (ip, port) else: # parse_ipport4('127.0.0.1:0') ipport_v4 = "auto" # parse_ipport6('[::1]:0') ipport_v6 = "auto" if not opt.disable_ipv6 else None try: int(opt.tmark, 16) except ValueError: parser.error("--tmark must be a hexadecimal value") opt.tmark = opt.tmark.lower() # make 'x' in 0x lowercase if not opt.tmark.startswith("0x"): # accept without 0x prefix opt.tmark = "0x%s" % opt.tmark if opt.syslog: ssyslog.start_syslog() ssyslog.close_stdin() ssyslog.stdout_to_syslog() ssyslog.stderr_to_syslog() return_code = client.main(ipport_v6, ipport_v4, opt.ssh_cmd, remotename, opt.python, opt.latency_control, opt.latency_buffer_size, opt.dns, nslist, opt.method, sh, opt.auto_hosts, opt.auto_nets, includes, excludes, opt.daemon, opt.to_ns, opt.pidfile, opt.user, opt.group, opt.sudo_pythonpath, opt.add_cmd_delimiter, opt.remote_shell, opt.tmark) if return_code == 0: log('Normal exit code, exiting...') else: log('Abnormal exit code %d detected, failing...' % return_code) return return_code except Fatal as e: log('fatal: %s' % e) return 99 except KeyboardInterrupt: log('\n') log('Keyboard interrupt: exiting.') return 1 sshuttle-1.3.1/sshuttle/firewall.py000066400000000000000000000364051477061133000174330ustar00rootroot00000000000000import errno import shutil import socket import signal import sys import os import platform import traceback import subprocess as ssubprocess import base64 import io import sshuttle.ssyslog as ssyslog import sshuttle.helpers as helpers from sshuttle.helpers import is_admin_user, log, debug1, debug2, debug3, Fatal from sshuttle.methods import get_auto_method, get_method if sys.platform == 'win32': HOSTSFILE = r"C:\Windows\System32\drivers\etc\hosts" else: HOSTSFILE = '/etc/hosts' sshuttle_pid = None def rewrite_etc_hosts(hostmap, port): BAKFILE = '%s.sbak' % HOSTSFILE APPEND = '# sshuttle-firewall-%d AUTOCREATED' % port old_content = '' st = None try: old_content = open(HOSTSFILE).read() st = os.stat(HOSTSFILE) except IOError as e: if e.errno == errno.ENOENT: pass else: raise if old_content.strip() and not os.path.exists(BAKFILE): try: os.link(HOSTSFILE, BAKFILE) except OSError: # file is locked - performing non-atomic copy shutil.copyfile(HOSTSFILE, BAKFILE) tmpname = "%s.%d.tmp" % (HOSTSFILE, port) f = open(tmpname, 'w') for line in old_content.rstrip().split('\n'): if line.find(APPEND) >= 0: continue f.write('%s\n' % line) for (name, ip) in sorted(hostmap.items()): f.write('%-30s %s\n' % ('%s %s' % (ip, name), APPEND)) f.close() if sys.platform != 'win32': if st is not None: os.chown(tmpname, st.st_uid, st.st_gid) os.chmod(tmpname, st.st_mode) else: os.chown(tmpname, 0, 0) os.chmod(tmpname, 0o644) try: os.rename(tmpname, HOSTSFILE) except OSError: # file is locked - performing non-atomic copy log('Warning: Using a non-atomic way to overwrite %s that can corrupt the file if ' 'multiple processes write to it simultaneously.' % HOSTSFILE) shutil.move(tmpname, HOSTSFILE) def restore_etc_hosts(hostmap, port): # Only restore if we added hosts to /etc/hosts previously. if len(hostmap) > 0: debug2('undoing /etc/hosts changes.') rewrite_etc_hosts({}, port) def firewall_exit(signum, frame): # The typical sshuttle exit is that the main sshuttle process # exits, closes file descriptors it uses, and the firewall process # notices that it can't read from stdin anymore and exits # (cleaning up firewall rules). # # However, in some cases, Ctrl+C might get sent to the firewall # process. This might caused if someone manually tries to kill the # firewall process, or if sshuttle was started using sudo's use_pty option # and they try to exit by pressing Ctrl+C. Here, we forward the # Ctrl+C/SIGINT to the main sshuttle process which should trigger # the typical exit process as described above. global sshuttle_pid if sshuttle_pid: debug1("Relaying interupt signal to sshuttle process %d" % sshuttle_pid) if sys.platform == 'win32': sig = signal.CTRL_C_EVENT else: sig = signal.SIGINT os.kill(sshuttle_pid, sig) def _setup_daemon_for_unix_like(): if not is_admin_user(): raise Fatal('You must have root privileges (or enable su/sudo) to set the firewall') # don't disappear if our controlling terminal or stdout/stderr # disappears; we still have to clean up. signal.signal(signal.SIGHUP, signal.SIG_IGN) signal.signal(signal.SIGPIPE, signal.SIG_IGN) signal.signal(signal.SIGTERM, firewall_exit) signal.signal(signal.SIGINT, firewall_exit) # Calling setsid() here isn't strictly necessary. However, it forces # Ctrl+C to get sent to the main sshuttle process instead of to # the firewall process---which is our preferred way to shutdown. # Nonetheless, if the firewall process receives a SIGTERM/SIGINT # signal, it will relay a SIGINT to the main sshuttle process # automatically. try: os.setsid() except OSError: # setsid() fails if sudo is configured with the use_pty option. pass return sys.stdin.buffer, sys.stdout.buffer def _setup_daemon_for_windows(): if not is_admin_user(): raise Fatal('You must be administrator to set the firewall') signal.signal(signal.SIGTERM, firewall_exit) signal.signal(signal.SIGINT, firewall_exit) com_chan = os.environ.get('SSHUTTLE_FW_COM_CHANNEL') if com_chan == 'stdio': debug3('Using inherited stdio for communicating with sshuttle client process') else: debug3('Using shared socket for communicating with sshuttle client process') socket_share_data = base64.b64decode(com_chan) sock = socket.fromshare(socket_share_data) # type: socket.socket sys.stdin = io.TextIOWrapper(sock.makefile('rb', buffering=0)) sys.stdout = io.TextIOWrapper(sock.makefile('wb', buffering=0), write_through=True) sock.close() return sys.stdin.buffer, sys.stdout.buffer # Isolate function that needs to be replaced for tests if sys.platform == 'win32': setup_daemon = _setup_daemon_for_windows else: setup_daemon = _setup_daemon_for_unix_like # Note that we're sorting in a very particular order: # we need to go from smaller, more specific, port ranges, to larger, # less-specific, port ranges. At each level, we order by subnet # width, from most-specific subnets (largest swidth) to # least-specific. On ties, excludes come first. # s:(inet, subnet width, exclude flag, subnet, first port, last port) def subnet_weight(s): return (-s[-1] + (s[-2] or -65535), s[1], s[2]) def flush_systemd_dns_cache(): # If the user is using systemd-resolve for DNS resolution, it is # possible for the request to go through systemd-resolve before we # see it...and it may use a cached result instead of sending a # request that we can intercept. When sshuttle starts and stops, # this means that we should clear the cache! # # The command to do this was named systemd-resolve, but changed to # resolvectl in systemd 239. # https://github.com/systemd/systemd/blob/f8eb41003df1a4eab59ff9bec67b2787c9368dbd/NEWS#L3816 p = None if helpers.which("resolvectl"): debug2("Flushing systemd's DNS resolver cache: " "resolvectl flush-caches") p = ssubprocess.Popen(["resolvectl", "flush-caches"], stdout=ssubprocess.PIPE, env=helpers.get_env()) elif helpers.which("systemd-resolve"): debug2("Flushing systemd's DNS resolver cache: " "systemd-resolve --flush-caches") p = ssubprocess.Popen(["systemd-resolve", "--flush-caches"], stdout=ssubprocess.PIPE, env=helpers.get_env()) if p: # Wait so flush is finished and process doesn't show up as defunct. rv = p.wait() if rv != 0: log("Received non-zero return code %d when flushing DNS resolver " "cache." % rv) # This is some voodoo for setting up the kernel's transparent # proxying stuff. If subnets is empty, we just delete our sshuttle rules; # otherwise we delete it, then make them from scratch. # # This code is supposed to clean up after itself by deleting its rules on # exit. In case that fails, it's not the end of the world; future runs will # supersede it in the transproxy list, at least, so the leftover rules # are hopefully harmless. def main(method_name, syslog): helpers.logprefix = 'fw: ' stdin, stdout = setup_daemon() hostmap = {} debug1('Starting firewall with Python version %s' % platform.python_version()) if method_name == "auto": method = get_auto_method() else: method = get_method(method_name) if syslog: ssyslog.start_syslog() ssyslog.stderr_to_syslog() if not method.is_supported(): raise Fatal("The %s method is not supported on this machine. " "Check that the appropriate programs are in your " "PATH." % method_name) debug1('ready method name %s.' % method.name) stdout.write(('READY %s\n' % method.name).encode('ASCII')) stdout.flush() def _read_next_string_line(): try: line = stdin.readline(128) if not line: return # parent probably exited return line.decode('ASCII').strip() except IOError as e: # On windows, ConnectionResetError is thrown when parent process closes it's socket pair end debug3('read from stdin failed: %s' % (e,)) return # we wait until we get some input before creating the rules. That way, # sshuttle can launch us as early as possible (and get sudo password # authentication as early in the startup process as possible). try: line = _read_next_string_line() if not line: return # parent probably exited except IOError as e: # On windows, ConnectionResetError is thrown when parent process closes it's socket pair end debug3('read from stdin failed: %s' % (e,)) return subnets = [] if line != 'ROUTES': raise Fatal('expected ROUTES but got %r' % line) while 1: line = _read_next_string_line() if not line: raise Fatal('expected route but got %r' % line) elif line.startswith("NSLIST"): break try: (family, width, exclude, ip, fport, lport) = line.split(',', 5) except Exception: raise Fatal('expected route or NSLIST but got %r' % line) subnets.append(( int(family), int(width), bool(int(exclude)), ip, int(fport), int(lport))) debug2('Got subnets: %r' % subnets) nslist = [] if line != 'NSLIST': raise Fatal('expected NSLIST but got %r' % line) while 1: line = _read_next_string_line() if not line: raise Fatal('expected nslist but got %r' % line) elif line.startswith("PORTS "): break try: (family, ip) = line.split(',', 1) except Exception: raise Fatal('expected nslist or PORTS but got %r' % line) nslist.append((int(family), ip)) debug2('Got partial nslist: %r' % nslist) debug2('Got nslist: %r' % nslist) if not line.startswith('PORTS '): raise Fatal('expected PORTS but got %r' % line) _, _, ports = line.partition(" ") ports = ports.split(",") if len(ports) != 4: raise Fatal('expected 4 ports but got %d' % len(ports)) port_v6 = int(ports[0]) port_v4 = int(ports[1]) dnsport_v6 = int(ports[2]) dnsport_v4 = int(ports[3]) assert port_v6 >= 0 assert port_v6 <= 65535 assert port_v4 >= 0 assert port_v4 <= 65535 assert dnsport_v6 >= 0 assert dnsport_v6 <= 65535 assert dnsport_v4 >= 0 assert dnsport_v4 <= 65535 debug2('Got ports: %d,%d,%d,%d' % (port_v6, port_v4, dnsport_v6, dnsport_v4)) line = _read_next_string_line() if not line or not line.startswith("GO "): raise Fatal('expected GO but got %r' % line) _, _, args = line.partition(" ") global sshuttle_pid udp, user, group, tmark, sshuttle_pid = args.split(" ", 4) udp = bool(int(udp)) sshuttle_pid = int(sshuttle_pid) if user == '-': user = None if group == '-': group = None debug2('Got udp: %r, user: %r, group: %r, tmark: %s, sshuttle_pid: %d' % (udp, user, group, tmark, sshuttle_pid)) subnets_v6 = [i for i in subnets if i[0] == socket.AF_INET6] nslist_v6 = [i for i in nslist if i[0] == socket.AF_INET6] subnets_v4 = [i for i in subnets if i[0] == socket.AF_INET] nslist_v4 = [i for i in nslist if i[0] == socket.AF_INET] try: debug1('setting up.') if subnets_v6 or nslist_v6: debug2('setting up IPv6.') method.setup_firewall( port_v6, dnsport_v6, nslist_v6, socket.AF_INET6, subnets_v6, udp, user, group, tmark) if subnets_v4 or nslist_v4: debug2('setting up IPv4.') method.setup_firewall( port_v4, dnsport_v4, nslist_v4, socket.AF_INET, subnets_v4, udp, user, group, tmark) try: # For some methods (eg: windivert) firewall setup will be differed / will run asynchronously. # Such method implements wait_for_firewall_ready() to wait until firewall is up and running. method.wait_for_firewall_ready(sshuttle_pid) except NotImplementedError: pass if sys.platform == 'linux': flush_systemd_dns_cache() try: stdout.write(b'STARTED\n') stdout.flush() except IOError as e: # the parent process probably died debug3('write to stdout failed: %s' % (e,)) return # Now we wait until EOF or any other kind of exception. We need # to stay running so that we don't need a *second* password # authentication at shutdown time - that cleanup is important! while 1: line = _read_next_string_line() if not line: return if line.startswith('HOST '): (name, ip) = line[5:].split(',', 1) hostmap[name] = ip debug2('setting up /etc/hosts.') rewrite_etc_hosts(hostmap, port_v6 or port_v4) elif line: if not method.firewall_command(line): raise Fatal('expected command, got %r' % line) else: break finally: try: debug1('undoing changes.') except Exception: debug2('An error occurred, ignoring it.') try: if subnets_v6 or nslist_v6: debug2('undoing IPv6 changes.') method.restore_firewall(port_v6, socket.AF_INET6, udp, user, group) except Exception: try: debug1("Error trying to undo IPv6 firewall.") debug1(traceback.format_exc()) except Exception: debug2('An error occurred, ignoring it.') try: if subnets_v4 or nslist_v4: debug2('undoing IPv4 changes.') method.restore_firewall(port_v4, socket.AF_INET, udp, user, group) except Exception: try: debug1("Error trying to undo IPv4 firewall.") debug1(traceback.format_exc()) except Exception: debug2('An error occurred, ignoring it.') try: # debug2() message printed in restore_etc_hosts() function. restore_etc_hosts(hostmap, port_v6 or port_v4) except Exception: try: debug1("Error trying to undo /etc/hosts changes.") debug1(traceback.format_exc()) except Exception: debug2('An error occurred, ignoring it.') if sys.platform == 'linux': try: flush_systemd_dns_cache() except Exception: try: debug1("Error trying to flush systemd dns cache.") debug1(traceback.format_exc()) except Exception: debug2("An error occurred, ignoring it.") sshuttle-1.3.1/sshuttle/helpers.py000066400000000000000000000253151477061133000172660ustar00rootroot00000000000000import sys import socket import errno import os import threading import subprocess import traceback import re if sys.platform != "win32": import fcntl logprefix = '' verbose = 0 def b(s): return s.encode("ASCII") def get_verbose_level(): return verbose def log(s): global logprefix try: sys.stdout.flush() except (IOError, ValueError): # ValueError ~ I/O operation on closed file pass try: # Put newline at end of string if line doesn't have one. if not s.endswith("\n"): s = s+"\n" prefix = logprefix s = s.rstrip("\n") for line in s.split("\n"): sys.stderr.write(prefix + line + "\n") prefix = " " sys.stderr.flush() except (IOError, ValueError): # ValueError ~ I/O operation on closed file # this could happen if stderr gets forcibly disconnected, eg. because # our tty closes. That sucks, but it's no reason to abort the program. pass def debug1(s): if verbose >= 1: log(s) def debug2(s): if verbose >= 2: log(s) def debug3(s): if verbose >= 3: log(s) class Fatal(Exception): pass def resolvconf_nameservers(systemd_resolved): """Retrieves a list of tuples (address type, address as a string) of the DNS servers used by the system to resolve hostnames. If parameter is False, DNS servers are retrieved from only /etc/resolv.conf. This behavior makes sense for the sshuttle server. If parameter is True, we retrieve information from both /etc/resolv.conf and /run/systemd/resolve/resolv.conf (if it exists). This behavior makes sense for the sshuttle client. """ # Historically, we just needed to read /etc/resolv.conf. # # If systemd-resolved is active, /etc/resolv.conf will point to # localhost and the actual DNS servers that systemd-resolved uses # are stored in /run/systemd/resolve/resolv.conf. For programs # that use the localhost DNS server, having sshuttle read # /etc/resolv.conf is sufficient. However, resolved provides other # ways of resolving hostnames (such as via dbus) that may not # route requests through localhost. So, we retrieve a list of DNS # servers that resolved uses so we can intercept those as well. # # For more information about systemd-resolved, see: # https://www.freedesktop.org/software/systemd/man/systemd-resolved.service.html # # On machines without systemd-resolved, we expect opening the # second file will fail. files = ['/etc/resolv.conf'] if systemd_resolved: files += ['/run/systemd/resolve/resolv.conf'] nsservers = [] for f in files: this_file_nsservers = [] try: for line in open(f): words = line.lower().split() if len(words) >= 2 and words[0] == 'nameserver': this_file_nsservers.append(family_ip_tuple(words[1])) debug2("Found DNS servers in %s: %s" % (f, [n[1] for n in this_file_nsservers])) nsservers += this_file_nsservers except OSError as e: debug3("Failed to read %s when looking for DNS servers: %s" % (f, e.strerror)) return nsservers def windows_nameservers(): out = subprocess.check_output(["powershell", "-NonInteractive", "-NoProfile", "-Command", "Get-DnsClientServerAddress"], encoding="utf-8") servers = set() for line in out.splitlines(): if line.startswith("Loopback "): continue m = re.search(r'{.+}', line) if not m: continue for s in m.group().strip('{}').split(','): s = s.strip() if s.startswith('fec0:0:0:ffff'): continue servers.add(s) debug2("Found DNS servers: %s" % servers) return [(socket.AF_INET6 if ':' in s else socket.AF_INET, s) for s in servers] def get_random_nameserver(): """Return a random nameserver selected from servers produced by resolvconf_nameservers()/windows_nameservers() """ if sys.platform == "win32": if globals().get('_nameservers') is None: ns_list = windows_nameservers() globals()['_nameservers'] = ns_list else: ns_list = globals()['_nameservers'] else: ns_list = resolvconf_nameservers(systemd_resolved=False) if ns_list: if len(ns_list) > 1: # don't import this unless we really need it import random random.shuffle(ns_list) return ns_list[0] else: return (socket.AF_INET, '127.0.0.1') def islocal(ip, family): sock = socket.socket(family) try: try: sock.bind((ip, 0)) except socket.error: _, e = sys.exc_info()[:2] if e.args[0] == errno.EADDRNOTAVAIL: return False # not a local IP else: raise finally: sock.close() return True # it's a local IP, or there would have been an error def family_ip_tuple(ip): if ':' in ip: return (socket.AF_INET6, ip) else: return (socket.AF_INET, ip) def family_to_string(family): if family == socket.AF_INET6: return "AF_INET6" elif family == socket.AF_INET: return "AF_INET" else: return str(family) def get_env(): """An environment for sshuttle subprocesses. See get_path().""" env = { 'PATH': get_path(), 'LC_ALL': "C", } return env def get_path(): """Returns a string of paths separated by os.pathsep. Users might not have all of the programs sshuttle needs in their PATH variable (i.e., some programs might be in /sbin). Use PATH and a hardcoded set of paths to search through. This function is used by our which() and get_env() functions. If which() and the subprocess environments differ, programs that which() finds might not be found at run time (or vice versa). """ path = [] if "PATH" in os.environ: path += os.environ["PATH"].split(os.pathsep) # Python default paths. path += os.defpath.split(os.pathsep) # /sbin, etc are not in os.defpath and may not be in PATH either. # /bin/ and /usr/bin below are probably redundant. path += ['/bin', '/usr/bin', '/sbin', '/usr/sbin'] # Remove duplicates. Not strictly necessary. path_dedup = [] for i in path: if i not in path_dedup: path_dedup.append(i) return os.pathsep.join(path_dedup) if sys.version_info >= (3, 3): from shutil import which as _which else: # Although sshuttle does not officially support older versions of # Python, some still run the sshuttle server on remote machines # with old versions of python. def _which(file, mode=os.F_OK | os.X_OK, path=None): if path is not None: search_paths = path.split(os.pathsep) elif "PATH" in os.environ: search_paths = os.environ["PATH"].split(os.pathsep) else: search_paths = os.defpath.split(os.pathsep) for p in search_paths: filepath = os.path.join(p, file) if os.path.exists(filepath) and os.access(filepath, mode): return filepath return None def which(file, mode=os.F_OK | os.X_OK): """A wrapper around shutil.which() that searches a predictable set of paths and is more verbose about what is happening. See get_path() for more information. """ path = get_path() rv = _which(file, mode, path) if rv: debug2("which() found '%s' at %s" % (file, rv)) else: debug2("which() could not find '%s' in %s" % (file, path)) return rv def is_admin_user(): if sys.platform == 'win32': # https://stackoverflow.com/questions/130763/request-uac-elevation-from-within-a-python-script/41930586#41930586 import ctypes try: return ctypes.windll.shell32.IsUserAnAdmin() except Exception: return False # TODO(nom3ad): for sys.platform == 'linux', check capabilities for non-root users. (CAP_NET_ADMIN might be enough?) return os.getuid() == 0 def set_non_blocking_io(fd): if sys.platform != "win32": try: os.set_blocking(fd, False) except AttributeError: # python < 3.5 flags = fcntl.fcntl(fd, fcntl.F_GETFL) flags |= os.O_NONBLOCK fcntl.fcntl(fd, fcntl.F_SETFL, flags) else: _sock = socket.fromfd(fd, socket.AF_INET, socket.SOCK_STREAM) _sock.setblocking(False) class RWPair: def __init__(self, r, w): self.r = r self.w = w self.read = r.read self.readline = r.readline self.write = w.write self.flush = w.flush def close(self): for f in self.r, self.w: try: f.close() except Exception: pass class SocketRWShim: __slots__ = ('_r', '_w', '_on_end', '_s1', '_s2', '_t1', '_t2') def __init__(self, r, w, on_end=None): self._r = r self._w = w self._on_end = on_end self._s1, self._s2 = socket.socketpair() debug3("[SocketShim] r=%r w=%r | s1=%r s2=%r" % (self._r, self._w, self._s1, self._s2)) def stream_reader_to_sock(): try: for data in iter(lambda: self._r.read(16384), b''): self._s1.sendall(data) # debug3("[SocketRWShim] <<<<< r.read() %d %r..." % (len(data), data[:min(32, len(data))])) except Exception: traceback.print_exc(file=sys.stderr) finally: debug2("[SocketRWShim] Thread 'stream_reader_to_sock' exiting") self._s1.close() self._on_end and self._on_end() def stream_sock_to_writer(): try: for data in iter(lambda: self._s1.recv(16384), b''): while data: n = self._w.write(data) data = data[n:] # debug3("[SocketRWShim] <<<<< w.write() %d %r..." % (len(data), data[:min(32, len(data))])) except Exception: traceback.print_exc(file=sys.stderr) finally: debug2("[SocketRWShim] Thread 'stream_sock_to_writer' exiting") self._s1.close() self._on_end and self._on_end() self._t1 = threading.Thread(target=stream_reader_to_sock, name='stream_reader_to_sock', daemon=True).start() self._t2 = threading.Thread(target=stream_sock_to_writer, name='stream_sock_to_writer', daemon=True).start() def makefiles(self): return self._s2.makefile("rb", buffering=0), self._s2.makefile("wb", buffering=0) sshuttle-1.3.1/sshuttle/hostwatch.py000066400000000000000000000157371477061133000176370ustar00rootroot00000000000000import time import socket import re import select import errno import os import sys import platform import subprocess as ssubprocess import sshuttle.helpers as helpers from sshuttle.helpers import log, debug1, debug2, debug3, get_env POLL_TIME = 60 * 15 NETSTAT_POLL_TIME = 30 CACHEFILE = os.path.expanduser('~/.sshuttle.hosts') # Have we already failed to write CACHEFILE? CACHE_WRITE_FAILED = False SHOULD_WRITE_CACHE = False hostnames = {} queue = {} try: null = open(os.devnull, 'wb') except IOError: _, e = sys.exc_info()[:2] log('warning: %s' % e) null = os.popen("sh -c 'while read x; do :; done'", 'wb', 4096) def _is_ip(s): return re.match(r'\d+\.\d+\.\d+\.\d+$', s) def write_host_cache(): """If possible, write our hosts file to disk so future connections can reuse the hosts that we already found.""" tmpname = '%s.%d.tmp' % (CACHEFILE, os.getpid()) global CACHE_WRITE_FAILED try: f = open(tmpname, 'wb') for name, ip in sorted(hostnames.items()): f.write(('%s,%s\n' % (name, ip)).encode("ASCII")) f.close() os.chmod(tmpname, 384) # 600 in octal, 'rw-------' os.rename(tmpname, CACHEFILE) CACHE_WRITE_FAILED = False except (OSError, IOError): # Write message if we haven't yet or if we get a failure after # a previous success. if not CACHE_WRITE_FAILED: log("Failed to write host cache to temporary file " "%s and rename it to %s" % (tmpname, CACHEFILE)) CACHE_WRITE_FAILED = True try: os.unlink(tmpname) except Exception: pass def read_host_cache(): """If possible, read the cache file from disk to populate hosts that were found in a previous sshuttle run.""" try: f = open(CACHEFILE) except (OSError, IOError): _, e = sys.exc_info()[:2] if e.errno == errno.ENOENT: return else: log("Failed to read existing host cache file %s on remote host" % CACHEFILE) return for line in f: words = line.strip().split(',') if len(words) == 2: (name, ip) = words name = re.sub(r'[^-\w\.]', '-', name).strip() # Remove characters that shouldn't be in IP ip = re.sub(r'[^0-9.]', '', ip).strip() if name and ip: found_host(name, ip) f.close() global SHOULD_WRITE_CACHE if SHOULD_WRITE_CACHE: write_host_cache() SHOULD_WRITE_CACHE = False def found_host(name, ip): """The provided name maps to the given IP. Add the host to the hostnames list, send the host to the sshuttle client via stdout, and write the host to the cache file. """ hostname = re.sub(r'\..*', '', name) hostname = re.sub(r'[^-\w\.]', '_', hostname) if (ip.startswith('127.') or ip.startswith('255.') or hostname == 'localhost'): return if hostname != name: found_host(hostname, ip) global SHOULD_WRITE_CACHE oldip = hostnames.get(name) if oldip != ip: hostnames[name] = ip debug1('Found: %s: %s' % (name, ip)) sys.stdout.write('%s,%s\n' % (name, ip)) SHOULD_WRITE_CACHE = True def _check_etc_hosts(): """If possible, read /etc/hosts to find hosts.""" filename = '/etc/hosts' debug2(' > Reading %s on remote host' % filename) try: for line in open(filename): line = re.sub(r'#.*', '', line) # remove comments words = line.strip().split() if not words: continue ip = words[0] if _is_ip(ip): names = words[1:] debug3('< %s %r' % (ip, names)) for n in names: check_host(n) found_host(n, ip) except (OSError, IOError): debug1("Failed to read %s on remote host" % filename) def _check_revdns(ip): """Use reverse DNS to try to get hostnames from an IP addresses.""" debug2(' > rev: %s' % ip) try: r = socket.gethostbyaddr(ip) debug3('< %s' % r[0]) check_host(r[0]) found_host(r[0], ip) except (OSError, socket.error, UnicodeError): # This case is expected to occur regularly. # debug3('< %s gethostbyaddr failed on remote host' % ip) pass def _check_dns(hostname): debug2(' > dns: %s' % hostname) try: ip = socket.gethostbyname(hostname) debug3('< %s' % ip) check_host(ip) found_host(hostname, ip) except (socket.gaierror, UnicodeError): pass def _check_netstat(): debug2(' > netstat') argv = ['netstat', '-n'] try: p = ssubprocess.Popen(argv, stdout=ssubprocess.PIPE, stderr=null, env=get_env()) content = p.stdout.read().decode("ASCII") p.wait() except OSError: _, e = sys.exc_info()[:2] log('%r failed: %r' % (argv, e)) return # The same IPs may appear multiple times. Consolidate them so the # debug message doesn't print the same IP repeatedly. ip_list = [] for ip in re.findall(r'\d+\.\d+\.\d+\.\d+', content): if ip not in ip_list: ip_list.append(ip) for ip in sorted(ip_list): debug3('< %s' % ip) check_host(ip) def check_host(hostname): if _is_ip(hostname): _enqueue(_check_revdns, hostname) else: _enqueue(_check_dns, hostname) def _enqueue(op, *args): t = (op, args) if queue.get(t) is None: queue[t] = 0 def _stdin_still_ok(timeout): r, _, _ = select.select([sys.stdin.fileno()], [], [], timeout) if r: b = os.read(sys.stdin.fileno(), 4096) if not b: return False return True def hw_main(seed_hosts, auto_hosts): helpers.logprefix = 'HH: ' debug1('Starting hostwatch with Python version %s' % platform.python_version()) for h in seed_hosts: check_host(h) if auto_hosts: read_host_cache() _enqueue(_check_etc_hosts) _enqueue(_check_netstat) check_host('localhost') check_host(socket.gethostname()) while 1: now = time.time() # For each item in the queue for t, last_polled in list(queue.items()): (op, args) = t if not _stdin_still_ok(0): break # Determine if we need to run. maxtime = POLL_TIME # netstat runs more often than other jobs if op == _check_netstat: maxtime = NETSTAT_POLL_TIME # Check if this jobs needs to run. if now - last_polled > maxtime: queue[t] = time.time() op(*args) try: sys.stdout.flush() except IOError: break # FIXME: use a smarter timeout based on oldest last_polled if not _stdin_still_ok(1): # sleeps for up to 1 second break sshuttle-1.3.1/sshuttle/linux.py000066400000000000000000000032441477061133000167600ustar00rootroot00000000000000import socket import subprocess as ssubprocess from sshuttle.helpers import log, debug1, Fatal, family_to_string, get_env def nonfatal(func, *args): try: func(*args) except Fatal as e: log('error: %s' % e) def ipt_chain_exists(family, table, name): if family == socket.AF_INET6: cmd = 'ip6tables' elif family == socket.AF_INET: cmd = 'iptables' else: raise Exception('Unsupported family "%s"' % family_to_string(family)) argv = [cmd, '-w', '-t', table, '-nL'] try: output = ssubprocess.check_output(argv, env=get_env()) for line in output.decode('ASCII', errors='replace').split('\n'): if line.startswith('Chain %s ' % name): return True except ssubprocess.CalledProcessError as e: raise Fatal('%r returned %d' % (argv, e.returncode)) def ipt(family, table, *args): if family == socket.AF_INET6: argv = ['ip6tables', '-w', '-t', table] + list(args) elif family == socket.AF_INET: argv = ['iptables', '-w', '-t', table] + list(args) else: raise Exception('Unsupported family "%s"' % family_to_string(family)) debug1('%s' % ' '.join(argv)) rv = ssubprocess.call(argv, env=get_env()) if rv: raise Fatal('%r returned %d' % (argv, rv)) def nft(family, table, action, *args): if family in (socket.AF_INET, socket.AF_INET6): argv = ['nft', action, 'inet', table] + list(args) else: raise Exception('Unsupported family "%s"' % family_to_string(family)) debug1('%s' % ' '.join(argv)) rv = ssubprocess.call(argv, env=get_env()) if rv: raise Fatal('%r returned %d' % (argv, rv)) sshuttle-1.3.1/sshuttle/methods/000077500000000000000000000000001477061133000167075ustar00rootroot00000000000000sshuttle-1.3.1/sshuttle/methods/__init__.py000066400000000000000000000073531477061133000210300ustar00rootroot00000000000000import importlib import socket import struct import sys import errno import ipaddress from sshuttle.helpers import Fatal, debug3 def original_dst(sock): try: family = sock.family SO_ORIGINAL_DST = 80 if family == socket.AF_INET: SOCKADDR_MIN = 16 sockaddr_in = sock.getsockopt(socket.SOL_IP, SO_ORIGINAL_DST, SOCKADDR_MIN) port, raw_ip = struct.unpack_from('!2xH4s', sockaddr_in[:8]) ip = str(ipaddress.IPv4Address(raw_ip)) elif family == socket.AF_INET6: sockaddr_in = sock.getsockopt(41, SO_ORIGINAL_DST, 64) port, raw_ip = struct.unpack_from("!2xH4x16s", sockaddr_in) ip = str(ipaddress.IPv6Address(raw_ip)) else: raise Fatal("fw: Unknown family type.") except socket.error as e: if e.args[0] == errno.ENOPROTOOPT: return sock.getsockname() raise return (ip, port) class Features(object): pass class BaseMethod(object): def __init__(self, name): self.firewall = None self.name = name def set_firewall(self, firewall): self.firewall = firewall @staticmethod def get_supported_features(): result = Features() result.loopback_proxy_port = True result.ipv4 = True result.ipv6 = False result.udp = False result.dns = True result.user = False result.group = False return result @staticmethod def is_supported(): """Returns true if it appears that this method will work on this machine.""" return False @staticmethod def get_tcp_dstip(sock): return original_dst(sock) @staticmethod def recv_udp(udp_listener, bufsize): debug3('Accept UDP using recvfrom.') data, srcip = udp_listener.recvfrom(bufsize) return (srcip, None, data) def send_udp(self, sock, srcip, dstip, data): if srcip is not None: raise Fatal("Method %s send_udp does not support setting srcip to %r" % (self.name, srcip)) sock.sendto(data, dstip) def setup_tcp_listener(self, tcp_listener): pass def setup_udp_listener(self, udp_listener): pass def assert_features(self, features): avail = self.get_supported_features() for key in ["udp", "dns", "ipv6", "ipv4", "user"]: if getattr(features, key) and not getattr(avail, key): raise Fatal( "Feature %s not supported with method %s." % (key, self.name)) def setup_firewall(self, port, dnsport, nslist, family, subnets, udp, user, group, tmark): raise NotImplementedError() def restore_firewall(self, port, family, udp, user, group): raise NotImplementedError() def wait_for_firewall_ready(self, sshuttle_pid): raise NotImplementedError() @staticmethod def firewall_command(line): return False def get_method(method_name): module = importlib.import_module("sshuttle.methods.%s" % method_name) return module.Method(method_name) def get_auto_method(): debug3("Selecting a method automatically...") # Try these methods, in order: methods_to_try = ["nat", "nft", "pf", "ipfw"] if sys.platform != "win32" else ["windivert"] for m in methods_to_try: method = get_method(m) if method.is_supported(): debug3("Method '%s' was automatically selected." % m) return method raise Fatal("Unable to automatically find a supported method. Check that " "the appropriate programs are in your PATH. We tried " "methods: %s" % str(methods_to_try)) sshuttle-1.3.1/sshuttle/methods/ipfw.py000066400000000000000000000162321477061133000202320ustar00rootroot00000000000000import os import subprocess as ssubprocess from sshuttle.methods import BaseMethod from sshuttle.helpers import log, debug1, debug2, debug3, \ Fatal, family_to_string, get_env, which import socket IP_BINDANY = 24 IP_RECVDSTADDR = 7 SOL_IPV6 = 41 IPV6_RECVDSTADDR = 74 def recv_udp(listener, bufsize): debug3('Accept UDP python using recvmsg.') data, ancdata, _, srcip = listener.recvmsg(4096, socket.CMSG_SPACE(4)) dstip = None for cmsg_level, cmsg_type, cmsg_data in ancdata: if cmsg_level == socket.SOL_IP and cmsg_type == IP_RECVDSTADDR: port = 53 ip = socket.inet_ntop(socket.AF_INET, cmsg_data[0:4]) dstip = (ip, port) break return (srcip, dstip, data) def ipfw_rule_exists(n): argv = ['ipfw', 'list', '%d' % n] p = ssubprocess.Popen(argv, stdout=ssubprocess.PIPE, env=get_env()) found = False for line in p.stdout: if line.startswith(b'%05d ' % n): if 'check-state :sshuttle' not in line: log('non-sshuttle ipfw rule: %r' % line.strip()) raise Fatal('non-sshuttle ipfw rule #%d already exists!' % n) found = True break rv = p.wait() if rv: raise Fatal('%r returned %d' % (argv, rv)) return found _oldctls = {} def _fill_oldctls(prefix): argv = ['sysctl', prefix] p = ssubprocess.Popen(argv, stdout=ssubprocess.PIPE, env=get_env()) for line in p.stdout: line = line.decode() assert line[-1] == '\n' (k, v) = line[:-1].split(': ', 1) _oldctls[k] = v.strip() rv = p.wait() if rv: raise Fatal('%r returned %d' % (argv, rv)) if not line: raise Fatal('%r returned no data' % (argv,)) def _sysctl_set(name, val): argv = ['sysctl', '-w', '%s=%s' % (name, val)] debug1('>> %s' % ' '.join(argv)) return ssubprocess.call(argv, stdout=open(os.devnull, 'w'), env=get_env()) # No env: No output. (Or error that won't be parsed.) _changedctls = [] def sysctl_set(name, val, permanent=False): PREFIX = 'net.inet.ip' assert name.startswith(PREFIX + '.') val = str(val) if not _oldctls: _fill_oldctls(PREFIX) if not (name in _oldctls): debug1('>> No such sysctl: %r' % name) return False oldval = _oldctls[name] if val != oldval: rv = _sysctl_set(name, val) if rv == 0 and permanent: debug1('>> ...saving permanently in /etc/sysctl.conf') f = open('/etc/sysctl.conf', 'a') f.write('\n' '# Added by sshuttle\n' '%s=%s\n' % (name, val)) f.close() else: _changedctls.append(name) return True def ipfw(*args): argv = ['ipfw', '-q'] + list(args) debug1('>> %s' % ' '.join(argv)) rv = ssubprocess.call(argv, env=get_env()) # No env: No output. (Or error that won't be parsed.) if rv: raise Fatal('%r returned %d' % (argv, rv)) def ipfw_noexit(*args): argv = ['ipfw', '-q'] + list(args) debug1('>> %s' % ' '.join(argv)) ssubprocess.call(argv, env=get_env()) # No env: No output. (Or error that won't be parsed.) class Method(BaseMethod): def get_supported_features(self): result = super(Method, self).get_supported_features() result.ipv6 = False result.udp = False # NOTE: Almost there, kernel patch needed result.dns = True return result def get_tcp_dstip(self, sock): return sock.getsockname() def recv_udp(self, udp_listener, bufsize): srcip, dstip, data = recv_udp(udp_listener, bufsize) if not dstip: debug1( "-- ignored UDP from %r: " "couldn't determine destination IP address" % (srcip,)) return None return srcip, dstip, data def send_udp(self, sock, srcip, dstip, data): if not srcip: debug1( "-- ignored UDP to %r: " "couldn't determine source IP address" % (dstip,)) return # debug3('Sending SRC: %r DST: %r' % (srcip, dstip)) sender = socket.socket(sock.family, socket.SOCK_DGRAM) sender.setsockopt(socket.SOL_IP, IP_BINDANY, 1) sender.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sender.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) sender.bind(srcip) sender.sendto(data, dstip) sender.close() def setup_udp_listener(self, udp_listener): if udp_listener.v4 is not None: udp_listener.v4.setsockopt(socket.SOL_IP, IP_RECVDSTADDR, 1) # if udp_listener.v6 is not None: # udp_listener.v6.setsockopt(SOL_IPV6, IPV6_RECVDSTADDR, 1) def setup_firewall(self, port, dnsport, nslist, family, subnets, udp, user, group, tmark): # IPv6 not supported if family not in [socket.AF_INET]: raise Exception( 'Address family "%s" unsupported by ipfw method_name' % family_to_string(family)) # XXX: Any risk from this? ipfw_noexit('delete', '1') while _changedctls: name = _changedctls.pop() oldval = _oldctls[name] _sysctl_set(name, oldval) if subnets or dnsport: sysctl_set('net.inet.ip.fw.enable', 1) ipfw('add', '1', 'check-state', ':sshuttle') ipfw('add', '1', 'skipto', '2', 'tcp', 'from', 'any', 'to', 'table(125)') ipfw('add', '1', 'fwd', '127.0.0.1,%d' % port, 'tcp', 'from', 'any', 'to', 'table(126)', 'setup', 'keep-state', ':sshuttle') ipfw_noexit('table', '124', 'flush') dnscount = 0 for _, ip in [i for i in nslist if i[0] == family]: ipfw('table', '124', 'add', '%s' % (ip)) dnscount += 1 if dnscount > 0: ipfw('add', '1', 'fwd', '127.0.0.1,%d' % dnsport, 'udp', 'from', 'any', 'to', 'table(124)', 'keep-state', ':sshuttle') ipfw('add', '1', 'allow', 'udp', 'from', 'any', 'to', 'any') if subnets: # create new subnet entries for _, swidth, sexclude, snet, fport, lport \ in sorted(subnets, key=lambda s: s[1], reverse=True): if sexclude: ipfw('table', '125', 'add', '%s/%s' % (snet, swidth)) else: ipfw('table', '126', 'add', '%s/%s' % (snet, swidth)) def restore_firewall(self, port, family, udp, user, group): if family not in [socket.AF_INET]: raise Exception( 'Address family "%s" unsupported by ipfw method' % family_to_string(family)) ipfw_noexit('delete', '1') ipfw_noexit('table', '124', 'flush') ipfw_noexit('table', '125', 'flush') ipfw_noexit('table', '126', 'flush') def is_supported(self): if which("ipfw"): return True debug2("ipfw method not supported because 'ipfw' command is " "missing.") return False sshuttle-1.3.1/sshuttle/methods/nat.py000066400000000000000000000115171477061133000200500ustar00rootroot00000000000000import socket from sshuttle.firewall import subnet_weight from sshuttle.helpers import family_to_string, which, debug2 from sshuttle.linux import ipt, ipt_chain_exists, nonfatal from sshuttle.methods import BaseMethod class Method(BaseMethod): # We name the chain based on the transproxy port number so that it's # possible to run multiple copies of sshuttle at the same time. Of course, # the multiple copies shouldn't have overlapping subnets, or only the most- # recently-started one will win (because we use "-I OUTPUT 1" instead of # "-A OUTPUT"). def setup_firewall(self, port, dnsport, nslist, family, subnets, udp, user, group, tmark): if family != socket.AF_INET and family != socket.AF_INET6: raise Exception( 'Address family "%s" unsupported by nat method_name' % family_to_string(family)) if udp: raise Exception("UDP not supported by nat method_name") table = "nat" def _ipt(*args): return ipt(family, table, *args) def _ipm(*args): return ipt(family, "mangle", *args) chain = 'sshuttle-%s' % port # basic cleanup/setup of chains self.restore_firewall(port, family, udp, user, group) _ipt('-N', chain) _ipt('-F', chain) if user is not None or group is not None: margs = ['-I', 'OUTPUT', '1', '-m', 'owner'] if user is not None: margs += ['--uid-owner', str(user)] if group is not None: margs += ['--gid-owner', str(group)] margs += ['-j', 'MARK', '--set-mark', str(port)] nonfatal(_ipm, *margs) args = '-m', 'mark', '--mark', str(port), '-j', chain else: args = '-j', chain _ipt('-I', 'OUTPUT', '1', *args) _ipt('-I', 'PREROUTING', '1', *args) # Redirect DNS traffic as requested. This includes routing traffic # to localhost DNS servers through sshuttle. for _, ip in [i for i in nslist if i[0] == family]: _ipt('-A', chain, '-j', 'REDIRECT', '--dest', '%s' % ip, '-p', 'udp', '--dport', '53', '--to-ports', str(dnsport)) # create new subnet entries. for _, swidth, sexclude, snet, fport, lport \ in sorted(subnets, key=subnet_weight, reverse=True): tcp_ports = ('-p', 'tcp') if fport: tcp_ports = tcp_ports + ('--dport', '%d:%d' % (fport, lport)) if sexclude: _ipt('-A', chain, '-j', 'RETURN', '--dest', '%s/%s' % (snet, swidth), *tcp_ports) else: _ipt('-A', chain, '-j', 'REDIRECT', '--dest', '%s/%s' % (snet, swidth), *(tcp_ports + ('--to-ports', str(port)))) # Don't route any remaining local traffic through sshuttle. _ipt('-A', chain, '-j', 'RETURN', '-m', 'addrtype', '--dst-type', 'LOCAL') def restore_firewall(self, port, family, udp, user, group): # only ipv4 supported with NAT if family != socket.AF_INET and family != socket.AF_INET6: raise Exception( 'Address family "%s" unsupported by nat method_name' % family_to_string(family)) if udp: raise Exception("UDP not supported by nat method_name") table = "nat" def _ipt(*args): return ipt(family, table, *args) def _ipm(*args): return ipt(family, "mangle", *args) chain = 'sshuttle-%s' % port # basic cleanup/setup of chains if ipt_chain_exists(family, table, chain): if user is not None or group is not None: margs = ['-D', 'OUTPUT', '-m', 'owner'] if user is not None: margs += ['--uid-owner', str(user)] if group is not None: margs += ['--gid-owner', str(group)] margs += ['-j', 'MARK', '--set-mark', str(port)] nonfatal(_ipm, *margs) args = '-m', 'mark', '--mark', str(port), '-j', chain else: args = '-j', chain nonfatal(_ipt, '-D', 'OUTPUT', *args) nonfatal(_ipt, '-D', 'PREROUTING', *args) nonfatal(_ipt, '-F', chain) _ipt('-X', chain) def get_supported_features(self): result = super(Method, self).get_supported_features() result.user = True result.ipv6 = True result.group = True return result def is_supported(self): if which("iptables"): return True debug2("nat method not supported because 'iptables' command " "is missing.") return False sshuttle-1.3.1/sshuttle/methods/nft.py000066400000000000000000000106151477061133000200530ustar00rootroot00000000000000import socket from sshuttle.firewall import subnet_weight from sshuttle.linux import nft, nonfatal from sshuttle.methods import BaseMethod from sshuttle.helpers import debug2, which class Method(BaseMethod): # We name the chain based on the transproxy port number so that it's # possible to run multiple copies of sshuttle at the same time. Of course, # the multiple copies shouldn't have overlapping subnets, or only the most- # recently-started one will win (because we use "-I OUTPUT 1" instead of # "-A OUTPUT"). def setup_firewall(self, port, dnsport, nslist, family, subnets, udp, user, group, tmark): if udp: raise Exception("UDP not supported by nft") if family == socket.AF_INET: table = 'sshuttle-ipv4-%s' % port if family == socket.AF_INET6: table = 'sshuttle-ipv6-%s' % port def _nft(action, *args): return nft(family, table, action, *args) chain = table # basic cleanup/setup of chains _nft('add table', '') _nft('add chain', 'prerouting', '{ type nat hook prerouting priority -100; policy accept; }') _nft('add chain', 'output', '{ type nat hook output priority -100; policy accept; }') _nft('add chain', chain) _nft('flush chain', chain) _nft('add rule', 'output jump %s' % chain) _nft('add rule', 'prerouting jump %s' % chain) # setup_firewall() gets called separately for ipv4 and ipv6. Make sure # we only handle the version that we expect to. if family == socket.AF_INET: _nft('add rule', chain, 'meta', 'nfproto', '!=', 'ipv4', 'return') else: _nft('add rule', chain, 'meta', 'nfproto', '!=', 'ipv6', 'return') # Strings to use below to simplify our code if family == socket.AF_INET: ip_version_l = 'ipv4' ip_version = 'ip' elif family == socket.AF_INET6: ip_version_l = 'ipv6' ip_version = 'ip6' # Redirect DNS traffic as requested. This includes routing traffic # to localhost DNS servers through sshuttle. for _, ip in [i for i in nslist if i[0] == family]: _nft('add rule', chain, ip_version, 'daddr %s' % ip, 'udp dport 53', ('redirect to :' + str(dnsport))) # Don't route any remaining local traffic through sshuttle _nft('add rule', chain, 'fib daddr type local return') # create new subnet entries. for _, swidth, sexclude, snet, fport, lport \ in sorted(subnets, key=subnet_weight, reverse=True): # match using nfproto as described at # https://superuser.com/questions/1560376/match-ipv6-protocol-using-nftables if fport and fport != lport: tcp_ports = ('meta', 'nfproto', ip_version_l, 'tcp', 'dport', '{ %d-%d }' % (fport, lport)) elif fport and fport == lport: tcp_ports = ('meta', 'nfproto', ip_version_l, 'tcp', 'dport', '%d' % (fport)) else: tcp_ports = ('meta', 'nfproto', ip_version_l, 'meta', 'l4proto', 'tcp') if sexclude: _nft('add rule', chain, *(tcp_ports + ( ip_version, 'daddr %s/%s' % (snet, swidth), 'return'))) else: _nft('add rule', chain, *(tcp_ports + ( ip_version, 'daddr %s/%s' % (snet, swidth), ('redirect to :' + str(port))))) def restore_firewall(self, port, family, udp, user, group): if udp: raise Exception("UDP not supported by nft method_name") if family == socket.AF_INET: table = 'sshuttle-ipv4-%s' % port if family == socket.AF_INET6: table = 'sshuttle-ipv6-%s' % port def _nft(action, *args): return nft(family, table, action, *args) # basic cleanup/setup of chains nonfatal(_nft, 'delete table', '') def get_supported_features(self): result = super(Method, self).get_supported_features() result.ipv6 = True return result def is_supported(self): if which("nft"): return True debug2("nft method not supported because 'nft' command is missing.") return False sshuttle-1.3.1/sshuttle/methods/pf.py000066400000000000000000000422641477061133000176760ustar00rootroot00000000000000import os import sys import platform import re import socket import errno import struct import subprocess as ssubprocess import shlex from fcntl import ioctl from ctypes import c_char, c_uint8, c_uint16, c_uint32, Union, Structure, \ sizeof, addressof, memmove from sshuttle.firewall import subnet_weight from sshuttle.helpers import log, debug1, debug2, debug3, Fatal, \ family_to_string, get_env, which from sshuttle.methods import BaseMethod _pf_context = { 'started_by_sshuttle': 0, 'loaded_by_sshuttle': True, 'Xtoken': [] } _pf_fd = None class Generic(object): MAXPATHLEN = 1024 PF_CHANGE_ADD_TAIL = 2 PF_CHANGE_GET_TICKET = 6 PF_PASS = 0 PF_RDR = 8 PF_OUT = 2 ACTION_OFFSET = 0 POOL_TICKET_OFFSET = 8 ANCHOR_CALL_OFFSET = 1040 class pf_addr(Structure): class _pfa(Union): _fields_ = [("v4", c_uint32), # struct in_addr ("v6", c_uint32 * 4), # struct in6_addr ("addr8", c_uint8 * 16), ("addr16", c_uint16 * 8), ("addr32", c_uint32 * 4)] _fields_ = [("pfa", _pfa)] _anonymous_ = ("pfa",) def __init__(self): self.status = b'' self.pfioc_pooladdr = c_char * 1136 self.DIOCNATLOOK = ( (0x40000000 | 0x80000000) | ((sizeof(self.pfioc_natlook) & 0x1fff) << 16) | ((ord('D')) << 8) | (23)) self.DIOCCHANGERULE = ( (0x40000000 | 0x80000000) | ((sizeof(self.pfioc_rule) & 0x1fff) << 16) | ((ord('D')) << 8) | (26)) self.DIOCBEGINADDRS = ( (0x40000000 | 0x80000000) | ((sizeof(self.pfioc_pooladdr) & 0x1fff) << 16) | ((ord('D')) << 8) | (51)) def enable(self): if b'INFO:\nStatus: Disabled' in self.status: pfctl('-e') _pf_context['started_by_sshuttle'] += 1 @staticmethod def disable(anchor): pfctl('-a %s -F all' % anchor) if _pf_context['started_by_sshuttle'] == 1: pfctl('-d') _pf_context['started_by_sshuttle'] -= 1 def query_nat(self, family, proto, src_ip, src_port, dst_ip, dst_port): [proto, family, src_port, dst_port] = [ int(v) for v in [proto, family, src_port, dst_port]] packed_src_ip = socket.inet_pton(family, src_ip) packed_dst_ip = socket.inet_pton(family, dst_ip) assert len(packed_src_ip) == len(packed_dst_ip) length = len(packed_src_ip) pnl = self.pfioc_natlook() pnl.proto = proto pnl.direction = self.PF_OUT pnl.af = family memmove(addressof(pnl.saddr), packed_src_ip, length) memmove(addressof(pnl.daddr), packed_dst_ip, length) self._add_natlook_ports(pnl, src_port, dst_port) ioctl(pf_get_dev(), self.DIOCNATLOOK, (c_char * sizeof(pnl)).from_address(addressof(pnl))) ip = socket.inet_ntop( pnl.af, (c_char * length).from_address(addressof(pnl.rdaddr)).raw) port = socket.ntohs(self._get_natlook_port(pnl.rdxport)) return (ip, port) @staticmethod def _add_natlook_ports(pnl, src_port, dst_port): pnl.sxport = socket.htons(src_port) pnl.dxport = socket.htons(dst_port) @staticmethod def _get_natlook_port(xport): return xport def add_anchors(self, anchor, status=None): if status is None: status = pfctl('-s all')[0] self.status = status if ('\nanchor "%s"' % anchor).encode('ASCII') not in status: self._add_anchor_rule(self.PF_PASS, anchor.encode('ASCII')) def _add_anchor_rule(self, kind, name, pr=None): if pr is None: pr = self.pfioc_rule() memmove(addressof(pr) + self.ANCHOR_CALL_OFFSET, name, min(self.MAXPATHLEN, len(name))) # anchor_call = name memmove(addressof(pr) + self.RULE_ACTION_OFFSET, struct.pack('I', kind), 4) # rule.action = kind memmove(addressof(pr) + self.ACTION_OFFSET, struct.pack('I', self.PF_CHANGE_GET_TICKET), 4) # action = PF_CHANGE_GET_TICKET ioctl(pf_get_dev(), pf.DIOCCHANGERULE, pr) memmove(addressof(pr) + self.ACTION_OFFSET, struct.pack('I', self.PF_CHANGE_ADD_TAIL), 4) # action = PF_CHANGE_ADD_TAIL ioctl(pf_get_dev(), pf.DIOCCHANGERULE, pr) @staticmethod def _inet_version(family): return b'inet' if family == socket.AF_INET else b'inet6' @staticmethod def _lo_addr(family): return b'127.0.0.1' if family == socket.AF_INET else b'::1' @staticmethod def add_rules(anchor, rules): assert isinstance(rules, bytes) debug3("rules:\n" + rules.decode("ASCII")) pfctl('-a %s -f /dev/stdin' % anchor, rules) @staticmethod def has_skip_loopback(): return b'skip' in pfctl('-s Interfaces -i lo -v')[0] class FreeBsd(Generic): RULE_ACTION_OFFSET = 2968 def __new__(cls): class pfioc_natlook(Structure): pf_addr = Generic.pf_addr _fields_ = [("saddr", pf_addr), ("daddr", pf_addr), ("rsaddr", pf_addr), ("rdaddr", pf_addr), ("sxport", c_uint16), ("dxport", c_uint16), ("rsxport", c_uint16), ("rdxport", c_uint16), ("af", c_uint8), # sa_family_t ("proto", c_uint8), ("proto_variant", c_uint8), ("direction", c_uint8)] freebsd = Generic.__new__(cls) freebsd.pfioc_rule = c_char * 3040 freebsd.pfioc_natlook = pfioc_natlook return freebsd def enable(self): returncode = ssubprocess.call(['kldload', 'pf'], env=get_env()) # No env: No output. super(FreeBsd, self).enable() if returncode == 0: _pf_context['loaded_by_sshuttle'] = True def disable(self, anchor): super(FreeBsd, self).disable(anchor) if _pf_context['loaded_by_sshuttle'] and \ _pf_context['started_by_sshuttle'] == 0: ssubprocess.call(['kldunload', 'pf'], env=get_env()) # No env: No output. def add_anchors(self, anchor): status = pfctl('-s all')[0] if ('\nrdr-anchor "%s"' % anchor).encode('ASCII') not in status: self._add_anchor_rule(self.PF_RDR, anchor.encode('ASCII')) super(FreeBsd, self).add_anchors(anchor, status=status) def _add_anchor_rule(self, kind, name, pr=None): pr = pr or self.pfioc_rule() ppa = self.pfioc_pooladdr() ioctl(pf_get_dev(), self.DIOCBEGINADDRS, ppa) # pool ticket memmove(addressof(pr) + self.POOL_TICKET_OFFSET, ppa[4:8], 4) super(FreeBsd, self)._add_anchor_rule(kind, name, pr=pr) def add_rules(self, anchor, includes, port, dnsport, nslist, family): inet_version = self._inet_version(family) lo_addr = self._lo_addr(family) tables = [] translating_rules = [ b'rdr pass on lo0 %s proto tcp from ! %s to %s ' b'-> %s port %r' % (inet_version, lo_addr, subnet, lo_addr, port) for exclude, subnet in includes if not exclude ] filtering_rules = [ b'pass out route-to lo0 %s proto tcp ' b'to %s keep state' % (inet_version, subnet) if not exclude else b'pass out %s proto tcp to %s' % (inet_version, subnet) for exclude, subnet in includes ] if nslist: tables.append( b'table {%s}' % b','.join([ns[1].encode("ASCII") for ns in nslist])) translating_rules.append( b'rdr pass on lo0 %s proto udp to ' b'port 53 -> %s port %r' % (inet_version, lo_addr, dnsport)) filtering_rules.append( b'pass out route-to lo0 %s proto udp to ' b' port 53 keep state' % inet_version) rules = b'\n'.join(tables + translating_rules + filtering_rules) \ + b'\n' super(FreeBsd, self).add_rules(anchor, rules) class OpenBsd(Generic): POOL_TICKET_OFFSET = 4 RULE_ACTION_OFFSET = 3324 ANCHOR_CALL_OFFSET = 1036 def __init__(self): class pfioc_natlook(Structure): pf_addr = Generic.pf_addr _fields_ = [("saddr", pf_addr), ("daddr", pf_addr), ("rsaddr", pf_addr), ("rdaddr", pf_addr), ("rdomain", c_uint16), ("rrdomain", c_uint16), ("sxport", c_uint16), ("dxport", c_uint16), ("rsxport", c_uint16), ("rdxport", c_uint16), ("af", c_uint8), # sa_family_t ("proto", c_uint8), ("proto_variant", c_uint8), ("direction", c_uint8)] self.pfioc_rule = c_char * 3408 self.pfioc_natlook = pfioc_natlook super(OpenBsd, self).__init__() def add_anchors(self, anchor): # before adding anchors and rules we must override the skip lo # that comes by default in openbsd pf.conf so the rules we will add, # which rely on translating/filtering packets on lo, can work if self.has_skip_loopback(): pfctl('-f /dev/stdin', b'match on lo\n') super(OpenBsd, self).add_anchors(anchor) def add_rules(self, anchor, includes, port, dnsport, nslist, family): inet_version = self._inet_version(family) lo_addr = self._lo_addr(family) tables = [] translating_rules = [ b'pass in on lo0 %s proto tcp to %s ' b'divert-to %s port %r' % (inet_version, subnet, lo_addr, port) for exclude, subnet in includes if not exclude ] filtering_rules = [ b'pass out %s proto tcp to %s ' b'route-to lo0 keep state' % (inet_version, subnet) if not exclude else b'pass out %s proto tcp to %s' % (inet_version, subnet) for exclude, subnet in includes ] if nslist: tables.append( b'table {%s}' % b','.join([ns[1].encode("ASCII") for ns in nslist])) translating_rules.append( b'pass in on lo0 %s proto udp to port 53 ' b'rdr-to %s port %r' % (inet_version, lo_addr, dnsport)) filtering_rules.append( b'pass out %s proto udp to port 53 ' b'route-to lo0 keep state' % inet_version) rules = b'\n'.join(tables + translating_rules + filtering_rules) \ + b'\n' super(OpenBsd, self).add_rules(anchor, rules) class Darwin(FreeBsd): RULE_ACTION_OFFSET = 3068 def __init__(self): class pf_state_xport(Union): _fields_ = [("port", c_uint16), ("call_id", c_uint16), ("spi", c_uint32)] class pfioc_natlook(Structure): pf_addr = Generic.pf_addr _fields_ = [("saddr", pf_addr), ("daddr", pf_addr), ("rsaddr", pf_addr), ("rdaddr", pf_addr), ("sxport", pf_state_xport), ("dxport", pf_state_xport), ("rsxport", pf_state_xport), ("rdxport", pf_state_xport), ("af", c_uint8), # sa_family_t ("proto", c_uint8), ("proto_variant", c_uint8), ("direction", c_uint8)] self.pfioc_rule = c_char * 3104 self.pfioc_natlook = pfioc_natlook super(Darwin, self).__init__() def enable(self): o = pfctl('-E') _pf_context['Xtoken'].append(re.search(b'Token : (.+)', o[1]).group(1)) def disable(self, anchor): pfctl('-a %s -F all' % anchor) if _pf_context['Xtoken']: pfctl('-X %s' % _pf_context['Xtoken'].pop().decode("ASCII")) def add_anchors(self, anchor): # before adding anchors and rules we must override the skip lo # that in some cases ends up in the chain so the rules we will add, # which rely on translating/filtering packets on lo, can work if self.has_skip_loopback(): pfctl('-f /dev/stdin', b'pass on lo\n') super(Darwin, self).add_anchors(anchor) def _add_natlook_ports(self, pnl, src_port, dst_port): pnl.sxport.port = socket.htons(src_port) pnl.dxport.port = socket.htons(dst_port) def _get_natlook_port(self, xport): return xport.port class PfSense(FreeBsd): RULE_ACTION_OFFSET = 3040 def __init__(self): self.pfioc_rule = c_char * 3112 super(PfSense, self).__init__() if sys.platform == 'darwin': pf = Darwin() elif sys.platform.startswith('openbsd'): pf = OpenBsd() elif platform.version().endswith('pfSense'): pf = PfSense() else: pf = FreeBsd() def pfctl(args, stdin=None): argv = ['pfctl'] + shlex.split(args) debug1('>> %s' % ' '.join(argv)) p = ssubprocess.Popen(argv, stdin=ssubprocess.PIPE, stdout=ssubprocess.PIPE, stderr=ssubprocess.PIPE, env=get_env()) o = p.communicate(stdin) if p.returncode: log('%r returned %d, stdout and stderr follows: ' % (argv, p.returncode)) log("stdout:\n%s" % o[0].decode("ascii")) log("stderr:\n%s" % o[1].decode("ascii")) raise Fatal('%r returned %d' % (argv, p.returncode)) return o def pf_get_dev(): global _pf_fd if _pf_fd is None: _pf_fd = os.open('/dev/pf', os.O_RDWR) return _pf_fd def pf_get_anchor(family, port): return 'sshuttle%s-%d' % ('' if family == socket.AF_INET else '6', port) class Method(BaseMethod): def get_supported_features(self): result = super(Method, self).get_supported_features() result.ipv6 = True return result def get_tcp_dstip(self, sock): pfile = self.firewall.pfile try: peer = sock.getpeername() except socket.error: _, e = sys.exc_info()[:2] if e.args[0] == errno.EINVAL: return sock.getsockname() proxy = sock.getsockname() argv = (sock.family, socket.IPPROTO_TCP, peer[0].encode("ASCII"), peer[1], proxy[0].encode("ASCII"), proxy[1]) out_line = b"QUERY_PF_NAT %d,%d,%s,%d,%s,%d\n" % argv pfile.write(out_line) pfile.flush() in_line = pfile.readline() debug2(out_line.decode("ASCII") + ' > ' + in_line.decode("ASCII")) if in_line.startswith(b'QUERY_PF_NAT_SUCCESS '): (ip, port) = in_line[21:].split(b',') return (ip.decode("ASCII"), int(port)) return sock.getsockname() def setup_firewall(self, port, dnsport, nslist, family, subnets, udp, user, group, tmark): if family not in [socket.AF_INET, socket.AF_INET6]: raise Exception( 'Address family "%s" unsupported by pf method_name' % family_to_string(family)) if udp: raise Exception("UDP not supported by pf method_name") if subnets: includes = [] # If a given subnet is both included and excluded, list the # exclusion first; the table will ignore the second, opposite # definition for _, swidth, sexclude, snet, fport, lport \ in sorted(subnets, key=subnet_weight): includes.append((sexclude, b"%s/%d%s" % ( snet.encode("ASCII"), swidth, b" port %d:%d" % (fport, lport) if fport else b""))) anchor = pf_get_anchor(family, port) pf.add_anchors(anchor) pf.add_rules(anchor, includes, port, dnsport, nslist, family) pf.enable() def restore_firewall(self, port, family, udp, user, group): if family not in [socket.AF_INET, socket.AF_INET6]: raise Exception( 'Address family "%s" unsupported by pf method_name' % family_to_string(family)) if udp: raise Exception("UDP not supported by pf method_name") pf.disable(pf_get_anchor(family, port)) def firewall_command(self, line): if line.startswith('QUERY_PF_NAT '): try: dst = pf.query_nat(*(line[13:].split(','))) sys.stdout.write('QUERY_PF_NAT_SUCCESS %s,%r\n' % dst) except IOError as e: sys.stdout.write('QUERY_PF_NAT_FAILURE %s\n' % e) sys.stdout.flush() return True else: return False def is_supported(self): if which("pfctl"): return True debug2("pf method not supported because 'pfctl' command is missing.") return False sshuttle-1.3.1/sshuttle/methods/tproxy.py000066400000000000000000000237341477061133000206370ustar00rootroot00000000000000import struct from sshuttle.firewall import subnet_weight from sshuttle.helpers import family_to_string from sshuttle.linux import ipt, ipt_chain_exists from sshuttle.methods import BaseMethod from sshuttle.helpers import debug1, debug2, debug3, Fatal, which import socket import os IP_TRANSPARENT = 19 IP_ORIGDSTADDR = 20 IP_RECVORIGDSTADDR = IP_ORIGDSTADDR SOL_IPV6 = 41 IPV6_ORIGDSTADDR = 74 IPV6_RECVORIGDSTADDR = IPV6_ORIGDSTADDR def recv_udp(listener, bufsize): debug3('Accept UDP python using recvmsg.') data, ancdata, _, srcip = listener.recvmsg( 4096, socket.CMSG_SPACE(24)) dstip = None family = None for cmsg_level, cmsg_type, cmsg_data in ancdata: if cmsg_level == socket.SOL_IP and cmsg_type == IP_ORIGDSTADDR: family, port = struct.unpack('=HH', cmsg_data[0:4]) port = socket.htons(port) if family == socket.AF_INET: start = 4 length = 4 else: raise Fatal("Unsupported socket type '%s'" % family) ip = socket.inet_ntop(family, cmsg_data[start:start + length]) dstip = (ip, port) break elif cmsg_level == SOL_IPV6 and cmsg_type == IPV6_ORIGDSTADDR: family, port = struct.unpack('=HH', cmsg_data[0:4]) port = socket.htons(port) if family == socket.AF_INET6: start = 8 length = 16 else: raise Fatal("Unsupported socket type '%s'" % family) ip = socket.inet_ntop(family, cmsg_data[start:start + length]) dstip = (ip, port) break return (srcip, dstip, data) class Method(BaseMethod): def get_supported_features(self): result = super(Method, self).get_supported_features() result.ipv6 = True result.udp = True result.dns = True return result def get_tcp_dstip(self, sock): return sock.getsockname() def recv_udp(self, udp_listener, bufsize): srcip, dstip, data = recv_udp(udp_listener, bufsize) if not dstip: debug1( "-- ignored UDP from %r: " "couldn't determine destination IP address\n" % (srcip,)) return None return srcip, dstip, data def setsockopt_error(self, e): """The tproxy method needs root permissions to successfully set the IP_TRANSPARENT option on sockets. This method is called when we receive a PermissionError when trying to do so.""" raise Fatal("Insufficient permissions for tproxy method.\n" "Your effective UID is %d, not 0. Try rerunning as root.\n" % os.geteuid()) def send_udp(self, sock, srcip, dstip, data): if not srcip: debug1( "-- ignored UDP to %r: " "couldn't determine source IP address\n" % (dstip,)) return sender = socket.socket(sock.family, socket.SOCK_DGRAM) sender.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) try: sender.setsockopt(socket.SOL_IP, IP_TRANSPARENT, 1) except PermissionError as e: self.setsockopt_error(e) sender.bind(srcip) sender.sendto(data, dstip) sender.close() def setup_tcp_listener(self, tcp_listener): try: tcp_listener.setsockopt(socket.SOL_IP, IP_TRANSPARENT, 1) except PermissionError as e: self.setsockopt_error(e) def setup_udp_listener(self, udp_listener): try: udp_listener.setsockopt(socket.SOL_IP, IP_TRANSPARENT, 1) except PermissionError as e: self.setsockopt_error(e) if udp_listener.v4 is not None: udp_listener.v4.setsockopt( socket.SOL_IP, IP_RECVORIGDSTADDR, 1) if udp_listener.v6 is not None: udp_listener.v6.setsockopt(SOL_IPV6, IPV6_RECVORIGDSTADDR, 1) def setup_firewall(self, port, dnsport, nslist, family, subnets, udp, user, group, tmark): if family not in [socket.AF_INET, socket.AF_INET6]: raise Exception( 'Address family "%s" unsupported by tproxy method' % family_to_string(family)) table = "mangle" def _ipt(*args): return ipt(family, table, *args) def _ipt_proto_ports(proto, fport, lport): return proto + ('--dport', '%d:%d' % (fport, lport)) \ if fport else proto mark_chain = 'sshuttle-m-%s' % port tproxy_chain = 'sshuttle-t-%s' % port divert_chain = 'sshuttle-d-%s' % port # basic cleanup/setup of chains self.restore_firewall(port, family, udp, user, group) _ipt('-N', mark_chain) _ipt('-F', mark_chain) _ipt('-N', divert_chain) _ipt('-F', divert_chain) _ipt('-N', tproxy_chain) _ipt('-F', tproxy_chain) _ipt('-I', 'OUTPUT', '1', '-j', mark_chain) _ipt('-I', 'PREROUTING', '1', '-j', tproxy_chain) for _, ip in [i for i in nslist if i[0] == family]: _ipt('-A', mark_chain, '-j', 'MARK', '--set-mark', tmark, '--dest', '%s/32' % ip, '-m', 'udp', '-p', 'udp', '--dport', '53') _ipt('-A', tproxy_chain, '-j', 'TPROXY', '--tproxy-mark', tmark, '--dest', '%s/32' % ip, '-m', 'udp', '-p', 'udp', '--dport', '53', '--on-port', str(dnsport)) # Don't have packets sent to any of our local IP addresses go # through the tproxy or mark chains (except DNS ones). # # Without this fix, if a large subnet is redirected through # sshuttle (i.e., 0/0), then the user may be unable to receive # UDP responses or connect to their own machine using an IP # besides (127.0.0.1). Prior to including these lines, the # documentation reminded the user to use -x to exclude their # own IP addresses to receive UDP responses if they are # redirecting a large subnet through sshuttle (i.e., 0/0). _ipt('-A', tproxy_chain, '-j', 'RETURN', '-m', 'addrtype', '--dst-type', 'LOCAL') _ipt('-A', mark_chain, '-j', 'RETURN', '-m', 'addrtype', '--dst-type', 'LOCAL') _ipt('-A', divert_chain, '-j', 'MARK', '--set-mark', tmark) _ipt('-A', divert_chain, '-j', 'ACCEPT') _ipt('-A', tproxy_chain, '-m', 'socket', '-j', divert_chain, '-m', 'tcp', '-p', 'tcp') if udp: _ipt('-A', tproxy_chain, '-m', 'socket', '-j', divert_chain, '-m', 'udp', '-p', 'udp') for _, swidth, sexclude, snet, fport, lport \ in sorted(subnets, key=subnet_weight, reverse=True): tcp_ports = ('-p', 'tcp') tcp_ports = _ipt_proto_ports(tcp_ports, fport, lport) if sexclude: _ipt('-A', mark_chain, '-j', 'RETURN', '--dest', '%s/%s' % (snet, swidth), '-m', 'tcp', *tcp_ports) _ipt('-A', tproxy_chain, '-j', 'RETURN', '--dest', '%s/%s' % (snet, swidth), '-m', 'tcp', *tcp_ports) else: _ipt('-A', mark_chain, '-j', 'MARK', '--set-mark', tmark, '--dest', '%s/%s' % (snet, swidth), '-m', 'tcp', *tcp_ports) _ipt('-A', tproxy_chain, '-j', 'TPROXY', '--tproxy-mark', tmark, '--dest', '%s/%s' % (snet, swidth), '-m', 'tcp', *(tcp_ports + ('--on-port', str(port)))) if udp: udp_ports = ('-p', 'udp') udp_ports = _ipt_proto_ports(udp_ports, fport, lport) if sexclude: _ipt('-A', mark_chain, '-j', 'RETURN', '--dest', '%s/%s' % (snet, swidth), '-m', 'udp', *udp_ports) _ipt('-A', tproxy_chain, '-j', 'RETURN', '--dest', '%s/%s' % (snet, swidth), '-m', 'udp', *udp_ports) else: _ipt('-A', mark_chain, '-j', 'MARK', '--set-mark', tmark, '--dest', '%s/%s' % (snet, swidth), '-m', 'udp', *udp_ports) _ipt('-A', tproxy_chain, '-j', 'TPROXY', '--tproxy-mark', tmark, '--dest', '%s/%s' % (snet, swidth), '-m', 'udp', *(udp_ports + ('--on-port', str(port)))) def restore_firewall(self, port, family, udp, user, group): if family not in [socket.AF_INET, socket.AF_INET6]: raise Exception( 'Address family "%s" unsupported by tproxy method' % family_to_string(family)) table = "mangle" def _ipt(*args): return ipt(family, table, *args) mark_chain = 'sshuttle-m-%s' % port tproxy_chain = 'sshuttle-t-%s' % port divert_chain = 'sshuttle-d-%s' % port # basic cleanup/setup of chains if ipt_chain_exists(family, table, mark_chain): _ipt('-D', 'OUTPUT', '-j', mark_chain) _ipt('-F', mark_chain) _ipt('-X', mark_chain) if ipt_chain_exists(family, table, tproxy_chain): _ipt('-D', 'PREROUTING', '-j', tproxy_chain) _ipt('-F', tproxy_chain) _ipt('-X', tproxy_chain) if ipt_chain_exists(family, table, divert_chain): _ipt('-F', divert_chain) _ipt('-X', divert_chain) def is_supported(self): if which("iptables") and which("ip6tables"): return True debug2("tproxy method not supported because 'iptables' " "or 'ip6tables' commands are missing.\n") return False sshuttle-1.3.1/sshuttle/methods/windivert.py000066400000000000000000000550671477061133000213110ustar00rootroot00000000000000import os import sys from ipaddress import ip_address, ip_network import threading from collections import namedtuple import socket import subprocess import re from multiprocessing import shared_memory from struct import Struct from functools import wraps from enum import IntEnum import time import traceback from sshuttle.methods import BaseMethod from sshuttle.helpers import log, debug3, debug1, debug2, get_verbose_level, Fatal try: # https://reqrypt.org/windivert-doc.html#divert_iphdr # https://www.reqrypt.org/windivert-changelog.txt import pydivert except ImportError: raise Exception("Could not import pydivert module. windivert requires https://pypi.org/project/pydivert") ConnectionTuple = namedtuple( "ConnectionTuple", ["protocol", "ip_version", "src_addr", "src_port", "dst_addr", "dst_port", "state_epoch", "state"], ) WINDIVERT_MAX_CONNECTIONS = int(os.environ.get('WINDIVERT_MAX_CONNECTIONS', 1024)) class IPProtocol(IntEnum): TCP = socket.IPPROTO_TCP UDP = socket.IPPROTO_UDP @property def filter(self): return "tcp" if self == IPProtocol.TCP else "udp" class IPFamily(IntEnum): IPv4 = socket.AF_INET IPv6 = socket.AF_INET6 @staticmethod def from_ip_version(version): return IPFamily.IPv6 if version == 4 else IPFamily.IPv4 @property def filter(self): return "ip" if self == socket.AF_INET else "ipv6" @property def version(self): return 4 if self == socket.AF_INET else 6 @property def loopback_addr(self): return ip_address("127.0.0.1" if self == socket.AF_INET else "::1") class ConnState(IntEnum): TCP_SYN_SENT = 11 # SYN sent TCP_ESTABLISHED = 12 # SYN+ACK received TCP_FIN_WAIT_1 = 91 # FIN sent TCP_CLOSE_WAIT = 92 # FIN received @staticmethod def can_timeout(state): return state in (ConnState.TCP_SYN_SENT, ConnState.TCP_FIN_WAIT_1, ConnState.TCP_CLOSE_WAIT) def repr_pkt(p): try: direction = p.direction.name if p.is_loopback: direction += "/lo" except AttributeError: # windiver > 2.0 direction = 'OUT' if p.address.Outbound == 1 else 'IN' if p.address.Loopback == 1: direction += '/lo' r = f"{direction} {p.src_addr}:{p.src_port}->{p.dst_addr}:{p.dst_port}" if p.tcp: t = p.tcp r += f" {len(t.payload)}B (" r += "+".join( f.upper() for f in ("fin", "syn", "rst", "psh", "ack", "urg", "ece", "cwr", "ns") if getattr(t, f) ) r += f") SEQ#{t.seq_num}" if t.ack: r += f" ACK#{t.ack_num}" r += f" WZ={t.window_size}" else: r += f" {p.udp=} {p.icmpv4=} {p.icmpv6=}" return f"" def synchronized_method(lock): def decorator(method): @wraps(method) def wrapped(self, *args, **kwargs): with getattr(self, lock): return method(self, *args, **kwargs) return wrapped return decorator class ConnTrack: _instance = None def __new__(cls, *args, **kwargs): if not cls._instance: cls._instance = object.__new__(cls) return cls._instance raise RuntimeError("ConnTrack can not be instantiated multiple times") def __init__(self, name, max_connections=0) -> None: self.struct_full_tuple = Struct(">" + "".join(("B", "B", "16s", "H", "16s", "H", "L", "B"))) self.struct_src_tuple = Struct(">" + "".join(("B", "B", "16s", "H"))) self.struct_state_tuple = Struct(">" + "".join(("L", "B"))) try: self.max_connections = max_connections self.shm_list = shared_memory.ShareableList( [bytes(self.struct_full_tuple.size) for _ in range(max_connections)], name=name ) self.is_owner = True self.next_slot = 0 self.used_slots = set() self.rlock = threading.RLock() except FileExistsError: self.is_owner = False self.shm_list = shared_memory.ShareableList(name=name) self.max_connections = len(self.shm_list) debug2( f"ConnTrack: is_owner={self.is_owner} cap={len(self.shm_list)} item_sz={self.struct_full_tuple.size}B" f"shm_name={self.shm_list.shm.name} shm_sz={self.shm_list.shm.size}B" ) @synchronized_method("rlock") def add(self, proto, src_addr, src_port, dst_addr, dst_port, state): if not self.is_owner: raise RuntimeError("Only owner can mutate ConnTrack") if len(self.used_slots) >= self.max_connections: raise RuntimeError(f"No slot available in ConnTrack {len(self.used_slots)}/{self.max_connections}") if self.get(proto, src_addr, src_port): return for _ in range(self.max_connections): if self.next_slot not in self.used_slots: break self.next_slot = (self.next_slot + 1) % self.max_connections else: raise RuntimeError("No slot available in ConnTrack") # should not be here src_addr = ip_address(src_addr) dst_addr = ip_address(dst_addr) assert src_addr.version == dst_addr.version ip_version = src_addr.version state_epoch = int(time.time()) entry = (proto, ip_version, src_addr.packed, src_port, dst_addr.packed, dst_port, state_epoch, state) packed = self.struct_full_tuple.pack(*entry) self.shm_list[self.next_slot] = packed self.used_slots.add(self.next_slot) proto = IPProtocol(proto) debug3( f"ConnTrack: added ({proto.name} {src_addr}:{src_port}->{dst_addr}:{dst_port} @{state_epoch}:{state.name}) to " f"slot={self.next_slot} | #ActiveConn={len(self.used_slots)}" ) @synchronized_method("rlock") def update(self, proto, src_addr, src_port, state): if not self.is_owner: raise RuntimeError("Only owner can mutate ConnTrack") src_addr = ip_address(src_addr) packed = self.struct_src_tuple.pack(proto, src_addr.version, src_addr.packed, src_port) for i in self.used_slots: if self.shm_list[i].startswith(packed): state_epoch = int(time.time()) self.shm_list[i] = self.shm_list[i][:-5] + self.struct_state_tuple.pack(state_epoch, state) debug3( f"ConnTrack: updated ({proto.name} {src_addr}:{src_port} @{state_epoch}:{state.name}) from slot={i} | " f"#ActiveConn={len(self.used_slots)}" ) return self._unpack(self.shm_list[i]) else: debug3( f"ConnTrack: ({proto.name} src={src_addr}:{src_port}) is not found to update to {state.name} | " f"#ActiveConn={len(self.used_slots)}" ) @synchronized_method("rlock") def remove(self, proto, src_addr, src_port): if not self.is_owner: raise RuntimeError("Only owner can mutate ConnTrack") src_addr = ip_address(src_addr) packed = self.struct_src_tuple.pack(proto, src_addr.version, src_addr.packed, src_port) for i in self.used_slots: if self.shm_list[i].startswith(packed): conn = self._unpack(self.shm_list[i]) self.shm_list[i] = b"" self.used_slots.remove(i) debug3( f"ConnTrack: removed ({proto.name} src={src_addr}:{src_port} state={conn.state.name}) from slot={i} | " f"#ActiveConn={len(self.used_slots)}" ) return conn else: debug3( f"ConnTrack: ({proto.name} src={src_addr}:{src_port}) is not found to remove |" f" #ActiveConn={len(self.used_slots)}" ) def get(self, proto, src_addr, src_port): src_addr = ip_address(src_addr) packed = self.struct_src_tuple.pack(proto, src_addr.version, src_addr.packed, src_port) for entry in self.shm_list: if entry and entry.startswith(packed): return self._unpack(entry) def dump(self): for entry in self.shm_list: if not entry: continue conn = self._unpack(entry) proto, ip_version, src_addr, src_port, dst_addr, dst_port, state_epoch, state = conn log(f"{proto.name}/{ip_version} {src_addr}:{src_port} -> {dst_addr}:{dst_port} {state.name}@{state_epoch}") @synchronized_method("rlock") def gc(self, connection_timeout_sec=15): # self.dump() now = int(time.time()) n = 0 for i in tuple(self.used_slots): state_packed = self.shm_list[i][-5:] (state_epoch, state) = self.struct_state_tuple.unpack(state_packed) if (now - state_epoch) < connection_timeout_sec: continue if ConnState.can_timeout(state): conn = self._unpack(self.shm_list[i]) self.shm_list[i] = b"" self.used_slots.remove(i) n += 1 debug3( f"ConnTrack: GC: removed ({conn.protocol.name} src={conn.src_addr}:{conn.src_port} state={conn.state.name})" f" from slot={i} | #ActiveConn={len(self.used_slots)}" ) debug3(f"ConnTrack: GC: collected {n} connections | #ActiveConn={len(self.used_slots)}") def _unpack(self, packed): ( proto, ip_version, src_addr_packed, src_port, dst_addr_packed, dst_port, state_epoch, state, ) = self.struct_full_tuple.unpack(packed) dst_addr = ip_address(dst_addr_packed if ip_version == 6 else dst_addr_packed[:4]).exploded src_addr = ip_address(src_addr_packed if ip_version == 6 else src_addr_packed[:4]).exploded proto = IPProtocol(proto) state = ConnState(state) return ConnectionTuple(proto, ip_version, src_addr, src_port, dst_addr, dst_port, state_epoch, state) def __iter__(self): def conn_iter(): for i in self.used_slots: yield self._unpack(self.shm_list[i]) return conn_iter() def __repr__(self): return f"" class Method(BaseMethod): network_config = {} def __init__(self, name): super().__init__(name) def _get_bind_address_for_port(self, port, family): proto = "TCPv6" if family.version == 6 else "TCP" for line in subprocess.check_output(["netstat", "-a", "-n", "-p", proto]).decode(errors='ignore').splitlines(): try: _, local_addr, _, state, *_ = re.split(r"\s+", line.strip()) except ValueError: continue port_suffix = ":" + str(port) if state == "LISTENING" and local_addr.endswith(port_suffix): return ip_address(local_addr[:-len(port_suffix)].strip("[]")) raise Fatal("Could not find listening address for {}/{}".format(port, proto)) def setup_firewall(self, proxy_port, dnsport, nslist, family, subnets, udp, user, group, tmark): debug2(f"{proxy_port=}, {dnsport=}, {nslist=}, {family=}, {subnets=}, {udp=}, {user=}, {group=} {tmark=}") if nslist or user or udp or group: raise NotImplementedError("user, group, nslist, udp are not supported") family = IPFamily(family) proxy_ip = None # using loopback only proxy binding won't work with windivert. # See: https://github.com/basil00/Divert/issues/17#issuecomment-341100167 https://github.com/basil00/Divert/issues/82) # As a workaround, finding another interface ip instead. (client should not bind proxy to loopback address) proxy_bind_addr = self._get_bind_address_for_port(proxy_port, family) if proxy_bind_addr.is_loopback: raise Fatal("Windivert method requires proxy to be reachable by a non loopback address.") if not proxy_bind_addr.is_unspecified: proxy_ip = proxy_bind_addr else: local_addresses = [ip_address(info[4][0]) for info in socket.getaddrinfo(socket.gethostname(), 0, family=family)] for addr in local_addresses: if not addr.is_loopback and not addr.is_link_local: proxy_ip = addr break else: raise Fatal("Windivert method requires proxy to be reachable by a non loopback address." f"No address found for {family.name} in {local_addresses}") debug2(f"Found non loopback address to connect to proxy: {proxy_ip}") subnet_addresses = [] for (_, mask, exclude, network_addr, fport, lport) in subnets: if fport and lport: if lport > fport: raise Fatal("lport must be less than or equal to fport") ports = (fport, lport) else: ports = None subnet_addresses.append((ip_network(f"{network_addr}/{mask}"), ports, exclude)) self.network_config[family] = { "subnets": subnet_addresses, "nslist": nslist, "proxy_addr": (proxy_ip, proxy_port) } def wait_for_firewall_ready(self, sshuttle_pid): debug2(f"network_config={self.network_config}") self.conntrack = ConnTrack(f"sshuttle-windivert-{sshuttle_pid}", WINDIVERT_MAX_CONNECTIONS) if not self.conntrack.is_owner: raise Fatal("ConnTrack should be owner in wait_for_firewall_ready()") thread_target_funcs = (self._egress_divert, self._ingress_divert, self._connection_gc) ready_events = [] for fn in thread_target_funcs: ev = threading.Event() ready_events.append(ev) def _target(): try: fn(ev.set) except Exception: debug2(f"thread {fn.__name__} exiting due to: " + traceback.format_exc()) sys.stdin.close() # this will exist main thread sys.stdout.close() threading.Thread(name=fn.__name__, target=_target, daemon=True).start() for ev in ready_events: if not ev.wait(5): # at most 5 sec raise Fatal("timeout in wait_for_firewall_ready()") def restore_firewall(self, port, family, udp, user, group): pass def get_supported_features(self): result = super(Method, self).get_supported_features() result.loopback_proxy_port = False result.user = False result.dns = False # ipv6 only able to support with Windivert 2.x due to bugs in filter parsing # TODO(nom3ad): Enable ipv6 once https://github.com/ffalcinelli/pydivert/pull/57 merged result.ipv6 = False return result def get_tcp_dstip(self, sock): if not hasattr(self, "conntrack"): self.conntrack = ConnTrack(f"sshuttle-windivert-{os.getpid()}") if self.conntrack.is_owner: raise Fatal("ConnTrack should not be owner in get_tcp_dstip()") src_addr, src_port = sock.getpeername() c = self.conntrack.get(IPProtocol.TCP, src_addr, src_port) if not c: return (src_addr, src_port) return (c.dst_addr, c.dst_port) def is_supported(self): if sys.platform == "win32": return True return False def _egress_divert(self, ready_cb): """divert outgoing packets to proxy""" proto = IPProtocol.TCP filter = f"outbound and {proto.filter}" af_filters = [] for af, c in self.network_config.items(): subnet_include_filters = [] subnet_exclude_filters = [] for ip_net, ports, exclude in c["subnets"]: first_ip = ip_net.network_address.exploded last_ip = ip_net.broadcast_address.exploded if first_ip == last_ip: _subnet_filter = f"{af.filter}.DstAddr=={first_ip}" else: _subnet_filter = f"{af.filter}.DstAddr>={first_ip} and {af.filter}.DstAddr<={last_ip}" if ports: if ports[0] == ports[1]: _subnet_filter += f" and {proto.filter}.DstPort=={ports[0]}" else: _subnet_filter += f" and tcp.DstPort>={ports[0]} and tcp.DstPort<={ports[1]}" (subnet_exclude_filters if exclude else subnet_include_filters).append(f"({_subnet_filter})") _af_filter = f"{af.filter}" if subnet_include_filters: _af_filter += f" and ({' or '.join(subnet_include_filters)})" if subnet_exclude_filters: # TODO(noma3ad) use not() operator with Windivert2 after upgrade _af_filter += f" and (({' or '.join(subnet_exclude_filters)})? false : true)" proxy_ip, proxy_port = c["proxy_addr"] # Avoids proxy outbound traffic getting directed to itself proxy_guard_filter = f"(({af.filter}.DstAddr=={proxy_ip.exploded} and tcp.DstPort=={proxy_port})? false : true)" _af_filter += f" and {proxy_guard_filter}" af_filters.append(_af_filter) if not af_filters: raise Fatal("At least one ipv4 or ipv6 subnet is expected") filter = f"{filter} and ({' or '.join(af_filters)})" debug1(f"[EGRESS] {filter=}") with pydivert.WinDivert(filter, layer=pydivert.Layer.NETWORK, flags=pydivert.Flag.DEFAULT) as w: proxy_ipv4, proxy_ipv6 = None, None if IPFamily.IPv4 in self.network_config: proxy_ipv4 = self.network_config[IPFamily.IPv4]["proxy_addr"] proxy_ipv4 = proxy_ipv4[0].exploded, proxy_ipv4[1] if IPFamily.IPv6 in self.network_config: proxy_ipv6 = self.network_config[IPFamily.IPv6]["proxy_addr"] proxy_ipv6 = proxy_ipv6[0].exploded, proxy_ipv6[1] ready_cb() verbose = get_verbose_level() for pkt in w: verbose >= 3 and debug3("[EGRESS] " + repr_pkt(pkt)) if pkt.tcp.syn and not pkt.tcp.ack: # SYN sent (start of 3-way handshake connection establishment from our side, we wait for SYN+ACK) self.conntrack.add( socket.IPPROTO_TCP, pkt.src_addr, pkt.src_port, pkt.dst_addr, pkt.dst_port, ConnState.TCP_SYN_SENT, ) if pkt.tcp.fin: # FIN sent (start of graceful close our side, and we wait for ACK) self.conntrack.update(IPProtocol.TCP, pkt.src_addr, pkt.src_port, ConnState.TCP_FIN_WAIT_1) if pkt.tcp.rst: # RST sent (initiate abrupt connection teardown from our side, so we don't expect any reply) self.conntrack.remove(IPProtocol.TCP, pkt.src_addr, pkt.src_port) # DNAT if pkt.ipv4 and proxy_ipv4: pkt.dst_addr, pkt.tcp.dst_port = proxy_ipv4 if pkt.ipv6 and proxy_ipv6: pkt.dst_addr, pkt.tcp.dst_port = proxy_ipv6 # XXX: If we set loopback proxy address (DNAT), then we should do SNAT as well # by setting src_addr to loopback address. # Otherwise injecting packet will be ignored by Windows network stack # as they packet has to cross public to private address space. # See: https://github.com/basil00/Divert/issues/82 # Managing SNAT is more trickier, as we have to restore the original source IP address for reply packets. # >>> pkt.dst_addr = proxy_ipv4 w.send(pkt, recalculate_checksum=True) def _ingress_divert(self, ready_cb): """handles incoming packets from proxy""" proto = IPProtocol.TCP # Windivert treats all local process traffic as outbound, regardless of origin external/loopback iface direction = "outbound" proxy_addr_filters = [] for af, c in self.network_config.items(): if not c["subnets"]: continue proxy_ip, proxy_port = c["proxy_addr"] # "ip.SrcAddr=={hex(int(proxy_ip))}" # only Windivert >=2 supports this proxy_addr_filters.append(f"{af.filter}.SrcAddr=={proxy_ip.exploded} and tcp.SrcPort=={proxy_port}") if not proxy_addr_filters: raise Fatal("At least one ipv4 or ipv6 address is expected") filter = f"{direction} and {proto.filter} and ({' or '.join(proxy_addr_filters)})" debug1(f"[INGRESS] {filter=}") with pydivert.WinDivert(filter, layer=pydivert.Layer.NETWORK, flags=pydivert.Flag.DEFAULT) as w: ready_cb() verbose = get_verbose_level() for pkt in w: verbose >= 3 and debug3("[INGRESS] " + repr_pkt(pkt)) if pkt.tcp.syn and pkt.tcp.ack: # SYN+ACK received (connection established from proxy conn = self.conntrack.update(IPProtocol.TCP, pkt.dst_addr, pkt.dst_port, ConnState.TCP_ESTABLISHED) elif pkt.tcp.rst: # RST received - Abrupt connection teardown initiated by proxy. Don't expect anymore packets conn = self.conntrack.remove(IPProtocol.TCP, pkt.dst_addr, pkt.dst_port) # https://wiki.wireshark.org/TCP-4-times-close.md elif pkt.tcp.fin and pkt.tcp.ack: # FIN+ACK received (Passive close by proxy. Don't expect any more packets. proxy expects an ACK) conn = self.conntrack.remove(IPProtocol.TCP, pkt.dst_addr, pkt.dst_port) elif pkt.tcp.fin: # FIN received (proxy initiated graceful close. Expect a final ACK for a FIN packet) conn = self.conntrack.update(IPProtocol.TCP, pkt.dst_addr, pkt.dst_port, ConnState.TCP_CLOSE_WAIT) else: # data fragments and ACKs conn = self.conntrack.get(socket.IPPROTO_TCP, pkt.dst_addr, pkt.dst_port) if not conn: verbose >= 2 and debug2("Unexpected packet: " + repr_pkt(pkt)) continue pkt.src_addr = conn.dst_addr pkt.tcp.src_port = conn.dst_port w.send(pkt, recalculate_checksum=True) def _connection_gc(self, ready_cb): ready_cb() while True: time.sleep(5) self.conntrack.gc() sshuttle-1.3.1/sshuttle/namespace.py000066400000000000000000000021131477061133000175470ustar00rootroot00000000000000import os import ctypes import ctypes.util from sshuttle.helpers import Fatal, debug1, debug2 CLONE_NEWNET = 0x40000000 NETNS_RUN_DIR = "/var/run/netns" def enter_namespace(namespace, namespace_pid): if namespace: namespace_dir = f'{NETNS_RUN_DIR}/{namespace}' else: namespace_dir = f'/proc/{namespace_pid}/ns/net' if not os.path.exists(namespace_dir): raise Fatal('The namespace %r does not exists.' % namespace_dir) debug2('loading libc') libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True) default_errcheck = libc.setns.errcheck def errcheck(ret, *args): if ret == -1: e = ctypes.get_errno() raise Fatal(e, os.strerror(e)) if default_errcheck: return default_errcheck(ret, *args) libc.setns.errcheck = errcheck # type: ignore debug1('Entering namespace %r' % namespace_dir) with open(namespace_dir) as fd: libc.setns(fd.fileno(), CLONE_NEWNET) debug1('Namespace %r successfully set' % namespace_dir) sshuttle-1.3.1/sshuttle/options.py000066400000000000000000000313041477061133000173120ustar00rootroot00000000000000import re import socket import sys from argparse import ArgumentParser, Action, ArgumentTypeError as Fatal from sshuttle import __version__ # Subnet file, supporting empty lines and hash-started comment lines def parse_subnetport_file(s): try: handle = open(s, 'r') except OSError: raise Fatal('Unable to open subnet file: %s' % s) raw_config_lines = handle.readlines() subnets = [] for _, line in enumerate(raw_config_lines): line = line.strip() if not line: continue if line[0] == '#': continue subnets.append(parse_subnetport(line)) return subnets # 1.2.3.4/5:678, 1.2.3.4:567, 1.2.3.4/16 or just 1.2.3.4 # [1:2::3/64]:456, [1:2::3]:456, 1:2::3/64 or just 1:2::3 # example.com:123 or just example.com # # In addition, the port number can be specified as a range: # 1.2.3.4:8000-8080. # # Can return multiple matches if the domain name used in the request # has multiple IP addresses. def parse_subnetport(s): if s.count(':') > 1: rx = r'(?:\[?(?:\*\.)?([\w\:]+)(?:/(\d+))?]?)(?::(\d+)(?:-(\d+))?)?$' else: rx = r'((?:\*\.)?[\w\.\-]+)(?:/(\d+))?(?::(\d+)(?:-(\d+))?)?$' m = re.match(rx, s) if not m: raise Fatal('%r is not a valid address/mask:port format' % s) # Ports range from fport to lport. If only one port is specified, # fport is defined and lport is None. # # cidr is the mask defined with the slash notation host, cidr, fport, lport = m.groups() try: addrinfo = socket.getaddrinfo(host, 0, 0, socket.SOCK_STREAM) except socket.gaierror: raise Fatal('Unable to resolve address: %s' % host) # If the address is a domain with multiple IPs and a mask is also # provided, proceed cautiously: if cidr is not None: addr_v6 = [a for a in addrinfo if a[0] == socket.AF_INET6] addr_v4 = [a for a in addrinfo if a[0] == socket.AF_INET] # Refuse to proceed if IPv4 and IPv6 addresses are present: if len(addr_v6) > 0 and len(addr_v4) > 0: raise Fatal("%s has IPv4 and IPv6 addresses, so the mask " "of /%s is not supported. Specify the IP " "addresses directly if you wish to specify " "a mask." % (host, cidr)) # Warn if a domain has multiple IPs of the same type (IPv4 vs # IPv6) and the mask is applied to all of the IPs. if len(addr_v4) > 1 or len(addr_v6) > 1: print("WARNING: %s has multiple IP addresses. The " "mask of /%s is applied to all of the addresses." % (host, cidr)) rv = [] for a in addrinfo: family, _, _, _, addr = a # Largest possible slash value we can use with this IP: max_cidr = 32 if family == socket.AF_INET else 128 if cidr is None: # if no mask, use largest mask cidr_to_use = max_cidr else: # verify user-provided mask is appropriate cidr_to_use = int(cidr) if not 0 <= cidr_to_use <= max_cidr: raise Fatal('Slash in CIDR notation (/%d) is ' 'not between 0 and %d' % (cidr_to_use, max_cidr)) rv.append((family, addr[0], cidr_to_use, int(fport or 0), int(lport or fport or 0))) return rv # 1.2.3.4:567 or just 1.2.3.4 or just 567 # [1:2::3]:456 or [1:2::3] or just [::]:567 # example.com:123 or just example.com def parse_ipport(s): s = str(s) if s.isdigit(): rx = r'()(\d+)$' elif ']' in s: rx = r'(?:\[([^]]+)])(?::(\d+))?$' else: rx = r'([\w\.\-]+)(?::(\d+))?$' m = re.match(rx, s) if not m: raise Fatal('%r is not a valid IP:port format' % s) host, port = m.groups() host = host or '0.0.0.0' port = int(port or 0) try: addrinfo = socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM) except socket.gaierror: raise Fatal('Unable to resolve address: %s' % host) if len(addrinfo) > 1: print("WARNING: Host %s has more than one IP, only using one of them." % host) family, _, _, _, addr = min(addrinfo) # Note: addr contains (ip, port) return (family,) + addr[:2] def parse_list(lst): """Parse a comma separated string into a list.""" return re.split(r'[\s,]+', lst.strip()) if lst else [] def parse_namespace(namespace): try: assert re.fullmatch( r'(@?[a-z_A-Z]\w+(?:\.@?[a-z_A-Z]\w+)*)', namespace) return namespace except AssertionError: raise Fatal("%r is not a valid namespace name." % namespace) class Concat(Action): def __init__(self, option_strings, dest, nargs=None, **kwargs): if nargs is not None: raise ValueError("nargs not supported") super(Concat, self).__init__(option_strings, dest, **kwargs) def __call__(self, parser, namespace, values, option_string=None): curr_value = getattr(namespace, self.dest, None) or [] setattr(namespace, self.dest, curr_value + values) # Override one function in the ArgumentParser so that we can have # better control for how we parse files containing arguments. We # expect one argument per line, but strip whitespace/quotes from the # beginning/end of the lines. class MyArgumentParser(ArgumentParser): def convert_arg_line_to_args(self, arg_line): # Ignore comments if arg_line.startswith("#"): return [] # strip whitespace at beginning and end of line arg_line = arg_line.strip() # When copying parameters from the command line to a file, # some users might copy the quotes they used on the command # line into the config file. We ignore these if the line # starts and ends with the same quote. if arg_line.startswith("'") and arg_line.endswith("'") or \ arg_line.startswith('"') and arg_line.endswith('"'): arg_line = arg_line[1:-1] return [arg_line] parser = MyArgumentParser( prog="sshuttle", usage="%(prog)s [-l [ip:]port] -r [user@]sshserver[:port] ", fromfile_prefix_chars="@" ) parser.add_argument( "subnets", metavar="IP/MASK[:PORT[-PORT]]...", nargs="*", type=parse_subnetport, help=""" capture and forward traffic to these subnets (whitespace separated) """ ) parser.add_argument( "-l", "--listen", metavar="[IP:]PORT", help=""" transproxy to this ip address and port number """ ) parser.add_argument( "-H", "--auto-hosts", action="store_true", help=""" continuously scan for remote hostnames and update local /etc/hosts as they are found """ ) parser.add_argument( "-N", "--auto-nets", action="store_true", help=""" automatically determine subnets to route """ ) parser.add_argument( "--dns", action="store_true", help=""" capture local DNS requests and forward to the remote DNS server """ ) parser.add_argument( "--ns-hosts", metavar="IP[,IP]", default=[], type=parse_list, help=""" capture and forward DNS requests made to the following servers (comma separated) """ ) parser.add_argument( "--to-ns", metavar="IP[:PORT]", type=parse_ipport, help=""" the DNS server to forward requests to; defaults to servers in /etc/resolv.conf on remote side if not given. """ ) if sys.platform == 'win32': method_choices = ["auto", "windivert"] else: method_choices = ["auto", "nft", "nat", "tproxy", "pf", "ipfw"] parser.add_argument( "--method", choices=method_choices, metavar="TYPE", default="auto", help=""" %(choices)s """ ) parser.add_argument( "--python", metavar="PATH", help=""" path to python interpreter on the remote server """ ) parser.add_argument( "-r", "--remote", metavar="[USERNAME[:PASSWORD]@]ADDR[:PORT]", help=""" ssh hostname (and optional username and password) of remote %(prog)s server """ ) parser.add_argument( "-x", "--exclude", metavar="IP/MASK[:PORT[-PORT]]", action="append", default=[], type=parse_subnetport, help=""" exclude this subnet (can be used more than once) """ ) parser.add_argument( "-X", "--exclude-from", metavar="PATH", action=Concat, dest="exclude", type=parse_subnetport_file, help=""" exclude the subnets in a file (whitespace separated) """ ) parser.add_argument( "-v", "--verbose", action="count", default=0, help=""" increase debug message verbosity (can be used more than once) """ ) parser.add_argument( "-V", "--version", action="version", version=__version__, help=""" print the %(prog)s version number and exit """ ) parser.add_argument( "-e", "--ssh-cmd", metavar="CMD", default="ssh", help=""" the command to use to connect to the remote [%(default)s] """ ) parser.add_argument( "--no-cmd-delimiter", action="store_false", dest="add_cmd_delimiter", help=""" do not add a double dash before the python command """ ) parser.add_argument( "--remote-shell", metavar="PROGRAM", help=""" alternate remote shell program instead of defacto posix shell. For Windows targets it would be either `cmd` or `powershell` unless something like git-bash is in use. """ ) parser.add_argument( "--seed-hosts", metavar="HOSTNAME[,HOSTNAME]", default=[], help=""" comma-separated list of hostnames for initial scan (may be used with or without --auto-hosts) """ ) parser.add_argument( "--no-latency-control", action="store_false", dest="latency_control", help=""" sacrifice latency to improve bandwidth benchmarks """ ) parser.add_argument( "--latency-buffer-size", metavar="SIZE", type=int, default=32768, dest="latency_buffer_size", help=""" size of latency control buffer """ ) parser.add_argument( "--wrap", metavar="NUM", type=int, help=""" restart counting channel numbers after this number (for testing) """ ) parser.add_argument( "--disable-ipv6", action="store_true", help=""" disable IPv6 support """ ) parser.add_argument( "-D", "--daemon", action="store_true", help=""" run in the background as a daemon """ ) parser.add_argument( "-s", "--subnets", metavar="PATH", action=Concat, dest="subnets_file", default=[], type=parse_subnetport_file, help=""" file where the subnets are stored, instead of on the command line """ ) parser.add_argument( "--syslog", action="store_true", help=""" send log messages to syslog (default if you use --daemon) """ ) parser.add_argument( "--pidfile", metavar="PATH", default="./sshuttle.pid", help=""" pidfile name (only if using --daemon) [%(default)s] """ ) parser.add_argument( "--user", help=""" apply all the rules only to this linux user """ ) parser.add_argument( "--group", help=""" apply all the rules only to this linux group """ ) parser.add_argument( "--firewall", action="store_true", help=""" (internal use only) """ ) parser.add_argument( "--hostwatch", action="store_true", help=""" (internal use only) """ ) parser.add_argument( "--sudoers-no-modify", action="store_true", help=""" Prints a sudo configuration to STDOUT which allows a user to run sshuttle without a password. This option is INSECURE because, with some cleverness, it also allows the user to run any command as root without a password. The output also includes a suggested method for you to install the configuration. """ ) parser.add_argument( "--sudoers-user", default="", help=""" Set the user name or group with %%group_name for passwordless operation. Default is the current user. Only works with the --sudoers-no-modify option. """ ) parser.add_argument( "--no-sudo-pythonpath", action="store_false", dest="sudo_pythonpath", help=""" do not set PYTHONPATH when invoking sudo """ ) parser.add_argument( "-t", "--tmark", metavar="[MARK]", default="0x01", help=""" tproxy optional traffic mark with provided MARK value in hexadecimal (default '0x01') """ ) if sys.platform == 'linux': net_ns_group = parser.add_mutually_exclusive_group( required=False) net_ns_group.add_argument( '--namespace', type=parse_namespace, help="Run inside of a net namespace with the given name." ) net_ns_group.add_argument( '--namespace-pid', type=int, help=""" Run inside the net namespace used by the process with the given pid.""" ) sshuttle-1.3.1/sshuttle/sdnotify.py000066400000000000000000000031651477061133000174620ustar00rootroot00000000000000"""When sshuttle is run via a systemd service file, we can communicate to systemd about the status of the sshuttle process. In particular, we can send READY status to tell systemd that sshuttle has completed startup and send STOPPING to indicate that sshuttle is beginning shutdown. For details, see: https://www.freedesktop.org/software/systemd/man/sd_notify.html """ import socket import os from sshuttle.helpers import debug1 def _notify(message): """Send a notification message to systemd.""" addr = os.environ.get("NOTIFY_SOCKET", None) if not addr or len(addr) == 1 or addr[0] not in ('/', '@'): return False addr = '\0' + addr[1:] if addr[0] == '@' else addr try: sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) except (OSError, IOError) as e: debug1("Error creating socket to notify systemd: %s" % e) return False if not message: return False assert isinstance(message, bytes) try: return (sock.sendto(message, addr) > 0) except (OSError, IOError) as e: debug1("Error notifying systemd: %s" % e) return False def send(*messages): """Send multiple messages to systemd.""" return _notify(b'\n'.join(messages)) def ready(): """Constructs a message that is appropriate to send upon completion of sshuttle startup.""" return b"READY=1" def stop(): """Constructs a message that is appropriate to send when sshuttle is beginning to shutdown.""" return b"STOPPING=1" def status(message): """Constructs a status message to be sent to systemd.""" return b"STATUS=%s" % message.encode('utf8') sshuttle-1.3.1/sshuttle/server.py000066400000000000000000000332451477061133000171330ustar00rootroot00000000000000import re import struct import socket import traceback import time import sys import os import io import sshuttle.ssnet as ssnet import sshuttle.helpers as helpers import sshuttle.hostwatch as hostwatch import subprocess as ssubprocess from sshuttle.ssnet import Handler, Proxy, Mux, MuxWrapper from sshuttle.helpers import b, log, debug1, debug2, debug3, Fatal, \ get_random_nameserver, which, get_env, SocketRWShim def _ipmatch(ipstr): # FIXME: IPv4 only if ipstr == 'default': ipstr = '0.0.0.0/0' m = re.match(r'^(\d+(\.\d+(\.\d+(\.\d+)?)?)?)(?:/(\d+))?$', ipstr) if m: g = m.groups() ips = g[0] width = int(g[4] or 32) if g[1] is None: ips += '.0.0.0' width = min(width, 8) elif g[2] is None: ips += '.0.0' width = min(width, 16) elif g[3] is None: ips += '.0' width = min(width, 24) return (struct.unpack('!I', socket.inet_aton(ips))[0], width) def _ipstr(ip, width): # FIXME: IPv4 only if width >= 32: return ip else: return "%s/%d" % (ip, width) def _maskbits(netmask): # FIXME: IPv4 only if not netmask: return 32 for i in range(32): if netmask[0] & _shl(1, i): return 32 - i return 0 def _shl(n, bits): return n * int(2 ** bits) def _route_netstat(line): cols = line.split(None) if len(cols) < 3: return None, None ipw = _ipmatch(cols[0]) maskw = _ipmatch(cols[2]) # linux only mask = _maskbits(maskw) # returns 32 if maskw is null return ipw, mask def _route_iproute(line): ipm = line.split(None, 1)[0] if '/' not in ipm: return None, None ip, mask = ipm.split('/') ipw = _ipmatch(ip) return ipw, int(mask) def _route_windows(line): if " On-link " not in line: return None, None dest, net_mask = re.split(r'\s+', line.strip())[:2] if net_mask == "255.255.255.255": return None, None for p in ('127.', '0.', '224.', '169.254.'): if dest.startswith(p): return None, None ipw = _ipmatch(dest) mask = _maskbits(_ipmatch(net_mask)) return ipw, mask def _list_routes(argv, extract_route): # FIXME: IPv4 only p = ssubprocess.Popen(argv, stdout=ssubprocess.PIPE, env=get_env()) routes = [] for line in p.stdout: if not line.strip(): continue ipw, mask = extract_route(line.decode("ASCII")) if not ipw: continue width = min(ipw[1], mask) ip = ipw[0] & _shl(_shl(1, width) - 1, 32 - width) routes.append( (socket.AF_INET, socket.inet_ntoa(struct.pack('!I', ip)), width)) rv = p.wait() if rv != 0: log('WARNING: %r returned %d' % (argv, rv)) return routes def list_routes(): if sys.platform == 'win32': routes = _list_routes(['route', 'PRINT', '-4'], _route_windows) else: if which('ip'): routes = _list_routes(['ip', 'route'], _route_iproute) elif which('netstat'): routes = _list_routes(['netstat', '-rn'], _route_netstat) else: log('WARNING: Neither "ip" nor "netstat" were found on the server. ' '--auto-nets feature will not work.') routes = [] for (family, ip, width) in routes: if not ip.startswith('0.') and not ip.startswith('127.'): yield (family, ip, width) def _exc_dump(): exc_info = sys.exc_info() return ''.join(traceback.format_exception(*exc_info)) def start_hostwatch(seed_hosts, auto_hosts): s1, s2 = socket.socketpair() pid = os.fork() if not pid: # child rv = 99 try: try: s2.close() os.dup2(s1.fileno(), 1) os.dup2(s1.fileno(), 0) s1.close() rv = hostwatch.hw_main(seed_hosts, auto_hosts) or 0 except Exception: log('%s' % _exc_dump()) rv = 98 finally: os._exit(rv) s1.close() return pid, s2 class Hostwatch: def __init__(self): self.pid = 0 self.sock = None class DnsProxy(Handler): def __init__(self, mux, chan, request, to_nameserver): Handler.__init__(self, []) self.timeout = time.time() + 30 self.mux = mux self.chan = chan self.tries = 0 self.request = request self.peers = {} self.to_ns_peer = None self.to_ns_port = None if to_nameserver is None: self.to_nameserver = None else: self.to_ns_peer, self.to_ns_port = to_nameserver.split("@") self.to_nameserver = self._addrinfo(self.to_ns_peer, self.to_ns_port) self.try_send() @staticmethod def _addrinfo(peer, port): if int(port) == 0: port = 53 family, _, _, _, sockaddr = socket.getaddrinfo(peer, port)[0] return (family, sockaddr) def try_send(self): if self.tries >= 3: return self.tries += 1 if self.to_nameserver is None: _, peer = get_random_nameserver() port = 53 else: peer = self.to_ns_peer port = int(self.to_ns_port) family, sockaddr = self._addrinfo(peer, port) sock = socket.socket(family, socket.SOCK_DGRAM) sock.connect(sockaddr) self.peers[sock] = peer debug2('DNS: sending to %r:%d (try %d)' % (peer, port, self.tries)) try: sock.send(self.request) self.socks.append(sock) except socket.error: _, e = sys.exc_info()[:2] if e.args[0] in ssnet.NET_ERRS: # might have been spurious; try again. # Note: these errors sometimes are reported by recv(), # and sometimes by send(). We have to catch both. debug2('DNS send to %r: %s' % (peer, e)) self.try_send() return else: log('DNS send to %r: %s' % (peer, e)) return def callback(self, sock): peer = self.peers[sock] try: data = sock.recv(4096) except socket.error: _, e = sys.exc_info()[:2] self.socks.remove(sock) del self.peers[sock] if e.args[0] in ssnet.NET_ERRS: # might have been spurious; try again. # Note: these errors sometimes are reported by recv(), # and sometimes by send(). We have to catch both. debug2('DNS recv from %r: %s' % (peer, e)) self.try_send() return else: log('DNS recv from %r: %s' % (peer, e)) return debug2('DNS response: %d bytes' % len(data)) self.mux.send(self.chan, ssnet.CMD_DNS_RESPONSE, data) self.ok = False class UdpProxy(Handler): def __init__(self, mux, chan, family): sock = socket.socket(family, socket.SOCK_DGRAM) Handler.__init__(self, [sock]) self.timeout = time.time() + 30 self.mux = mux self.chan = chan self.sock = sock def send(self, dstip, data): debug2('UDP: sending to %r port %d' % dstip) try: self.sock.sendto(data, dstip) except socket.error: _, e = sys.exc_info()[:2] log('UDP send to %r port %d: %s' % (dstip[0], dstip[1], e)) return def callback(self, sock): try: data, peer = sock.recvfrom(4096) except socket.error: _, e = sys.exc_info()[:2] log('UDP recv from %r port %d: %s' % (peer[0], peer[1], e)) return debug2('UDP response: %d bytes' % len(data)) hdr = b("%s,%r," % (peer[0], peer[1])) self.mux.send(self.chan, ssnet.CMD_UDP_DATA, hdr + data) def main(latency_control, latency_buffer_size, auto_hosts, to_nameserver, auto_nets): try: helpers.logprefix = ' s: ' debug1('latency control setting = %r' % latency_control) if latency_buffer_size: import sshuttle.ssnet as ssnet ssnet.LATENCY_BUFFER_SIZE = latency_buffer_size # synchronization header sys.stdout.write('\0\0SSHUTTLE0001') sys.stdout.flush() handlers = [] # get unbuffered stdin and stdout in binary mode. Equivalent to stdin.buffer/stdout.buffer (Only available in Python 3) r, w = io.FileIO(0, mode='r'), io.FileIO(1, mode='w') if sys.platform == 'win32': def _deferred_exit(): time.sleep(1) # give enough time to write logs to stderr os._exit(23) shim = SocketRWShim(r, w, on_end=_deferred_exit) mux = Mux(*shim.makefiles()) else: mux = Mux(r, w) handlers.append(mux) debug1('auto-nets:' + str(auto_nets)) if auto_nets: routes = list(list_routes()) debug1('available routes:') for r in routes: debug1(' %d/%s/%d' % r) else: routes = [] routepkt = '' for r in routes: routepkt += '%d,%s,%d\n' % r mux.send(0, ssnet.CMD_ROUTES, b(routepkt)) hw = Hostwatch() hw.leftover = b('') def hostwatch_ready(sock): assert hw.pid content = hw.sock.recv(4096) if content: lines = (hw.leftover + content).split(b('\n')) if lines[-1]: # no terminating newline: entry isn't complete yet! hw.leftover = lines.pop() lines.append(b('')) else: hw.leftover = b('') mux.send(0, ssnet.CMD_HOST_LIST, b('\n').join(lines)) else: raise Fatal('hostwatch process died') def got_host_req(data): if not hw.pid: (hw.pid, hw.sock) = start_hostwatch( data.decode("ASCII").strip().split(), auto_hosts) handlers.append(Handler(socks=[hw.sock], callback=hostwatch_ready)) mux.got_host_req = got_host_req def new_channel(channel, data): (family, dstip, dstport) = data.decode("ASCII").split(',', 2) family = int(family) # AF_INET is the same constant on Linux and BSD but AF_INET6 # is different. As the client and server can be running on # different platforms we can not just set the socket family # to what comes in the wire. if family != socket.AF_INET: family = socket.AF_INET6 dstport = int(dstport) outwrap = ssnet.connect_dst(family, dstip, dstport) handlers.append(Proxy(MuxWrapper(mux, channel), outwrap)) mux.new_channel = new_channel dnshandlers = {} def dns_req(channel, data): debug2('Incoming DNS request channel=%d.' % channel) h = DnsProxy(mux, channel, data, to_nameserver) handlers.append(h) dnshandlers[channel] = h mux.got_dns_req = dns_req udphandlers = {} def udp_req(channel, cmd, data): debug2('Incoming UDP request channel=%d, cmd=%d' % (channel, cmd)) if cmd == ssnet.CMD_UDP_DATA: (dstip, dstport, data) = data.split(b(','), 2) dstport = int(dstport) debug2('is incoming UDP data. %r %d.' % (dstip, dstport)) h = udphandlers[channel] h.send((dstip, dstport), data) elif cmd == ssnet.CMD_UDP_CLOSE: debug2('is incoming UDP close') h = udphandlers[channel] h.ok = False del mux.channels[channel] def udp_open(channel, data): debug2('Incoming UDP open.') family = int(data) mux.channels[channel] = lambda cmd, data: udp_req(channel, cmd, data) if channel in udphandlers: raise Fatal('UDP connection channel %d already open' % channel) else: h = UdpProxy(mux, channel, family) handlers.append(h) udphandlers[channel] = h mux.got_udp_open = udp_open while mux.ok: if hw.pid: assert hw.pid > 0 (rpid, rv) = os.waitpid(hw.pid, os.WNOHANG) if rpid: raise Fatal( 'hostwatch exited unexpectedly: code 0x%04x' % rv) ssnet.runonce(handlers, mux) if latency_control: mux.check_fullness() if dnshandlers: now = time.time() remove = [] for channel, h in dnshandlers.items(): if h.timeout < now or not h.ok: debug3('expiring dnsreqs channel=%d' % channel) remove.append(channel) h.ok = False for channel in remove: del dnshandlers[channel] if udphandlers: remove = [] for channel, h in udphandlers.items(): if not h.ok: debug3('expiring UDP channel=%d' % channel) remove.append(channel) h.ok = False for channel in remove: del udphandlers[channel] except Fatal as e: log('fatal: %s' % e) sys.exit(99) sshuttle-1.3.1/sshuttle/ssh.py000066400000000000000000000235721477061133000164240ustar00rootroot00000000000000import sys import os import re import socket import zlib import importlib import importlib.util import subprocess as ssubprocess import shlex from shlex import quote import ipaddress from urllib.parse import urlparse import sshuttle.helpers as helpers from sshuttle.helpers import debug2, which, get_path, SocketRWShim, Fatal def get_module_source(name): spec = importlib.util.find_spec(name) with open(spec.origin, "rt") as f: return f.read().encode("utf-8") def empackage(z, name, data=None): if not data: data = get_module_source(name) content = z.compress(data) content += z.flush(zlib.Z_SYNC_FLUSH) return b'%s\n%d\n%s' % (name.encode("ASCII"), len(content), content) def parse_hostport(rhostport): """ parses the given rhostport variable, looking like this: [username[:password]@]host[:port] if only host is given, can be a hostname, IPv4/v6 address or a ssh alias from ~/.ssh/config and returns a tuple (username, password, port, host) """ # leave use of default port to ssh command to prevent overwriting # ports configured in ~/.ssh/config when no port is given if rhostport is None or len(rhostport) == 0: return None, None, None, None port = None username = None password = None host = rhostport if "@" in host: # split username (and possible password) from the host[:port] username, host = host.rsplit("@", 1) # Fix #410 bad username error detect if ":" in username: # this will even allow for the username to be empty username, password = username.split(":", 1) if ":" in host: # IPv6 address and/or got a port specified # If it is an IPv6 address with port specification, # then it will look like: [::1]:22 try: # try to parse host as an IP address, # if that works it is an IPv6 address host = str(ipaddress.ip_address(host)) except ValueError: # if that fails parse as URL to get the port parsed = urlparse('//{}'.format(host)) try: host = str(ipaddress.ip_address(parsed.hostname)) except ValueError: # else if both fails, we have a hostname with port host = parsed.hostname port = parsed.port if password is None or len(password) == 0: password = None return username, password, port, host def connect(ssh_cmd, rhostport, python, stderr, add_cmd_delimiter, remote_shell, options): username, password, port, host = parse_hostport(rhostport) if username: rhost = "{}@{}".format(username, host) else: rhost = host z = zlib.compressobj(1) content = get_module_source('sshuttle.assembler') optdata = ''.join("%s=%r\n" % (k, v) for (k, v) in list(options.items())) optdata = optdata.encode("UTF8") content2 = (empackage(z, 'sshuttle') + empackage(z, 'sshuttle.cmdline_options', optdata) + empackage(z, 'sshuttle.helpers') + empackage(z, 'sshuttle.ssnet') + empackage(z, 'sshuttle.hostwatch') + empackage(z, 'sshuttle.server') + b"\n") # If the exec() program calls sys.exit(), it should exit python # and the sys.exit(98) call won't be reached (so we try to only # exit that way in the server). However, if the code that we # exec() simply returns from main, then we will return from # exec(). If the server's python process dies, it should stop # executing and also won't reach sys.exit(98). # # So, we shouldn't reach sys.exit(98) and we certainly shouldn't # reach it immediately after trying to start the server. pyscript = r""" import sys, os; verbosity=%d; stdin = os.fdopen(0, 'rb'); exec(compile(stdin.read(%d), 'assembler.py', 'exec')); sys.exit(98); """ % (helpers.verbose or 0, len(content)) pyscript = re.sub(r'\s+', ' ', pyscript.strip()) if not rhost: # ignore the --python argument when running locally; we already know # which python version works. argv = [sys.executable, '-c', pyscript] else: if ssh_cmd: sshl = shlex.split(ssh_cmd) else: sshl = ['ssh'] if port is not None: portl = ["-p", str(port)] else: portl = [] if remote_shell == "cmd": pycmd = '"%s" -c "%s"' % (python or 'python', pyscript) elif remote_shell == "powershell": for c in ('\'', ' ', ';', '(', ')', ','): pyscript = pyscript.replace(c, '`' + c) pycmd = '%s -c %s' % (python or 'python', pyscript) else: # posix shell expected if python: pycmd = '"%s" -c "%s"' % (python, pyscript) else: # By default, we run the following code in a shell. # However, with restricted shells and other unusual # situations, there can be trouble. See the RESTRICTED # SHELL section in "man bash" for more information. The # code makes many assumptions: # # (1) That /bin/sh exists and that we can call it. # Restricted shells often do *not* allow you to run # programs specified with an absolute path like /bin/sh. # Either way, if there is trouble with this, it should # return error code 127. # # (2) python3 or python exists in the PATH and is # executable. If they aren't, then exec won't work (see (4) # below). # # (3) In /bin/sh, that we can redirect stderr in order to # hide the version that "python3 -V" might print (some # restricted shells don't allow redirection, see # RESTRICTED SHELL section in 'man bash'). However, if we # are in a restricted shell, we'd likely have trouble with # assumption (1) above. # # (4) The 'exec' command should work except if we failed # to exec python because it doesn't exist or isn't # executable OR if exec isn't allowed (some restricted # shells don't allow exec). If the exec succeeded, it will # not return and not get to the "exit 97" command. If exec # does return, we exit with code 97. # # Specifying the exact python program to run with --python # avoids many of the issues above. However, if # you have a restricted shell on remote, you may only be # able to run python if it is in your PATH (and you can't # run programs specified with an absolute path). In that # case, sshuttle might not work at all since it is not # possible to run python on the remote machine---even if # it is present. devnull = '/dev/null' pycmd = ("P=python3; $P -V 2>%s || P=python; " "exec \"$P\" -c %s; exit 97") % \ (devnull, quote(pyscript)) pycmd = ("/bin/sh -c {}".format(quote(pycmd))) if password is not None: os.environ['SSHPASS'] = str(password) argv = (["sshpass", "-e"] + sshl + portl + [rhost]) else: argv = (sshl + portl + [rhost]) if add_cmd_delimiter: argv += ['--', pycmd] else: argv += [pycmd] # Our which() function searches for programs in get_path() # directories (which include PATH). This step isn't strictly # necessary if ssh is already in the user's PATH, but it makes the # error message friendlier if the user incorrectly passes in a # custom ssh command that we cannot find. abs_path = which(argv[0]) if abs_path is None: raise Fatal("Failed to find '%s' in path %s" % (argv[0], get_path())) argv[0] = abs_path if sys.platform != 'win32': (s1, s2) = socket.socketpair() pstdin, pstdout = os.dup(s1.fileno()), os.dup(s1.fileno()) def preexec_fn(): # runs in the child process s2.close() s1.close() def get_server_io(): os.close(pstdin) os.close(pstdout) return s2.makefile("rb", buffering=0), s2.makefile("wb", buffering=0) else: # In Windows CPython, BSD sockets are not supported as subprocess stdio # and select.select() used in ssnet.py won't work on Windows pipes. # So we have to use both socketpair (for select.select) and pipes (for subprocess.Popen) together # along with reader/writer threads to stream data between them # NOTE: Their could be a better way. Need to investigate further on this. # Either to use sockets as stdio for subprocess. Or to use pipes but with a select() alternative # https://stackoverflow.com/questions/4993119/redirect-io-of-process-to-windows-socket pstdin = ssubprocess.PIPE pstdout = ssubprocess.PIPE preexec_fn = None def get_server_io(): shim = SocketRWShim(p.stdout, p.stdin, on_end=lambda: p.terminate()) return shim.makefiles() # See: stackoverflow.com/questions/48671215/howto-workaround-of-close-fds-true-and-redirect-stdout-stderr-on-windows close_fds = False if sys.platform == 'win32' else True debug2("executing: %r" % argv) p = ssubprocess.Popen(argv, stdin=pstdin, stdout=pstdout, preexec_fn=preexec_fn, close_fds=close_fds, stderr=stderr, bufsize=0) rfile, wfile = get_server_io() wfile.write(content) wfile.write(content2) return p, rfile, wfile sshuttle-1.3.1/sshuttle/ssnet.py000066400000000000000000000460661477061133000167660ustar00rootroot00000000000000import sys import struct import socket import errno import select import os from sshuttle.helpers import b, log, debug1, debug2, debug3, Fatal, set_non_blocking_io MAX_CHANNEL = 65535 LATENCY_BUFFER_SIZE = 32768 SHUT_RD = 0 SHUT_WR = 1 SHUT_RDWR = 2 HDR_LEN = 8 CMD_EXIT = 0x4200 CMD_PING = 0x4201 CMD_PONG = 0x4202 CMD_TCP_CONNECT = 0x4203 CMD_TCP_STOP_SENDING = 0x4204 CMD_TCP_EOF = 0x4205 CMD_TCP_DATA = 0x4206 CMD_ROUTES = 0x4207 CMD_HOST_REQ = 0x4208 CMD_HOST_LIST = 0x4209 CMD_DNS_REQ = 0x420a CMD_DNS_RESPONSE = 0x420b CMD_UDP_OPEN = 0x420c CMD_UDP_DATA = 0x420d CMD_UDP_CLOSE = 0x420e cmd_to_name = { CMD_EXIT: 'EXIT', CMD_PING: 'PING', CMD_PONG: 'PONG', CMD_TCP_CONNECT: 'TCP_CONNECT', CMD_TCP_STOP_SENDING: 'TCP_STOP_SENDING', CMD_TCP_EOF: 'TCP_EOF', CMD_TCP_DATA: 'TCP_DATA', CMD_ROUTES: 'ROUTES', CMD_HOST_REQ: 'HOST_REQ', CMD_HOST_LIST: 'HOST_LIST', CMD_DNS_REQ: 'DNS_REQ', CMD_DNS_RESPONSE: 'DNS_RESPONSE', CMD_UDP_OPEN: 'UDP_OPEN', CMD_UDP_DATA: 'UDP_DATA', CMD_UDP_CLOSE: 'UDP_CLOSE', } NET_ERRS = [errno.ECONNREFUSED, errno.ETIMEDOUT, errno.EHOSTUNREACH, errno.ENETUNREACH, errno.EHOSTDOWN, errno.ENETDOWN, errno.ENETUNREACH, errno.ECONNABORTED, errno.ECONNRESET] def _add(socks, elem): if elem not in socks: socks.append(elem) def _fds(socks): out = [] for i in socks: try: out.append(i.fileno()) except AttributeError: out.append(i) out.sort() return out def _nb_clean(func, *args): try: return func(*args) except (OSError, socket.error): # Note: In python2 socket.error != OSError (In python3, they are same) _, e = sys.exc_info()[:2] if e.errno not in (errno.EWOULDBLOCK, errno.EAGAIN): raise else: debug3('%s: err was: %s' % (func.__name__, e)) return None def _try_peername(sock): try: pn = sock.getpeername() if pn: return '%s:%s' % (pn[0], pn[1]) except socket.error: _, e = sys.exc_info()[:2] if e.args[0] == errno.EINVAL: pass elif e.args[0] not in (errno.ENOTCONN, errno.ENOTSOCK): raise except AttributeError: pass return 'unknown' _swcount = 0 class SockWrapper: def __init__(self, rsock, wsock, connect_to=None, peername=None): global _swcount _swcount += 1 debug3('creating new SockWrapper (%d now exist)' % _swcount) self.exc = None self.rsock = rsock self.wsock = wsock self.shut_read = self.shut_write = False self.buf = [] self.connect_to = connect_to self.peername = peername or _try_peername(self.rsock) self.try_connect() def __del__(self): global _swcount _swcount -= 1 debug1('%r: deleting (%d remain)' % (self, _swcount)) if self.exc: debug1('%r: error was: %s' % (self, self.exc)) def __repr__(self): if self.rsock == self.wsock: fds = '#%d' % self.rsock.fileno() else: fds = '#%d,%d' % (self.rsock.fileno(), self.wsock.fileno()) return 'SW%s:%s' % (fds, self.peername) def seterr(self, e): if not self.exc: self.exc = e self.nowrite() self.noread() def try_connect(self): if self.connect_to and self.shut_write: self.noread() self.connect_to = None if not self.connect_to: return # already connected self.rsock.setblocking(False) debug3('%r: trying connect to %r' % (self, self.connect_to)) try: self.rsock.connect(self.connect_to) # connected successfully (Linux) self.connect_to = None except socket.error: _, e = sys.exc_info()[:2] debug3('%r: connect result: %s' % (self, e)) if e.args[0] == errno.EINVAL: # this is what happens when you call connect() on a socket # that is now connected but returned EINPROGRESS last time, # on BSD, on python pre-2.5.1. We need to use getsockopt() # to get the "real" error. Later pythons do this # automatically, so this code won't run. realerr = self.rsock.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR) e = socket.error(realerr, os.strerror(realerr)) debug3('%r: fixed connect result: %s' % (self, e)) if e.args[0] in [errno.EINPROGRESS, errno.EALREADY]: pass # not connected yet elif sys.platform == 'win32' and e.args[0] == errno.WSAEWOULDBLOCK: # 10035 pass # not connected yet elif e.args[0] == 0: if sys.platform == 'win32': # On Windows "real" error of EINVAL could be 0, when socket is in connecting state pass else: # connected successfully (weird Linux bug?) # Sometimes Linux seems to return EINVAL when it isn't # invalid. This *may* be caused by a race condition # between connect() and getsockopt(SO_ERROR) (ie. it # finishes connecting in between the two, so there is no # longer an error). However, I'm not sure of that. # # I did get at least one report that the problem went away # when we added this, however. self.connect_to = None elif e.args[0] == errno.EISCONN: # connected successfully (BSD + Windows) self.connect_to = None elif e.args[0] in NET_ERRS + [errno.EACCES, errno.EPERM]: # a "normal" kind of error self.connect_to = None self.seterr(e) else: raise # error we've never heard of?! barf completely. def noread(self): if not self.shut_read: debug2('%r: done reading' % self) self.shut_read = True def nowrite(self): if not self.shut_write: debug2('%r: done writing' % self) self.shut_write = True try: self.wsock.shutdown(SHUT_WR) except socket.error: _, e = sys.exc_info()[:2] self.seterr('nowrite: %s' % e) @staticmethod def too_full(): return False # fullness is determined by the socket's select() state def uwrite(self, buf): if self.connect_to: return 0 # still connecting self.wsock.setblocking(False) try: return _nb_clean(self.wsock.send, buf) except OSError: _, e = sys.exc_info()[:2] if e.errno == errno.EPIPE: debug1('%r: uwrite: got EPIPE' % self) self.nowrite() return 0 else: # unexpected error... stream is dead self.seterr('uwrite: %s' % e) return 0 def write(self, buf): assert buf return self.uwrite(buf) def uread(self): if self.connect_to: return None # still connecting if self.shut_read: return self.rsock.setblocking(False) try: return _nb_clean(self.rsock.recv, 65536) except OSError: _, e = sys.exc_info()[:2] self.seterr('uread: %s' % e) return b('') # unexpected error... we'll call it EOF def fill(self): if self.buf: return rb = self.uread() if rb: self.buf.append(rb) if rb == b(''): # empty string means EOF; None means temporarily empty self.noread() def copy_to(self, outwrap): if self.buf and self.buf[0]: wrote = outwrap.write(self.buf[0]) self.buf[0] = self.buf[0][wrote:] while self.buf and not self.buf[0]: self.buf.pop(0) if not self.buf and self.shut_read: outwrap.nowrite() class Handler: def __init__(self, socks=None, callback=None): self.ok = True self.socks = socks or [] if callback: self.callback = callback def pre_select(self, r, w, x): for i in self.socks: _add(r, i) def callback(self, sock): log('--no callback defined-- %r' % self) (r, _, _) = select.select(self.socks, [], [], 0) for s in r: v = s.recv(4096) if not v: log('--closed-- %r' % self) self.socks = [] self.ok = False class Proxy(Handler): def __init__(self, wrap1, wrap2): Handler.__init__(self, [wrap1.rsock, wrap1.wsock, wrap2.rsock, wrap2.wsock]) self.wrap1 = wrap1 self.wrap2 = wrap2 def pre_select(self, r, w, x): if self.wrap1.shut_write: self.wrap2.noread() if self.wrap2.shut_write: self.wrap1.noread() if self.wrap1.connect_to: _add(w, self.wrap1.rsock) elif self.wrap1.buf: if not self.wrap2.too_full(): _add(w, self.wrap2.wsock) elif not self.wrap1.shut_read: _add(r, self.wrap1.rsock) if self.wrap2.connect_to: _add(w, self.wrap2.rsock) elif self.wrap2.buf: if not self.wrap1.too_full(): _add(w, self.wrap1.wsock) elif not self.wrap2.shut_read: _add(r, self.wrap2.rsock) def callback(self, sock): self.wrap1.try_connect() self.wrap2.try_connect() self.wrap1.fill() self.wrap2.fill() self.wrap1.copy_to(self.wrap2) self.wrap2.copy_to(self.wrap1) if self.wrap1.buf and self.wrap2.shut_write: self.wrap1.buf = [] self.wrap1.noread() if self.wrap2.buf and self.wrap1.shut_write: self.wrap2.buf = [] self.wrap2.noread() if (self.wrap1.shut_read and self.wrap2.shut_read and not self.wrap1.buf and not self.wrap2.buf): self.ok = False self.wrap1.nowrite() self.wrap2.nowrite() class Mux(Handler): def __init__(self, rfile, wfile): Handler.__init__(self, [rfile, wfile]) self.rfile = rfile self.wfile = wfile self.new_channel = self.got_dns_req = self.got_routes = None self.got_udp_open = self.got_udp_data = self.got_udp_close = None self.got_host_req = self.got_host_list = None self.channels = {} self.chani = 0 self.want = 0 self.inbuf = b('') self.outbuf = [] self.fullness = 0 self.too_full = False self.send(0, CMD_PING, b('chicken')) def next_channel(self): # channel 0 is special, so we never allocate it for _ in range(1024): self.chani += 1 if self.chani > MAX_CHANNEL: self.chani = 1 if not self.channels.get(self.chani): return self.chani def amount_queued(self): total = 0 for byte in self.outbuf: total += len(byte) return total def check_fullness(self): if self.fullness > LATENCY_BUFFER_SIZE: if not self.too_full: self.send(0, CMD_PING, b('rttest')) self.too_full = True def send(self, channel, cmd, data): assert isinstance(data, bytes) assert len(data) <= 65535 p = struct.pack('!ccHHH', b('S'), b('S'), channel, cmd, len(data)) \ + data self.outbuf.append(p) debug2(' > channel=%d cmd=%s len=%d (fullness=%d)' % (channel, cmd_to_name.get(cmd, hex(cmd)), len(data), self.fullness)) # debug3('>>> data: %r' % data) self.fullness += len(data) def got_packet(self, channel, cmd, data): debug2('< channel=%d cmd=%s len=%d' % (channel, cmd_to_name.get(cmd, hex(cmd)), len(data))) # debug3('<<< data: %r' % data) if cmd == CMD_PING: self.send(0, CMD_PONG, data) elif cmd == CMD_PONG: debug2('received PING response') self.too_full = False self.fullness = 0 elif cmd == CMD_EXIT: self.ok = False elif cmd == CMD_TCP_CONNECT: assert not self.channels.get(channel) if self.new_channel: self.new_channel(channel, data) elif cmd == CMD_DNS_REQ: assert not self.channels.get(channel) if self.got_dns_req: self.got_dns_req(channel, data) elif cmd == CMD_UDP_OPEN: assert not self.channels.get(channel) if self.got_udp_open: self.got_udp_open(channel, data) elif cmd == CMD_ROUTES: if self.got_routes: self.got_routes(data) else: raise Exception('got CMD_ROUTES without got_routes?') elif cmd == CMD_HOST_REQ: if self.got_host_req: self.got_host_req(data) else: raise Exception('got CMD_HOST_REQ without got_host_req?') elif cmd == CMD_HOST_LIST: if self.got_host_list: self.got_host_list(data) else: raise Exception('got CMD_HOST_LIST without got_host_list?') else: callback = self.channels.get(channel) if not callback: log('warning: closed channel %d got cmd=%s len=%d' % (channel, cmd_to_name.get(cmd, hex(cmd)), len(data))) else: callback(cmd, data) def flush(self): set_non_blocking_io(self.wfile.fileno()) if self.outbuf and self.outbuf[0]: wrote = _nb_clean(self.wfile.write, self.outbuf[0]) # self.wfile.flush() debug2('mux wrote: %r/%d' % (wrote, len(self.outbuf[0]))) if wrote: self.outbuf[0] = self.outbuf[0][wrote:] while self.outbuf and not self.outbuf[0]: self.outbuf[0:1] = [] def fill(self): set_non_blocking_io(self.rfile.fileno()) try: # If LATENCY_BUFFER_SIZE is inappropriately large, we will # get a MemoryError here. Read no more than 1MiB. read = _nb_clean(self.rfile.read, min(1048576, LATENCY_BUFFER_SIZE)) debug2('mux read: %r' % len(read)) except OSError: _, e = sys.exc_info()[:2] raise Fatal('other end: %r' % e) # log('<<< %r' % b) if read == b(''): # EOF self.ok = False if read: self.inbuf += read def handle(self): self.fill() while 1: if len(self.inbuf) >= (self.want or HDR_LEN): (s1, s2, channel, cmd, datalen) = \ struct.unpack('!ccHHH', self.inbuf[:HDR_LEN]) assert s1 == b('S') assert s2 == b('S') self.want = datalen + HDR_LEN if self.want and len(self.inbuf) >= self.want: data = self.inbuf[HDR_LEN:self.want] self.inbuf = self.inbuf[self.want:] self.want = 0 self.got_packet(channel, cmd, data) else: break def pre_select(self, r, w, x): _add(r, self.rfile) if self.outbuf: _add(w, self.wfile) def callback(self, sock): (r, w, _) = select.select([self.rfile], [self.wfile], [], 0) if self.rfile in r: self.handle() if self.outbuf and self.wfile in w: self.flush() class MuxWrapper(SockWrapper): def __init__(self, mux, channel): SockWrapper.__init__(self, mux.rfile, mux.wfile) self.mux = mux self.channel = channel self.mux.channels[channel] = self.got_packet self.socks = [] debug2('new channel: %d' % channel) def __del__(self): self.nowrite() SockWrapper.__del__(self) def __repr__(self): return 'SW%r:Mux#%d' % (self.peername, self.channel) def noread(self): if not self.shut_read: self.mux.send(self.channel, CMD_TCP_STOP_SENDING, b('')) self.setnoread() def setnoread(self): if not self.shut_read: debug2('%r: done reading' % self) self.shut_read = True self.maybe_close() def nowrite(self): if not self.shut_write: self.mux.send(self.channel, CMD_TCP_EOF, b('')) self.setnowrite() def setnowrite(self): if not self.shut_write: debug2('%r: done writing' % self) self.shut_write = True self.maybe_close() def maybe_close(self): if self.shut_read and self.shut_write: debug2('%r: closing connection' % self) # remove the mux's reference to us. The python garbage collector # will then be able to reap our object. self.mux.channels[self.channel] = None def too_full(self): return self.mux.too_full def uwrite(self, buf): if self.mux.too_full: return 0 # too much already enqueued if len(buf) > 2048: buf = buf[:2048] self.mux.send(self.channel, CMD_TCP_DATA, buf) return len(buf) def uread(self): if self.shut_read: return b('') # EOF else: return None # no data available right now def got_packet(self, cmd, data): if cmd == CMD_TCP_EOF: # Remote side already knows the status - set flag but don't notify self.setnoread() elif cmd == CMD_TCP_STOP_SENDING: # Remote side already knows the status - set flag but don't notify self.setnowrite() elif cmd == CMD_TCP_DATA: self.buf.append(data) else: raise Exception('unknown command %d (%d bytes)' % (cmd, len(data))) def connect_dst(family, ip, port): debug2('Connecting to %s:%d' % (ip, port)) outsock = socket.socket(family) return SockWrapper(outsock, outsock, connect_to=(ip, port), peername='%s:%d' % (ip, port)) def runonce(handlers, mux): r = [] w = [] x = [] to_remove = [s for s in handlers if not s.ok] for h in to_remove: handlers.remove(h) for s in handlers: s.pre_select(r, w, x) debug2('Waiting: %d r=%r w=%r x=%r (fullness=%d/%d)' % (len(handlers), _fds(r), _fds(w), _fds(x), mux.fullness, mux.too_full)) (r, w, x) = select.select(r, w, x) debug2(' Ready: %d r=%r w=%r x=%r' % (len(handlers), _fds(r), _fds(w), _fds(x))) ready = r + w + x did = {} for h in handlers: for s in h.socks: if s in ready: h.callback(s) did[s] = 1 for s in ready: if s not in did: raise Fatal('socket %r was not used by any handler' % s) sshuttle-1.3.1/sshuttle/ssyslog.py000066400000000000000000000011111477061133000173130ustar00rootroot00000000000000import sys import os import subprocess as ssubprocess _p = None def start_syslog(): global _p with open(os.devnull, 'w') as devnull: _p = ssubprocess.Popen( ['logger', '-p', 'daemon.err', '-t', 'sshuttle'], stdin=ssubprocess.PIPE, stdout=devnull, stderr=devnull ) def close_stdin(): sys.stdin.close() def stdout_to_syslog(): sys.stdout.flush() os.dup2(_p.stdin.fileno(), sys.stdout.fileno()) def stderr_to_syslog(): sys.stderr.flush() os.dup2(_p.stdin.fileno(), sys.stderr.fileno()) sshuttle-1.3.1/sshuttle/sudoers.py000066400000000000000000000030311477061133000172770ustar00rootroot00000000000000import os import sys import getpass from uuid import uuid4 def build_config(user_name): template = ''' # WARNING: If you intend to restrict a user to only running the # sshuttle command as root, THIS CONFIGURATION IS INSECURE. # When a user can run sshuttle as root (with or without a password), # they can also run other commands as root because sshuttle itself # can run a command specified by the user with the --ssh-cmd option. # INSTRUCTIONS: Add this text to your sudo configuration to run # sshuttle without needing to enter a sudo password. To use this # configuration, run 'visudo /etc/sudoers.d/sshuttle_auto' as root and # paste this text into the editor that it opens. If you want to give # multiple users these privileges, you may wish to use use different # filenames for each one (i.e., /etc/sudoers.d/sshuttle_auto_john). # This configuration was initially generated by the # 'sshuttle --sudoers-no-modify' command. Cmnd_Alias %(ca)s = /usr/bin/env PYTHONPATH=%(dist_packages)s %(py)s %(path)s * %(user_name)s ALL=NOPASSWD: %(ca)s ''' content = template % { # randomize command alias to avoid collisions 'ca': 'SSHUTTLE%(num)s' % {'num': uuid4().hex[-3:].upper()}, 'dist_packages': os.path.dirname(os.path.abspath(__file__))[:-9], 'py': sys.executable, 'path': sys.argv[0], 'user_name': user_name, } return content def sudoers(user_name=None): user_name = user_name or getpass.getuser() content = build_config(user_name) sys.stdout.write(content) exit(0) sshuttle-1.3.1/tests/000077500000000000000000000000001477061133000145335ustar00rootroot00000000000000sshuttle-1.3.1/tests/client/000077500000000000000000000000001477061133000160115ustar00rootroot00000000000000sshuttle-1.3.1/tests/client/test_firewall.py000066400000000000000000000115611477061133000212330ustar00rootroot00000000000000import io import os from socket import AF_INET, AF_INET6 from unittest.mock import Mock, patch, call import pytest import sshuttle.firewall def setup_daemon(): stdin = io.BytesIO(u"""ROUTES {inet},24,0,1.2.3.0,8000,9000 {inet},32,1,1.2.3.66,8080,8080 {inet6},64,0,2404:6800:4004:80c::,0,0 {inet6},128,1,2404:6800:4004:80c::101f,80,80 NSLIST {inet},1.2.3.33 {inet6},2404:6800:4004:80c::33 PORTS 1024,1025,1026,1027 GO 1 - - 0x01 12345 HOST 1.2.3.3,existing """.format(inet=AF_INET, inet6=AF_INET6).encode('ASCII')) stdout = Mock() return stdin, stdout def test_rewrite_etc_hosts(tmpdir): orig_hosts = tmpdir.join("hosts.orig") orig_hosts.write("1.2.3.3 existing\n") new_hosts = tmpdir.join("hosts") orig_hosts.copy(new_hosts) hostmap = { 'myhost': '1.2.3.4', 'myotherhost': '1.2.3.5', } with patch('sshuttle.firewall.HOSTSFILE', new=str(new_hosts)): sshuttle.firewall.rewrite_etc_hosts(hostmap, 10) with new_hosts.open() as f: line = f.readline() s = line.split() assert s == ['1.2.3.3', 'existing'] line = f.readline() s = line.split() assert s == ['1.2.3.4', 'myhost', '#', 'sshuttle-firewall-10', 'AUTOCREATED'] line = f.readline() s = line.split() assert s == ['1.2.3.5', 'myotherhost', '#', 'sshuttle-firewall-10', 'AUTOCREATED'] line = f.readline() assert line == "" with patch('sshuttle.firewall.HOSTSFILE', new=str(new_hosts)): sshuttle.firewall.restore_etc_hosts(hostmap, 10) assert orig_hosts.computehash() == new_hosts.computehash() @patch('os.link') @patch('os.rename') def test_rewrite_etc_hosts_no_overwrite(mock_link, mock_rename, tmpdir): mock_link.side_effect = OSError mock_rename.side_effect = OSError with pytest.raises(OSError): os.link('/test_from', '/test_to') with pytest.raises(OSError): os.rename('/test_from', '/test_to') test_rewrite_etc_hosts(tmpdir) def test_subnet_weight(): subnets = [ (AF_INET, 16, 0, '192.168.0.0', 0, 0), (AF_INET, 24, 0, '192.168.69.0', 0, 0), (AF_INET, 32, 0, '192.168.69.70', 0, 0), (AF_INET, 32, 1, '192.168.69.70', 0, 0), (AF_INET, 32, 1, '192.168.69.70', 80, 80), (AF_INET, 0, 1, '0.0.0.0', 0, 0), (AF_INET, 0, 1, '0.0.0.0', 8000, 9000), (AF_INET, 0, 1, '0.0.0.0', 8000, 8500), (AF_INET, 0, 1, '0.0.0.0', 8000, 8000), (AF_INET, 0, 1, '0.0.0.0', 400, 450) ] subnets_sorted = [ (AF_INET, 32, 1, '192.168.69.70', 80, 80), (AF_INET, 0, 1, '0.0.0.0', 8000, 8000), (AF_INET, 0, 1, '0.0.0.0', 400, 450), (AF_INET, 0, 1, '0.0.0.0', 8000, 8500), (AF_INET, 0, 1, '0.0.0.0', 8000, 9000), (AF_INET, 32, 1, '192.168.69.70', 0, 0), (AF_INET, 32, 0, '192.168.69.70', 0, 0), (AF_INET, 24, 0, '192.168.69.0', 0, 0), (AF_INET, 16, 0, '192.168.0.0', 0, 0), (AF_INET, 0, 1, '0.0.0.0', 0, 0) ] assert subnets_sorted == sorted(subnets, key=sshuttle.firewall.subnet_weight, reverse=True) @patch('sshuttle.firewall.rewrite_etc_hosts') @patch('sshuttle.firewall.setup_daemon') @patch('sshuttle.firewall.get_method') def test_main(mock_get_method, mock_setup_daemon, mock_rewrite_etc_hosts): stdin, stdout = setup_daemon() mock_setup_daemon.return_value = stdin, stdout mock_get_method("not_auto").name = "test" mock_get_method.reset_mock() sshuttle.firewall.main("not_auto", False) assert mock_rewrite_etc_hosts.mock_calls == [ call({'1.2.3.3': 'existing'}, 1024), call({}, 1024), ] assert stdout.mock_calls == [ call.write(b'READY test\n'), call.flush(), call.write(b'STARTED\n'), call.flush() ] assert mock_setup_daemon.mock_calls == [call()] assert mock_get_method.mock_calls == [ call('not_auto'), call().is_supported(), call().is_supported().__bool__(), call().setup_firewall( 1024, 1026, [(AF_INET6, u'2404:6800:4004:80c::33')], AF_INET6, [(AF_INET6, 64, False, u'2404:6800:4004:80c::', 0, 0), (AF_INET6, 128, True, u'2404:6800:4004:80c::101f', 80, 80)], True, None, None, '0x01'), call().setup_firewall( 1025, 1027, [(AF_INET, u'1.2.3.33')], AF_INET, [(AF_INET, 24, False, u'1.2.3.0', 8000, 9000), (AF_INET, 32, True, u'1.2.3.66', 8080, 8080)], True, None, None, '0x01'), call().wait_for_firewall_ready(12345), call().restore_firewall(1024, AF_INET6, True, None, None), call().restore_firewall(1025, AF_INET, True, None, None), ] sshuttle-1.3.1/tests/client/test_helpers.py000066400000000000000000000140271477061133000210700ustar00rootroot00000000000000import io import socket from socket import AF_INET, AF_INET6 import errno from unittest.mock import patch, call import sshuttle.helpers @patch('sshuttle.helpers.logprefix', new='prefix: ') @patch('sshuttle.helpers.sys.stdout') @patch('sshuttle.helpers.sys.stderr') def test_log(mock_stderr, mock_stdout): sshuttle.helpers.log("message") sshuttle.helpers.log("abc") sshuttle.helpers.log("message 1\n") sshuttle.helpers.log("message 2\nline2\nline3\n") sshuttle.helpers.log("message 3\nline2\nline3") assert mock_stdout.mock_calls == [ call.flush(), call.flush(), call.flush(), call.flush(), call.flush(), ] assert mock_stderr.mock_calls == [ call.write('prefix: message\n'), call.flush(), call.write('prefix: abc\n'), call.flush(), call.write('prefix: message 1\n'), call.flush(), call.write('prefix: message 2\n'), call.write(' line2\n'), call.write(' line3\n'), call.flush(), call.write('prefix: message 3\n'), call.write(' line2\n'), call.write(' line3\n'), call.flush(), ] @patch('sshuttle.helpers.logprefix', new='prefix: ') @patch('sshuttle.helpers.verbose', new=1) @patch('sshuttle.helpers.sys.stdout') @patch('sshuttle.helpers.sys.stderr') def test_debug1(mock_stderr, mock_stdout): sshuttle.helpers.debug1("message") assert mock_stdout.mock_calls == [ call.flush(), ] assert mock_stderr.mock_calls == [ call.write('prefix: message\n'), call.flush(), ] @patch('sshuttle.helpers.logprefix', new='prefix: ') @patch('sshuttle.helpers.verbose', new=0) @patch('sshuttle.helpers.sys.stdout') @patch('sshuttle.helpers.sys.stderr') def test_debug1_nop(mock_stderr, mock_stdout): sshuttle.helpers.debug1("message") assert mock_stdout.mock_calls == [] assert mock_stderr.mock_calls == [] @patch('sshuttle.helpers.logprefix', new='prefix: ') @patch('sshuttle.helpers.verbose', new=2) @patch('sshuttle.helpers.sys.stdout') @patch('sshuttle.helpers.sys.stderr') def test_debug2(mock_stderr, mock_stdout): sshuttle.helpers.debug2("message") assert mock_stdout.mock_calls == [ call.flush(), ] assert mock_stderr.mock_calls == [ call.write('prefix: message\n'), call.flush(), ] @patch('sshuttle.helpers.logprefix', new='prefix: ') @patch('sshuttle.helpers.verbose', new=1) @patch('sshuttle.helpers.sys.stdout') @patch('sshuttle.helpers.sys.stderr') def test_debug2_nop(mock_stderr, mock_stdout): sshuttle.helpers.debug2("message") assert mock_stdout.mock_calls == [] assert mock_stderr.mock_calls == [] @patch('sshuttle.helpers.logprefix', new='prefix: ') @patch('sshuttle.helpers.verbose', new=3) @patch('sshuttle.helpers.sys.stdout') @patch('sshuttle.helpers.sys.stderr') def test_debug3(mock_stderr, mock_stdout): sshuttle.helpers.debug3("message") assert mock_stdout.mock_calls == [ call.flush(), ] assert mock_stderr.mock_calls == [ call.write('prefix: message\n'), call.flush(), ] @patch('sshuttle.helpers.logprefix', new='prefix: ') @patch('sshuttle.helpers.verbose', new=2) @patch('sshuttle.helpers.sys.stdout') @patch('sshuttle.helpers.sys.stderr') def test_debug3_nop(mock_stderr, mock_stdout): sshuttle.helpers.debug3("message") assert mock_stdout.mock_calls == [] assert mock_stderr.mock_calls == [] @patch('sshuttle.helpers.open', create=True) def test_resolvconf_nameservers(mock_open): mock_open.return_value = io.StringIO(u""" # Generated by NetworkManager search pri nameserver 192.168.1.1 nameserver 192.168.2.1 nameserver 192.168.3.1 nameserver 192.168.4.1 nameserver 2404:6800:4004:80c::1 nameserver 2404:6800:4004:80c::2 nameserver 2404:6800:4004:80c::3 nameserver 2404:6800:4004:80c::4 """) ns = sshuttle.helpers.resolvconf_nameservers(False) assert ns == [ (AF_INET, u'192.168.1.1'), (AF_INET, u'192.168.2.1'), (AF_INET, u'192.168.3.1'), (AF_INET, u'192.168.4.1'), (AF_INET6, u'2404:6800:4004:80c::1'), (AF_INET6, u'2404:6800:4004:80c::2'), (AF_INET6, u'2404:6800:4004:80c::3'), (AF_INET6, u'2404:6800:4004:80c::4') ] @patch('sshuttle.helpers.open', create=True) def test_get_random_nameserver(mock_open): mock_open.return_value = io.StringIO(u""" # Generated by NetworkManager search pri nameserver 192.168.1.1 nameserver 192.168.2.1 nameserver 192.168.3.1 nameserver 192.168.4.1 nameserver 2404:6800:4004:80c::1 nameserver 2404:6800:4004:80c::2 nameserver 2404:6800:4004:80c::3 nameserver 2404:6800:4004:80c::4 """) ns = sshuttle.helpers.get_random_nameserver() assert ns in [ (AF_INET, u'192.168.1.1'), (AF_INET, u'192.168.2.1'), (AF_INET, u'192.168.3.1'), (AF_INET, u'192.168.4.1'), (AF_INET6, u'2404:6800:4004:80c::1'), (AF_INET6, u'2404:6800:4004:80c::2'), (AF_INET6, u'2404:6800:4004:80c::3'), (AF_INET6, u'2404:6800:4004:80c::4') ] @patch('sshuttle.helpers.socket.socket.bind') def test_islocal(mock_bind): bind_error = socket.error(errno.EADDRNOTAVAIL) mock_bind.side_effect = [None, bind_error, None, bind_error] assert sshuttle.helpers.islocal("127.0.0.1", AF_INET) assert not sshuttle.helpers.islocal("192.0.2.1", AF_INET) assert sshuttle.helpers.islocal("::1", AF_INET6) assert not sshuttle.helpers.islocal("2001:db8::1", AF_INET6) def test_family_ip_tuple(): assert sshuttle.helpers.family_ip_tuple("127.0.0.1") \ == (AF_INET, "127.0.0.1") assert sshuttle.helpers.family_ip_tuple("192.168.2.6") \ == (AF_INET, "192.168.2.6") assert sshuttle.helpers.family_ip_tuple("::1") \ == (AF_INET6, "::1") assert sshuttle.helpers.family_ip_tuple("2404:6800:4004:80c::1") \ == (AF_INET6, "2404:6800:4004:80c::1") def test_family_to_string(): assert sshuttle.helpers.family_to_string(AF_INET) == "AF_INET" assert sshuttle.helpers.family_to_string(AF_INET6) == "AF_INET6" assert isinstance(sshuttle.helpers.family_to_string(socket.AF_UNIX), str) sshuttle-1.3.1/tests/client/test_methods_nat.py000066400000000000000000000167271477061133000217440ustar00rootroot00000000000000import socket from socket import AF_INET, AF_INET6 import struct import pytest from unittest.mock import Mock, patch, call from sshuttle.helpers import Fatal from sshuttle.methods import get_method def test_get_supported_features(): method = get_method('nat') features = method.get_supported_features() assert features.ipv6 assert not features.udp assert features.dns def test_get_tcp_dstip(): sock = Mock() sock.family = AF_INET sock.getsockopt.return_value = struct.pack( '!HHBBBB', socket.ntohs(AF_INET), 1024, 127, 0, 0, 1) method = get_method('nat') assert method.get_tcp_dstip(sock) == ('127.0.0.1', 1024) assert sock.mock_calls == [call.getsockopt(0, 80, 16)] sock = Mock() sock.family = AF_INET6 sock.getsockopt.return_value = struct.pack( '!HH4xBBBBBBBBBBBBBBBB', socket.ntohs(AF_INET6), 1024, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1) method = get_method('nft') assert method.get_tcp_dstip(sock) == ('::1', 1024) assert sock.mock_calls == [call.getsockopt(41, 80, 64)] def test_recv_udp(): sock = Mock() sock.recvfrom.return_value = "11111", "127.0.0.1" method = get_method('nat') result = method.recv_udp(sock, 1024) assert sock.mock_calls == [call.recvfrom(1024)] assert result == ("127.0.0.1", None, "11111") def test_send_udp(): sock = Mock() method = get_method('nat') method.send_udp(sock, None, "127.0.0.1", "22222") assert sock.mock_calls == [call.sendto("22222", "127.0.0.1")] def test_setup_tcp_listener(): listener = Mock() method = get_method('nat') method.setup_tcp_listener(listener) assert listener.mock_calls == [] def test_setup_udp_listener(): listener = Mock() method = get_method('nat') method.setup_udp_listener(listener) assert listener.mock_calls == [] def test_assert_features(): method = get_method('nat') features = method.get_supported_features() method.assert_features(features) features.udp = True with pytest.raises(Fatal): method.assert_features(features) features.ipv6 = True with pytest.raises(Fatal): method.assert_features(features) def test_firewall_command(): method = get_method('nat') assert not method.firewall_command("something") @patch('sshuttle.methods.nat.ipt') @patch('sshuttle.methods.nat.ipt_chain_exists') def test_setup_firewall(mock_ipt_chain_exists, mock_ipt): mock_ipt_chain_exists.return_value = True method = get_method('nat') assert method.name == 'nat' assert mock_ipt_chain_exists.mock_calls == [] assert mock_ipt.mock_calls == [] method.setup_firewall( 1024, 1026, [(AF_INET6, u'2404:6800:4004:80c::33')], AF_INET6, [(AF_INET6, 64, False, u'2404:6800:4004:80c::', 0, 0), (AF_INET6, 128, True, u'2404:6800:4004:80c::101f', 80, 80)], False, None, None, '0x01') assert mock_ipt_chain_exists.mock_calls == [ call(AF_INET6, 'nat', 'sshuttle-1024') ] assert mock_ipt.mock_calls == [ call(AF_INET6, 'nat', '-D', 'OUTPUT', '-j', 'sshuttle-1024'), call(AF_INET6, 'nat', '-D', 'PREROUTING', '-j', 'sshuttle-1024'), call(AF_INET6, 'nat', '-F', 'sshuttle-1024'), call(AF_INET6, 'nat', '-X', 'sshuttle-1024'), call(AF_INET6, 'nat', '-N', 'sshuttle-1024'), call(AF_INET6, 'nat', '-F', 'sshuttle-1024'), call(AF_INET6, 'nat', '-I', 'OUTPUT', '1', '-j', 'sshuttle-1024'), call(AF_INET6, 'nat', '-I', 'PREROUTING', '1', '-j', 'sshuttle-1024'), call(AF_INET6, 'nat', '-A', 'sshuttle-1024', '-j', 'REDIRECT', '--dest', u'2404:6800:4004:80c::33', '-p', 'udp', '--dport', '53', '--to-ports', '1026'), call(AF_INET6, 'nat', '-A', 'sshuttle-1024', '-j', 'RETURN', '--dest', u'2404:6800:4004:80c::101f/128', '-p', 'tcp', '--dport', '80:80'), call(AF_INET6, 'nat', '-A', 'sshuttle-1024', '-j', 'REDIRECT', '--dest', u'2404:6800:4004:80c::/64', '-p', 'tcp', '--to-ports', '1024'), call(AF_INET6, 'nat', '-A', 'sshuttle-1024', '-j', 'RETURN', '-m', 'addrtype', '--dst-type', 'LOCAL') ] mock_ipt_chain_exists.reset_mock() mock_ipt.reset_mock() assert mock_ipt_chain_exists.mock_calls == [] assert mock_ipt.mock_calls == [] with pytest.raises(Exception) as excinfo: method.setup_firewall( 1025, 1027, [(AF_INET, u'1.2.3.33')], AF_INET, [(AF_INET, 24, False, u'1.2.3.0', 8000, 9000), (AF_INET, 32, True, u'1.2.3.66', 8080, 8080)], True, None, None, '0x01') assert str(excinfo.value) == 'UDP not supported by nat method_name' assert mock_ipt_chain_exists.mock_calls == [] assert mock_ipt.mock_calls == [] method.setup_firewall( 1025, 1027, [(AF_INET, u'1.2.3.33')], AF_INET, [(AF_INET, 24, False, u'1.2.3.0', 8000, 9000), (AF_INET, 32, True, u'1.2.3.66', 8080, 8080)], False, None, None, '0x01') assert mock_ipt_chain_exists.mock_calls == [ call(AF_INET, 'nat', 'sshuttle-1025') ] assert mock_ipt.mock_calls == [ call(AF_INET, 'nat', '-D', 'OUTPUT', '-j', 'sshuttle-1025'), call(AF_INET, 'nat', '-D', 'PREROUTING', '-j', 'sshuttle-1025'), call(AF_INET, 'nat', '-F', 'sshuttle-1025'), call(AF_INET, 'nat', '-X', 'sshuttle-1025'), call(AF_INET, 'nat', '-N', 'sshuttle-1025'), call(AF_INET, 'nat', '-F', 'sshuttle-1025'), call(AF_INET, 'nat', '-I', 'OUTPUT', '1', '-j', 'sshuttle-1025'), call(AF_INET, 'nat', '-I', 'PREROUTING', '1', '-j', 'sshuttle-1025'), call(AF_INET, 'nat', '-A', 'sshuttle-1025', '-j', 'REDIRECT', '--dest', u'1.2.3.33', '-p', 'udp', '--dport', '53', '--to-ports', '1027'), call(AF_INET, 'nat', '-A', 'sshuttle-1025', '-j', 'RETURN', '--dest', u'1.2.3.66/32', '-p', 'tcp', '--dport', '8080:8080'), call(AF_INET, 'nat', '-A', 'sshuttle-1025', '-j', 'REDIRECT', '--dest', u'1.2.3.0/24', '-p', 'tcp', '--dport', '8000:9000', '--to-ports', '1025'), call(AF_INET, 'nat', '-A', 'sshuttle-1025', '-j', 'RETURN', '-m', 'addrtype', '--dst-type', 'LOCAL'), ] mock_ipt_chain_exists.reset_mock() mock_ipt.reset_mock() method.restore_firewall(1025, AF_INET, False, None, None) assert mock_ipt_chain_exists.mock_calls == [ call(AF_INET, 'nat', 'sshuttle-1025') ] assert mock_ipt.mock_calls == [ call(AF_INET, 'nat', '-D', 'OUTPUT', '-j', 'sshuttle-1025'), call(AF_INET, 'nat', '-D', 'PREROUTING', '-j', 'sshuttle-1025'), call(AF_INET, 'nat', '-F', 'sshuttle-1025'), call(AF_INET, 'nat', '-X', 'sshuttle-1025') ] mock_ipt_chain_exists.reset_mock() mock_ipt.reset_mock() method.restore_firewall(1025, AF_INET6, False, None, None) assert mock_ipt_chain_exists.mock_calls == [ call(AF_INET6, 'nat', 'sshuttle-1025') ] assert mock_ipt.mock_calls == [ call(AF_INET6, 'nat', '-D', 'OUTPUT', '-j', 'sshuttle-1025'), call(AF_INET6, 'nat', '-D', 'PREROUTING', '-j', 'sshuttle-1025'), call(AF_INET6, 'nat', '-F', 'sshuttle-1025'), call(AF_INET6, 'nat', '-X', 'sshuttle-1025') ] mock_ipt_chain_exists.reset_mock() mock_ipt.reset_mock() sshuttle-1.3.1/tests/client/test_methods_pf.py000066400000000000000000000416451477061133000215640ustar00rootroot00000000000000import socket from socket import AF_INET, AF_INET6 import pytest from unittest.mock import Mock, patch, call, ANY from sshuttle.methods import get_method from sshuttle.helpers import Fatal, get_env from sshuttle.methods.pf import FreeBsd, Darwin, OpenBsd def test_get_supported_features(): method = get_method('pf') features = method.get_supported_features() assert features.ipv6 assert not features.udp assert features.dns @patch('sshuttle.helpers.verbose', new=3) def test_get_tcp_dstip(): sock = Mock() sock.getpeername.return_value = ("127.0.0.1", 1024) sock.getsockname.return_value = ("127.0.0.2", 1025) sock.family = AF_INET firewall = Mock() firewall.pfile.readline.return_value = \ b"QUERY_PF_NAT_SUCCESS 127.0.0.3,1026\n" method = get_method('pf') method.set_firewall(firewall) assert method.get_tcp_dstip(sock) == ('127.0.0.3', 1026) assert sock.mock_calls == [ call.getpeername(), call.getsockname(), ] assert firewall.mock_calls == [ call.pfile.write(b'QUERY_PF_NAT 2,6,127.0.0.1,1024,127.0.0.2,1025\n'), call.pfile.flush(), call.pfile.readline() ] def test_recv_udp(): sock = Mock() sock.recvfrom.return_value = "11111", "127.0.0.1" method = get_method('pf') result = method.recv_udp(sock, 1024) assert sock.mock_calls == [call.recvfrom(1024)] assert result == ("127.0.0.1", None, "11111") def test_send_udp(): sock = Mock() method = get_method('pf') method.send_udp(sock, None, "127.0.0.1", "22222") assert sock.mock_calls == [call.sendto("22222", "127.0.0.1")] def test_setup_tcp_listener(): listener = Mock() method = get_method('pf') method.setup_tcp_listener(listener) assert listener.mock_calls == [] def test_setup_udp_listener(): listener = Mock() method = get_method('pf') method.setup_udp_listener(listener) assert listener.mock_calls == [] def test_assert_features(): method = get_method('pf') features = method.get_supported_features() method.assert_features(features) features.udp = True with pytest.raises(Fatal): method.assert_features(features) features.ipv6 = True with pytest.raises(Fatal): method.assert_features(features) @patch('sshuttle.methods.pf.pf', Darwin()) @patch('sshuttle.methods.pf.sys.stdout') @patch('sshuttle.methods.pf.ioctl') @patch('sshuttle.methods.pf.pf_get_dev') def test_firewall_command_darwin(mock_pf_get_dev, mock_ioctl, mock_stdout): method = get_method('pf') assert not method.firewall_command("something") command = "QUERY_PF_NAT %d,%d,%s,%d,%s,%d\n" % ( AF_INET, socket.IPPROTO_TCP, "127.0.0.1", 1025, "127.0.0.2", 1024) assert method.firewall_command(command) assert mock_pf_get_dev.mock_calls == [call()] assert mock_ioctl.mock_calls == [ call(mock_pf_get_dev(), 0xc0544417, ANY), ] assert mock_stdout.mock_calls == [ call.write('QUERY_PF_NAT_SUCCESS 0.0.0.0,0\n'), call.flush(), ] @patch('sshuttle.methods.pf.pf', FreeBsd()) @patch('sshuttle.methods.pf.sys.stdout') @patch('sshuttle.methods.pf.ioctl') @patch('sshuttle.methods.pf.pf_get_dev') def test_firewall_command_freebsd(mock_pf_get_dev, mock_ioctl, mock_stdout): method = get_method('pf') assert not method.firewall_command("something") command = "QUERY_PF_NAT %d,%d,%s,%d,%s,%d\n" % ( AF_INET, socket.IPPROTO_TCP, "127.0.0.1", 1025, "127.0.0.2", 1024) assert method.firewall_command(command) assert mock_pf_get_dev.mock_calls == [call()] assert mock_ioctl.mock_calls == [ call(mock_pf_get_dev(), 0xc04c4417, ANY), ] assert mock_stdout.mock_calls == [ call.write('QUERY_PF_NAT_SUCCESS 0.0.0.0,0\n'), call.flush(), ] @patch('sshuttle.methods.pf.pf', OpenBsd()) @patch('sshuttle.methods.pf.sys.stdout') @patch('sshuttle.methods.pf.ioctl') @patch('sshuttle.methods.pf.pf_get_dev') def test_firewall_command_openbsd(mock_pf_get_dev, mock_ioctl, mock_stdout): method = get_method('pf') assert not method.firewall_command("something") command = "QUERY_PF_NAT %d,%d,%s,%d,%s,%d\n" % ( AF_INET, socket.IPPROTO_TCP, "127.0.0.1", 1025, "127.0.0.2", 1024) assert method.firewall_command(command) assert mock_pf_get_dev.mock_calls == [call()] assert mock_ioctl.mock_calls == [ call(mock_pf_get_dev(), 0xc0504417, ANY), ] assert mock_stdout.mock_calls == [ call.write('QUERY_PF_NAT_SUCCESS 0.0.0.0,0\n'), call.flush(), ] def pfctl(args, stdin=None): if args == '-s Interfaces -i lo -v': return (b'lo0 (skip)',) if args == '-s all': return (b'INFO:\nStatus: Disabled\nanother mary had a little lamb\n', b'little lamb\n') if args == '-E': return (b'\n', b'Token : abcdefg\n') return None @patch('sshuttle.helpers.verbose', new=3) @patch('sshuttle.methods.pf.pf', Darwin()) @patch('sshuttle.methods.pf.pfctl') @patch('sshuttle.methods.pf.ioctl') @patch('sshuttle.methods.pf.pf_get_dev') def test_setup_firewall_darwin(mock_pf_get_dev, mock_ioctl, mock_pfctl): mock_pfctl.side_effect = pfctl method = get_method('pf') assert method.name == 'pf' # IPV6 method.setup_firewall( 1024, 1026, [(AF_INET6, u'2404:6800:4004:80c::33')], AF_INET6, [(AF_INET6, 64, False, u'2404:6800:4004:80c::', 8000, 9000), (AF_INET6, 128, True, u'2404:6800:4004:80c::101f', 8080, 8080)], False, None, None, '0x01') assert mock_ioctl.mock_calls == [ call(mock_pf_get_dev(), 0xC4704433, ANY), call(mock_pf_get_dev(), 0xCC20441A, ANY), call(mock_pf_get_dev(), 0xCC20441A, ANY), call(mock_pf_get_dev(), 0xC4704433, ANY), call(mock_pf_get_dev(), 0xCC20441A, ANY), call(mock_pf_get_dev(), 0xCC20441A, ANY), ] assert mock_pfctl.mock_calls == [ call('-s Interfaces -i lo -v'), call('-f /dev/stdin', b'pass on lo\n'), call('-s all'), call('-a sshuttle6-1024 -f /dev/stdin', b'table {2404:6800:4004:80c::33}\n' b'rdr pass on lo0 inet6 proto tcp from ! ::1 to ' b'2404:6800:4004:80c::/64 port 8000:9000 -> ::1 port 1024\n' b'rdr pass on lo0 inet6 proto udp ' b'to port 53 -> ::1 port 1026\n' b'pass out route-to lo0 inet6 proto tcp to ' b'2404:6800:4004:80c::/64 port 8000:9000 keep state\n' b'pass out inet6 proto tcp to ' b'2404:6800:4004:80c::101f/128 port 8080:8080\n' b'pass out route-to lo0 inet6 proto udp ' b'to port 53 keep state\n'), call('-E'), ] mock_pf_get_dev.reset_mock() mock_ioctl.reset_mock() mock_pfctl.reset_mock() with pytest.raises(Exception) as excinfo: method.setup_firewall( 1025, 1027, [(AF_INET, u'1.2.3.33')], AF_INET, [(AF_INET, 24, False, u'1.2.3.0', 0, 0), (AF_INET, 32, True, u'1.2.3.66', 80, 80)], True, None, None, '0x01') assert str(excinfo.value) == 'UDP not supported by pf method_name' assert mock_pf_get_dev.mock_calls == [] assert mock_ioctl.mock_calls == [] assert mock_pfctl.mock_calls == [] method.setup_firewall( 1025, 1027, [(AF_INET, u'1.2.3.33')], AF_INET, [(AF_INET, 24, False, u'1.2.3.0', 0, 0), (AF_INET, 32, True, u'1.2.3.66', 80, 80)], False, None, None, '0x01') assert mock_ioctl.mock_calls == [ call(mock_pf_get_dev(), 0xC4704433, ANY), call(mock_pf_get_dev(), 0xCC20441A, ANY), call(mock_pf_get_dev(), 0xCC20441A, ANY), call(mock_pf_get_dev(), 0xC4704433, ANY), call(mock_pf_get_dev(), 0xCC20441A, ANY), call(mock_pf_get_dev(), 0xCC20441A, ANY), ] assert mock_pfctl.mock_calls == [ call('-s Interfaces -i lo -v'), call('-f /dev/stdin', b'pass on lo\n'), call('-s all'), call('-a sshuttle-1025 -f /dev/stdin', b'table {1.2.3.33}\n' b'rdr pass on lo0 inet proto tcp from ! 127.0.0.1 to 1.2.3.0/24 ' b'-> 127.0.0.1 port 1025\n' b'rdr pass on lo0 inet proto udp ' b'to port 53 -> 127.0.0.1 port 1027\n' b'pass out route-to lo0 inet proto tcp to 1.2.3.0/24 keep state\n' b'pass out inet proto tcp to 1.2.3.66/32 port 80:80\n' b'pass out route-to lo0 inet proto udp ' b'to port 53 keep state\n'), call('-E'), ] mock_pf_get_dev.reset_mock() mock_ioctl.reset_mock() mock_pfctl.reset_mock() method.restore_firewall(1025, AF_INET, False, None, None) assert mock_ioctl.mock_calls == [] assert mock_pfctl.mock_calls == [ call('-a sshuttle-1025 -F all'), call("-X abcdefg"), ] mock_pf_get_dev.reset_mock() mock_pfctl.reset_mock() mock_ioctl.reset_mock() @patch('sshuttle.helpers.verbose', new=3) @patch('sshuttle.methods.pf.pf', FreeBsd()) @patch('subprocess.call') @patch('sshuttle.methods.pf.pfctl') @patch('sshuttle.methods.pf.ioctl') @patch('sshuttle.methods.pf.pf_get_dev') def test_setup_firewall_freebsd(mock_pf_get_dev, mock_ioctl, mock_pfctl, mock_subprocess_call): mock_pfctl.side_effect = pfctl method = get_method('pf') assert method.name == 'pf' method.setup_firewall( 1024, 1026, [(AF_INET6, u'2404:6800:4004:80c::33')], AF_INET6, [(AF_INET6, 64, False, u'2404:6800:4004:80c::', 8000, 9000), (AF_INET6, 128, True, u'2404:6800:4004:80c::101f', 8080, 8080)], False, None, None, '0x01') assert mock_pfctl.mock_calls == [ call('-s all'), call('-a sshuttle6-1024 -f /dev/stdin', b'table {2404:6800:4004:80c::33}\n' b'rdr pass on lo0 inet6 proto tcp from ! ::1 to ' b'2404:6800:4004:80c::/64 port 8000:9000 -> ::1 port 1024\n' b'rdr pass on lo0 inet6 proto udp ' b'to port 53 -> ::1 port 1026\n' b'pass out route-to lo0 inet6 proto tcp to ' b'2404:6800:4004:80c::/64 port 8000:9000 keep state\n' b'pass out inet6 proto tcp to ' b'2404:6800:4004:80c::101f/128 port 8080:8080\n' b'pass out route-to lo0 inet6 proto udp ' b'to port 53 keep state\n'), call('-e'), ] assert call(['kldload', 'pf'], env=get_env()) in \ mock_subprocess_call.mock_calls mock_pf_get_dev.reset_mock() mock_ioctl.reset_mock() mock_pfctl.reset_mock() with pytest.raises(Exception) as excinfo: method.setup_firewall( 1025, 1027, [(AF_INET, u'1.2.3.33')], AF_INET, [(AF_INET, 24, False, u'1.2.3.0', 0, 0), (AF_INET, 32, True, u'1.2.3.66', 80, 80)], True, None, None, '0x01') assert str(excinfo.value) == 'UDP not supported by pf method_name' assert mock_pf_get_dev.mock_calls == [] assert mock_ioctl.mock_calls == [] assert mock_pfctl.mock_calls == [] method.setup_firewall( 1025, 1027, [(AF_INET, u'1.2.3.33')], AF_INET, [(AF_INET, 24, False, u'1.2.3.0', 0, 0), (AF_INET, 32, True, u'1.2.3.66', 80, 80)], False, None, None, '0x01') assert mock_ioctl.mock_calls == [ call(mock_pf_get_dev(), 0xC4704433, ANY), call(mock_pf_get_dev(), 0xCBE0441A, ANY), call(mock_pf_get_dev(), 0xCBE0441A, ANY), call(mock_pf_get_dev(), 0xC4704433, ANY), call(mock_pf_get_dev(), 0xCBE0441A, ANY), call(mock_pf_get_dev(), 0xCBE0441A, ANY), ] assert mock_pfctl.mock_calls == [ call('-s all'), call('-a sshuttle-1025 -f /dev/stdin', b'table {1.2.3.33}\n' b'rdr pass on lo0 inet proto tcp from ! 127.0.0.1 ' b'to 1.2.3.0/24 -> 127.0.0.1 port 1025\n' b'rdr pass on lo0 inet proto udp ' b'to port 53 -> 127.0.0.1 port 1027\n' b'pass out route-to lo0 inet proto tcp to 1.2.3.0/24 keep state\n' b'pass out inet proto tcp to 1.2.3.66/32 port 80:80\n' b'pass out route-to lo0 inet proto udp ' b'to port 53 keep state\n'), call('-e'), ] mock_pf_get_dev.reset_mock() mock_ioctl.reset_mock() mock_pfctl.reset_mock() method.restore_firewall(1025, AF_INET, False, None, None) method.restore_firewall(1024, AF_INET6, False, None, None) assert mock_ioctl.mock_calls == [] assert mock_pfctl.mock_calls == [ call('-a sshuttle-1025 -F all'), call('-a sshuttle6-1024 -F all'), call("-d"), ] mock_pf_get_dev.reset_mock() mock_pfctl.reset_mock() mock_ioctl.reset_mock() @patch('sshuttle.helpers.verbose', new=3) @patch('sshuttle.methods.pf.pf', OpenBsd()) @patch('sshuttle.methods.pf.pfctl') @patch('sshuttle.methods.pf.ioctl') @patch('sshuttle.methods.pf.pf_get_dev') def test_setup_firewall_openbsd(mock_pf_get_dev, mock_ioctl, mock_pfctl): mock_pfctl.side_effect = pfctl method = get_method('pf') assert method.name == 'pf' method.setup_firewall( 1024, 1026, [(AF_INET6, u'2404:6800:4004:80c::33')], AF_INET6, [(AF_INET6, 64, False, u'2404:6800:4004:80c::', 8000, 9000), (AF_INET6, 128, True, u'2404:6800:4004:80c::101f', 8080, 8080)], False, None, None, '0x01') assert mock_ioctl.mock_calls == [ call(mock_pf_get_dev(), 0xcd50441a, ANY), call(mock_pf_get_dev(), 0xcd50441a, ANY), ] assert mock_pfctl.mock_calls == [ call('-s Interfaces -i lo -v'), call('-f /dev/stdin', b'match on lo\n'), call('-s all'), call('-a sshuttle6-1024 -f /dev/stdin', b'table {2404:6800:4004:80c::33}\n' b'pass in on lo0 inet6 proto tcp to 2404:6800:4004:80c::/64 ' b'port 8000:9000 divert-to ::1 port 1024\n' b'pass in on lo0 inet6 proto udp ' b'to port 53 rdr-to ::1 port 1026\n' b'pass out inet6 proto tcp to 2404:6800:4004:80c::/64 ' b'port 8000:9000 route-to lo0 keep state\n' b'pass out inet6 proto tcp to ' b'2404:6800:4004:80c::101f/128 port 8080:8080\n' b'pass out inet6 proto udp to ' b' port 53 route-to lo0 keep state\n'), call('-e'), ] mock_pf_get_dev.reset_mock() mock_ioctl.reset_mock() mock_pfctl.reset_mock() with pytest.raises(Exception) as excinfo: method.setup_firewall( 1025, 1027, [(AF_INET, u'1.2.3.33')], AF_INET, [(AF_INET, 24, False, u'1.2.3.0', 0, 0), (AF_INET, 32, True, u'1.2.3.66', 80, 80)], True, None, None, '0x01') assert str(excinfo.value) == 'UDP not supported by pf method_name' assert mock_pf_get_dev.mock_calls == [] assert mock_ioctl.mock_calls == [] assert mock_pfctl.mock_calls == [] method.setup_firewall( 1025, 1027, [(AF_INET, u'1.2.3.33')], AF_INET, [(AF_INET, 24, False, u'1.2.3.0', 0, 0), (AF_INET, 32, True, u'1.2.3.66', 80, 80)], False, None, None, '0x01') assert mock_ioctl.mock_calls == [ call(mock_pf_get_dev(), 0xcd50441a, ANY), call(mock_pf_get_dev(), 0xcd50441a, ANY), ] assert mock_pfctl.mock_calls == [ call('-s Interfaces -i lo -v'), call('-f /dev/stdin', b'match on lo\n'), call('-s all'), call('-a sshuttle-1025 -f /dev/stdin', b'table {1.2.3.33}\n' b'pass in on lo0 inet proto tcp to 1.2.3.0/24 divert-to ' b'127.0.0.1 port 1025\n' b'pass in on lo0 inet proto udp to ' b' port 53 rdr-to 127.0.0.1 port 1027\n' b'pass out inet proto tcp to 1.2.3.0/24 route-to lo0 keep state\n' b'pass out inet proto tcp to 1.2.3.66/32 port 80:80\n' b'pass out inet proto udp to ' b' port 53 route-to lo0 keep state\n'), call('-e'), ] mock_pf_get_dev.reset_mock() mock_ioctl.reset_mock() mock_pfctl.reset_mock() method.restore_firewall(1025, AF_INET, False, None, None) method.restore_firewall(1024, AF_INET6, False, None, None) assert mock_ioctl.mock_calls == [] assert mock_pfctl.mock_calls == [ call('-a sshuttle-1025 -F all'), call('-a sshuttle6-1024 -F all'), call('-d'), ] mock_pf_get_dev.reset_mock() mock_pfctl.reset_mock() mock_ioctl.reset_mock() sshuttle-1.3.1/tests/client/test_methods_tproxy.py000066400000000000000000000312671477061133000225230ustar00rootroot00000000000000import socket from socket import AF_INET, AF_INET6 from unittest.mock import Mock, patch, call from sshuttle.methods import get_method def test_get_supported_features(): method = get_method('tproxy') features = method.get_supported_features() assert features.ipv6 assert features.udp assert features.dns def test_get_tcp_dstip(): sock = Mock() sock.getsockname.return_value = ('127.0.0.1', 1024) method = get_method('tproxy') assert method.get_tcp_dstip(sock) == ('127.0.0.1', 1024) assert sock.mock_calls == [call.getsockname()] @patch("sshuttle.methods.tproxy.recv_udp") def test_recv_udp(mock_recv_udp): mock_recv_udp.return_value = ("127.0.0.1", "127.0.0.2", "11111") sock = Mock() method = get_method('tproxy') result = method.recv_udp(sock, 1024) assert sock.mock_calls == [] assert mock_recv_udp.mock_calls == [call(sock, 1024)] assert result == ("127.0.0.1", "127.0.0.2", "11111") @patch("sshuttle.methods.socket.socket") def test_send_udp(mock_socket): sock = Mock() method = get_method('tproxy') method.send_udp(sock, "127.0.0.2", "127.0.0.1", "2222222") assert sock.mock_calls == [] assert mock_socket.mock_calls == [ call(sock.family, 2), call().setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1), call().setsockopt(0, 19, 1), call().bind('127.0.0.2'), call().sendto("2222222", '127.0.0.1'), call().close() ] def test_setup_tcp_listener(): listener = Mock() method = get_method('tproxy') method.setup_tcp_listener(listener) assert listener.mock_calls == [ call.setsockopt(0, 19, 1) ] def test_setup_udp_listener(): listener = Mock() method = get_method('tproxy') method.setup_udp_listener(listener) assert listener.mock_calls == [ call.setsockopt(0, 19, 1), call.v4.setsockopt(0, 20, 1), call.v6.setsockopt(41, 74, 1) ] def test_assert_features(): method = get_method('tproxy') features = method.get_supported_features() method.assert_features(features) def test_firewall_command(): method = get_method('tproxy') assert not method.firewall_command("something") @patch('sshuttle.methods.tproxy.ipt') @patch('sshuttle.methods.tproxy.ipt_chain_exists') def test_setup_firewall(mock_ipt_chain_exists, mock_ipt): mock_ipt_chain_exists.return_value = True method = get_method('tproxy') assert method.name == 'tproxy' # IPV6 method.setup_firewall( 1024, 1026, [(AF_INET6, u'2404:6800:4004:80c::33')], AF_INET6, [(AF_INET6, 64, False, u'2404:6800:4004:80c::', 8000, 9000), (AF_INET6, 128, True, u'2404:6800:4004:80c::101f', 8080, 8080)], True, None, None, '0x01') assert mock_ipt_chain_exists.mock_calls == [ call(AF_INET6, 'mangle', 'sshuttle-m-1024'), call(AF_INET6, 'mangle', 'sshuttle-t-1024'), call(AF_INET6, 'mangle', 'sshuttle-d-1024') ] assert mock_ipt.mock_calls == [ call(AF_INET6, 'mangle', '-D', 'OUTPUT', '-j', 'sshuttle-m-1024'), call(AF_INET6, 'mangle', '-F', 'sshuttle-m-1024'), call(AF_INET6, 'mangle', '-X', 'sshuttle-m-1024'), call(AF_INET6, 'mangle', '-D', 'PREROUTING', '-j', 'sshuttle-t-1024'), call(AF_INET6, 'mangle', '-F', 'sshuttle-t-1024'), call(AF_INET6, 'mangle', '-X', 'sshuttle-t-1024'), call(AF_INET6, 'mangle', '-F', 'sshuttle-d-1024'), call(AF_INET6, 'mangle', '-X', 'sshuttle-d-1024'), call(AF_INET6, 'mangle', '-N', 'sshuttle-m-1024'), call(AF_INET6, 'mangle', '-F', 'sshuttle-m-1024'), call(AF_INET6, 'mangle', '-N', 'sshuttle-d-1024'), call(AF_INET6, 'mangle', '-F', 'sshuttle-d-1024'), call(AF_INET6, 'mangle', '-N', 'sshuttle-t-1024'), call(AF_INET6, 'mangle', '-F', 'sshuttle-t-1024'), call(AF_INET6, 'mangle', '-I', 'OUTPUT', '1', '-j', 'sshuttle-m-1024'), call(AF_INET6, 'mangle', '-I', 'PREROUTING', '1', '-j', 'sshuttle-t-1024'), call(AF_INET6, 'mangle', '-A', 'sshuttle-m-1024', '-j', 'MARK', '--set-mark', '0x01', '--dest', u'2404:6800:4004:80c::33/32', '-m', 'udp', '-p', 'udp', '--dport', '53'), call(AF_INET6, 'mangle', '-A', 'sshuttle-t-1024', '-j', 'TPROXY', '--tproxy-mark', '0x01', '--dest', u'2404:6800:4004:80c::33/32', '-m', 'udp', '-p', 'udp', '--dport', '53', '--on-port', '1026'), call(AF_INET6, 'mangle', '-A', 'sshuttle-t-1024', '-j', 'RETURN', '-m', 'addrtype', '--dst-type', 'LOCAL'), call(AF_INET6, 'mangle', '-A', 'sshuttle-m-1024', '-j', 'RETURN', '-m', 'addrtype', '--dst-type', 'LOCAL'), call(AF_INET6, 'mangle', '-A', 'sshuttle-d-1024', '-j', 'MARK', '--set-mark', '0x01'), call(AF_INET6, 'mangle', '-A', 'sshuttle-d-1024', '-j', 'ACCEPT'), call(AF_INET6, 'mangle', '-A', 'sshuttle-t-1024', '-m', 'socket', '-j', 'sshuttle-d-1024', '-m', 'tcp', '-p', 'tcp'), call(AF_INET6, 'mangle', '-A', 'sshuttle-t-1024', '-m', 'socket', '-j', 'sshuttle-d-1024', '-m', 'udp', '-p', 'udp'), call(AF_INET6, 'mangle', '-A', 'sshuttle-m-1024', '-j', 'RETURN', '--dest', u'2404:6800:4004:80c::101f/128', '-m', 'tcp', '-p', 'tcp', '--dport', '8080:8080'), call(AF_INET6, 'mangle', '-A', 'sshuttle-t-1024', '-j', 'RETURN', '--dest', u'2404:6800:4004:80c::101f/128', '-m', 'tcp', '-p', 'tcp', '--dport', '8080:8080'), call(AF_INET6, 'mangle', '-A', 'sshuttle-m-1024', '-j', 'RETURN', '--dest', u'2404:6800:4004:80c::101f/128', '-m', 'udp', '-p', 'udp', '--dport', '8080:8080'), call(AF_INET6, 'mangle', '-A', 'sshuttle-t-1024', '-j', 'RETURN', '--dest', u'2404:6800:4004:80c::101f/128', '-m', 'udp', '-p', 'udp', '--dport', '8080:8080'), call(AF_INET6, 'mangle', '-A', 'sshuttle-m-1024', '-j', 'MARK', '--set-mark', '0x01', '--dest', u'2404:6800:4004:80c::/64', '-m', 'tcp', '-p', 'tcp', '--dport', '8000:9000'), call(AF_INET6, 'mangle', '-A', 'sshuttle-t-1024', '-j', 'TPROXY', '--tproxy-mark', '0x01', '--dest', u'2404:6800:4004:80c::/64', '-m', 'tcp', '-p', 'tcp', '--dport', '8000:9000', '--on-port', '1024'), call(AF_INET6, 'mangle', '-A', 'sshuttle-m-1024', '-j', 'MARK', '--set-mark', '0x01', '--dest', u'2404:6800:4004:80c::/64', '-m', 'udp', '-p', 'udp', '--dport', '8000:9000'), call(AF_INET6, 'mangle', '-A', 'sshuttle-t-1024', '-j', 'TPROXY', '--tproxy-mark', '0x01', '--dest', u'2404:6800:4004:80c::/64', '-m', 'udp', '-p', 'udp', '--dport', '8000:9000', '--on-port', '1024') ] mock_ipt_chain_exists.reset_mock() mock_ipt.reset_mock() method.restore_firewall(1025, AF_INET6, True, None, None) assert mock_ipt_chain_exists.mock_calls == [ call(AF_INET6, 'mangle', 'sshuttle-m-1025'), call(AF_INET6, 'mangle', 'sshuttle-t-1025'), call(AF_INET6, 'mangle', 'sshuttle-d-1025') ] assert mock_ipt.mock_calls == [ call(AF_INET6, 'mangle', '-D', 'OUTPUT', '-j', 'sshuttle-m-1025'), call(AF_INET6, 'mangle', '-F', 'sshuttle-m-1025'), call(AF_INET6, 'mangle', '-X', 'sshuttle-m-1025'), call(AF_INET6, 'mangle', '-D', 'PREROUTING', '-j', 'sshuttle-t-1025'), call(AF_INET6, 'mangle', '-F', 'sshuttle-t-1025'), call(AF_INET6, 'mangle', '-X', 'sshuttle-t-1025'), call(AF_INET6, 'mangle', '-F', 'sshuttle-d-1025'), call(AF_INET6, 'mangle', '-X', 'sshuttle-d-1025') ] mock_ipt_chain_exists.reset_mock() mock_ipt.reset_mock() # IPV4 method.setup_firewall( 1025, 1027, [(AF_INET, u'1.2.3.33')], AF_INET, [(AF_INET, 24, False, u'1.2.3.0', 0, 0), (AF_INET, 32, True, u'1.2.3.66', 80, 80)], True, None, None, '0x01') assert mock_ipt_chain_exists.mock_calls == [ call(AF_INET, 'mangle', 'sshuttle-m-1025'), call(AF_INET, 'mangle', 'sshuttle-t-1025'), call(AF_INET, 'mangle', 'sshuttle-d-1025') ] assert mock_ipt.mock_calls == [ call(AF_INET, 'mangle', '-D', 'OUTPUT', '-j', 'sshuttle-m-1025'), call(AF_INET, 'mangle', '-F', 'sshuttle-m-1025'), call(AF_INET, 'mangle', '-X', 'sshuttle-m-1025'), call(AF_INET, 'mangle', '-D', 'PREROUTING', '-j', 'sshuttle-t-1025'), call(AF_INET, 'mangle', '-F', 'sshuttle-t-1025'), call(AF_INET, 'mangle', '-X', 'sshuttle-t-1025'), call(AF_INET, 'mangle', '-F', 'sshuttle-d-1025'), call(AF_INET, 'mangle', '-X', 'sshuttle-d-1025'), call(AF_INET, 'mangle', '-N', 'sshuttle-m-1025'), call(AF_INET, 'mangle', '-F', 'sshuttle-m-1025'), call(AF_INET, 'mangle', '-N', 'sshuttle-d-1025'), call(AF_INET, 'mangle', '-F', 'sshuttle-d-1025'), call(AF_INET, 'mangle', '-N', 'sshuttle-t-1025'), call(AF_INET, 'mangle', '-F', 'sshuttle-t-1025'), call(AF_INET, 'mangle', '-I', 'OUTPUT', '1', '-j', 'sshuttle-m-1025'), call(AF_INET, 'mangle', '-I', 'PREROUTING', '1', '-j', 'sshuttle-t-1025'), call(AF_INET, 'mangle', '-A', 'sshuttle-m-1025', '-j', 'MARK', '--set-mark', '0x01', '--dest', u'1.2.3.33/32', '-m', 'udp', '-p', 'udp', '--dport', '53'), call(AF_INET, 'mangle', '-A', 'sshuttle-t-1025', '-j', 'TPROXY', '--tproxy-mark', '0x01', '--dest', u'1.2.3.33/32', '-m', 'udp', '-p', 'udp', '--dport', '53', '--on-port', '1027'), call(AF_INET, 'mangle', '-A', 'sshuttle-t-1025', '-j', 'RETURN', '-m', 'addrtype', '--dst-type', 'LOCAL'), call(AF_INET, 'mangle', '-A', 'sshuttle-m-1025', '-j', 'RETURN', '-m', 'addrtype', '--dst-type', 'LOCAL'), call(AF_INET, 'mangle', '-A', 'sshuttle-d-1025', '-j', 'MARK', '--set-mark', '0x01'), call(AF_INET, 'mangle', '-A', 'sshuttle-d-1025', '-j', 'ACCEPT'), call(AF_INET, 'mangle', '-A', 'sshuttle-t-1025', '-m', 'socket', '-j', 'sshuttle-d-1025', '-m', 'tcp', '-p', 'tcp'), call(AF_INET, 'mangle', '-A', 'sshuttle-t-1025', '-m', 'socket', '-j', 'sshuttle-d-1025', '-m', 'udp', '-p', 'udp'), call(AF_INET, 'mangle', '-A', 'sshuttle-m-1025', '-j', 'RETURN', '--dest', u'1.2.3.66/32', '-m', 'tcp', '-p', 'tcp', '--dport', '80:80'), call(AF_INET, 'mangle', '-A', 'sshuttle-t-1025', '-j', 'RETURN', '--dest', u'1.2.3.66/32', '-m', 'tcp', '-p', 'tcp', '--dport', '80:80'), call(AF_INET, 'mangle', '-A', 'sshuttle-m-1025', '-j', 'RETURN', '--dest', u'1.2.3.66/32', '-m', 'udp', '-p', 'udp', '--dport', '80:80'), call(AF_INET, 'mangle', '-A', 'sshuttle-t-1025', '-j', 'RETURN', '--dest', u'1.2.3.66/32', '-m', 'udp', '-p', 'udp', '--dport', '80:80'), call(AF_INET, 'mangle', '-A', 'sshuttle-m-1025', '-j', 'MARK', '--set-mark', '0x01', '--dest', u'1.2.3.0/24', '-m', 'tcp', '-p', 'tcp'), call(AF_INET, 'mangle', '-A', 'sshuttle-t-1025', '-j', 'TPROXY', '--tproxy-mark', '0x01', '--dest', u'1.2.3.0/24', '-m', 'tcp', '-p', 'tcp', '--on-port', '1025'), call(AF_INET, 'mangle', '-A', 'sshuttle-m-1025', '-j', 'MARK', '--set-mark', '0x01', '--dest', u'1.2.3.0/24', '-m', 'udp', '-p', 'udp'), call(AF_INET, 'mangle', '-A', 'sshuttle-t-1025', '-j', 'TPROXY', '--tproxy-mark', '0x01', '--dest', u'1.2.3.0/24', '-m', 'udp', '-p', 'udp', '--on-port', '1025') ] mock_ipt_chain_exists.reset_mock() mock_ipt.reset_mock() method.restore_firewall(1025, AF_INET, True, None, None) assert mock_ipt_chain_exists.mock_calls == [ call(AF_INET, 'mangle', 'sshuttle-m-1025'), call(AF_INET, 'mangle', 'sshuttle-t-1025'), call(AF_INET, 'mangle', 'sshuttle-d-1025') ] assert mock_ipt.mock_calls == [ call(AF_INET, 'mangle', '-D', 'OUTPUT', '-j', 'sshuttle-m-1025'), call(AF_INET, 'mangle', '-F', 'sshuttle-m-1025'), call(AF_INET, 'mangle', '-X', 'sshuttle-m-1025'), call(AF_INET, 'mangle', '-D', 'PREROUTING', '-j', 'sshuttle-t-1025'), call(AF_INET, 'mangle', '-F', 'sshuttle-t-1025'), call(AF_INET, 'mangle', '-X', 'sshuttle-t-1025'), call(AF_INET, 'mangle', '-F', 'sshuttle-d-1025'), call(AF_INET, 'mangle', '-X', 'sshuttle-d-1025') ] mock_ipt_chain_exists.reset_mock() mock_ipt.reset_mock() sshuttle-1.3.1/tests/client/test_options.py000066400000000000000000000171311477061133000211200ustar00rootroot00000000000000import socket from argparse import ArgumentTypeError as Fatal from unittest.mock import patch import pytest import sshuttle.options _ip4_reprs = { '0.0.0.0': '0.0.0.0', '255.255.255.255': '255.255.255.255', '10.0': '10.0.0.0', '184.172.10.74': '184.172.10.74', '3098282570': '184.172.10.74', '0xb8.0xac.0x0a.0x4a': '184.172.10.74', '0270.0254.0012.0112': '184.172.10.74', } _ip4_swidths = (1, 8, 22, 27, 32) _ip6_reprs = { '::': '::', '::1': '::1', 'fc00::': 'fc00::', '2a01:7e00:e000:188::1': '2a01:7e00:e000:188::1' } _ip6_swidths = (48, 64, 96, 115, 128) def _mock_getaddrinfo(host, *_): return { "example.com": [ (socket.AF_INET6, socket.SOCK_STREAM, 0, '', ('2606:2800:220:1:248:1893:25c8:1946', 0, 0, 0)), (socket.AF_INET, socket.SOCK_STREAM, 0, '', ('93.184.216.34', 0)), ], "my.local": [ (socket.AF_INET6, socket.SOCK_STREAM, 0, '', ('::1', 0, 0, 0)), (socket.AF_INET, socket.SOCK_STREAM, 0, '', ('127.0.0.1', 0)), ], "*.blogspot.com": [ (socket.AF_INET6, socket.SOCK_STREAM, 0, '', ('2404:6800:4004:821::2001', 0, 0, 0)), (socket.AF_INET, socket.SOCK_STREAM, 0, '', ('142.251.42.129', 0)), ], }.get(host, []) def test_parse_subnetport_ip4(): for ip_repr, ip in _ip4_reprs.items(): assert sshuttle.options.parse_subnetport(ip_repr) \ == [(socket.AF_INET, ip, 32, 0, 0)] with pytest.raises(Fatal) as excinfo: sshuttle.options.parse_subnetport('10.256.0.0') assert str(excinfo.value) == 'Unable to resolve address: 10.256.0.0' def test_parse_subnetport_ip4_with_mask(): for ip_repr, ip in _ip4_reprs.items(): for swidth in _ip4_swidths: assert sshuttle.options.parse_subnetport( '/'.join((ip_repr, str(swidth))) ) == [(socket.AF_INET, ip, swidth, 0, 0)] assert sshuttle.options.parse_subnetport('0/0') \ == [(socket.AF_INET, '0.0.0.0', 0, 0, 0)] with pytest.raises(Fatal) as excinfo: sshuttle.options.parse_subnetport('10.0.0.0/33') assert str(excinfo.value) \ == 'Slash in CIDR notation (/33) is not between 0 and 32' def test_parse_subnetport_ip4_with_port(): for ip_repr, ip in _ip4_reprs.items(): assert sshuttle.options.parse_subnetport(':'.join((ip_repr, '80'))) \ == [(socket.AF_INET, ip, 32, 80, 80)] assert sshuttle.options.parse_subnetport(':'.join((ip_repr, '80-90')))\ == [(socket.AF_INET, ip, 32, 80, 90)] def test_parse_subnetport_ip4_with_mask_and_port(): for ip_repr, ip in _ip4_reprs.items(): assert sshuttle.options.parse_subnetport(ip_repr + '/32:80') \ == [(socket.AF_INET, ip, 32, 80, 80)] assert sshuttle.options.parse_subnetport(ip_repr + '/16:80-90') \ == [(socket.AF_INET, ip, 16, 80, 90)] def test_parse_subnetport_ip6(): for ip_repr, ip in _ip6_reprs.items(): assert sshuttle.options.parse_subnetport(ip_repr) \ == [(socket.AF_INET6, ip, 128, 0, 0)] def test_parse_subnetport_ip6_with_mask(): for ip_repr, ip in _ip6_reprs.items(): for swidth in _ip4_swidths + _ip6_swidths: assert sshuttle.options.parse_subnetport( '/'.join((ip_repr, str(swidth))) ) == [(socket.AF_INET6, ip, swidth, 0, 0)] assert sshuttle.options.parse_subnetport('::/0') \ == [(socket.AF_INET6, '::', 0, 0, 0)] with pytest.raises(Fatal) as excinfo: sshuttle.options.parse_subnetport('fc00::/129') assert str(excinfo.value) \ == 'Slash in CIDR notation (/129) is not between 0 and 128' def test_parse_subnetport_ip6_with_port(): for ip_repr, ip in _ip6_reprs.items(): assert sshuttle.options.parse_subnetport('[' + ip_repr + ']:80') \ == [(socket.AF_INET6, ip, 128, 80, 80)] assert sshuttle.options.parse_subnetport('[' + ip_repr + ']:80-90') \ == [(socket.AF_INET6, ip, 128, 80, 90)] def test_parse_subnetport_ip6_with_mask_and_port(): for ip_repr, ip in _ip6_reprs.items(): assert sshuttle.options.parse_subnetport('[' + ip_repr + '/128]:80') \ == [(socket.AF_INET6, ip, 128, 80, 80)] assert sshuttle.options.parse_subnetport('[' + ip_repr + '/16]:80-90')\ == [(socket.AF_INET6, ip, 16, 80, 90)] def test_convert_arg_line_to_args_skips_comments(): parser = sshuttle.options.MyArgumentParser() assert parser.convert_arg_line_to_args("# whatever something") == [] @patch('sshuttle.options.socket.getaddrinfo', side_effect=_mock_getaddrinfo) def test_parse_subnetport_host(mock_getaddrinfo): assert set(sshuttle.options.parse_subnetport('example.com')) \ == set([ (socket.AF_INET6, '2606:2800:220:1:248:1893:25c8:1946', 128, 0, 0), (socket.AF_INET, '93.184.216.34', 32, 0, 0), ]) assert set(sshuttle.options.parse_subnetport('my.local')) \ == set([ (socket.AF_INET6, '::1', 128, 0, 0), (socket.AF_INET, '127.0.0.1', 32, 0, 0), ]) assert set(sshuttle.options.parse_subnetport('*.blogspot.com')) \ == set([ (socket.AF_INET6, '2404:6800:4004:821::2001', 128, 0, 0), (socket.AF_INET, '142.251.42.129', 32, 0, 0), ]) @patch('sshuttle.options.socket.getaddrinfo', side_effect=_mock_getaddrinfo) def test_parse_subnetport_host_with_port(mock_getaddrinfo): assert set(sshuttle.options.parse_subnetport('example.com:80')) \ == set([ (socket.AF_INET6, '2606:2800:220:1:248:1893:25c8:1946', 128, 80, 80), (socket.AF_INET, '93.184.216.34', 32, 80, 80), ]) assert set(sshuttle.options.parse_subnetport('example.com:80-90')) \ == set([ (socket.AF_INET6, '2606:2800:220:1:248:1893:25c8:1946', 128, 80, 90), (socket.AF_INET, '93.184.216.34', 32, 80, 90), ]) assert set(sshuttle.options.parse_subnetport('my.local:445')) \ == set([ (socket.AF_INET6, '::1', 128, 445, 445), (socket.AF_INET, '127.0.0.1', 32, 445, 445), ]) assert set(sshuttle.options.parse_subnetport('my.local:445-450')) \ == set([ (socket.AF_INET6, '::1', 128, 445, 450), (socket.AF_INET, '127.0.0.1', 32, 445, 450), ]) assert set(sshuttle.options.parse_subnetport('*.blogspot.com:80')) \ == set([ (socket.AF_INET6, '2404:6800:4004:821::2001', 128, 80, 80), (socket.AF_INET, '142.251.42.129', 32, 80, 80), ]) assert set(sshuttle.options.parse_subnetport('*.blogspot.com:80-90')) \ == set([ (socket.AF_INET6, '2404:6800:4004:821::2001', 128, 80, 90), (socket.AF_INET, '142.251.42.129', 32, 80, 90), ]) def test_parse_namespace(): valid_namespaces = [ 'my_namespace', 'my.namespace', 'my_namespace_with_underscore', 'MyNamespace', '@my_namespace', 'my.long_namespace.with.multiple.dots', '@my.long_namespace.with.multiple.dots', 'my.Namespace.With.Mixed.Case', ] for namespace in valid_namespaces: assert sshuttle.options.parse_namespace(namespace) == namespace invalid_namespaces = [ '', '123namespace', 'my-namespace', 'my_namespace!', '.my_namespace', 'my_namespace.', 'my..namespace', ] for namespace in invalid_namespaces: with pytest.raises(Fatal, match="'.*' is not a valid namespace name."): sshuttle.options.parse_namespace(namespace) sshuttle-1.3.1/tests/client/test_sdnotify.py000066400000000000000000000037121477061133000212640ustar00rootroot00000000000000import socket from unittest.mock import Mock, patch, call import sshuttle.sdnotify @patch('sshuttle.sdnotify.os.environ.get') def test_notify_invalid_socket_path(mock_get): mock_get.return_value = 'invalid_path' assert not sshuttle.sdnotify.send(sshuttle.sdnotify.ready()) @patch('sshuttle.sdnotify.os.environ.get') def test_notify_socket_not_there(mock_get): mock_get.return_value = '/run/valid_nonexistent_path' assert not sshuttle.sdnotify.send(sshuttle.sdnotify.ready()) @patch('sshuttle.sdnotify.os.environ.get') def test_notify_no_message(mock_get): mock_get.return_value = '/run/valid_path' assert not sshuttle.sdnotify.send() @patch('sshuttle.sdnotify.socket.socket') @patch('sshuttle.sdnotify.os.environ.get') def test_notify_socket_error(mock_get, mock_socket): mock_get.return_value = '/run/valid_path' mock_socket.side_effect = socket.error('test error') assert not sshuttle.sdnotify.send(sshuttle.sdnotify.ready()) @patch('sshuttle.sdnotify.socket.socket') @patch('sshuttle.sdnotify.os.environ.get') def test_notify_sendto_error(mock_get, mock_socket): message = sshuttle.sdnotify.ready() socket_path = '/run/valid_path' sock = Mock() sock.sendto.side_effect = socket.error('test error') mock_get.return_value = '/run/valid_path' mock_socket.return_value = sock assert not sshuttle.sdnotify.send(message) assert sock.sendto.mock_calls == [ call(message, socket_path), ] @patch('sshuttle.sdnotify.socket.socket') @patch('sshuttle.sdnotify.os.environ.get') def test_notify(mock_get, mock_socket): messages = [sshuttle.sdnotify.ready(), sshuttle.sdnotify.status('Running')] socket_path = '/run/valid_path' sock = Mock() sock.sendto.return_value = 1 mock_get.return_value = '/run/valid_path' mock_socket.return_value = sock assert sshuttle.sdnotify.send(*messages) assert sock.sendto.mock_calls == [ call(b'\n'.join(messages), socket_path), ] sshuttle-1.3.1/tests/server/000077500000000000000000000000001477061133000160415ustar00rootroot00000000000000sshuttle-1.3.1/tests/server/test_server.py000066400000000000000000000034461477061133000207670ustar00rootroot00000000000000import io import socket from unittest.mock import patch, Mock import sshuttle.server def test__ipmatch(): assert sshuttle.server._ipmatch("1.2.3.4") is not None assert sshuttle.server._ipmatch("::1") is None # ipv6 not supported assert sshuttle.server._ipmatch("42 Example Street, Melbourne") is None def test__ipstr(): assert sshuttle.server._ipstr("1.2.3.4", 24) == "1.2.3.4/24" assert sshuttle.server._ipstr("1.2.3.4", 32) == "1.2.3.4" def test__maskbits(): netmask = sshuttle.server._ipmatch("255.255.255.0") sshuttle.server._maskbits(netmask) @patch('sshuttle.server.which', side_effect=lambda x: x == 'netstat') @patch('sshuttle.server.ssubprocess.Popen') def test_listroutes_netstat(mock_popen, mock_which): mock_pobj = Mock() mock_pobj.stdout = io.BytesIO(b""" Kernel IP routing table Destination Gateway Genmask Flags MSS Window irtt Iface 0.0.0.0 192.168.1.1 0.0.0.0 UG 0 0 0 wlan0 192.168.1.0 0.0.0.0 255.255.255.0 U 0 0 0 wlan0 """) mock_pobj.wait.return_value = 0 mock_popen.return_value = mock_pobj routes = sshuttle.server.list_routes() assert list(routes) == [ (socket.AF_INET, '192.168.1.0', 24) ] @patch('sshuttle.server.which', side_effect=lambda x: x == 'ip') @patch('sshuttle.server.ssubprocess.Popen') def test_listroutes_iproute(mock_popen, mock_which): mock_pobj = Mock() mock_pobj.stdout = io.BytesIO(b""" default via 192.168.1.1 dev wlan0 proto static 192.168.1.0/24 dev wlan0 proto kernel scope link src 192.168.1.1 """) mock_pobj.wait.return_value = 0 mock_popen.return_value = mock_pobj routes = sshuttle.server.list_routes() assert list(routes) == [ (socket.AF_INET, '192.168.1.0', 24) ] sshuttle-1.3.1/tests/ssh/000077500000000000000000000000001477061133000153305ustar00rootroot00000000000000sshuttle-1.3.1/tests/ssh/test_parse_hostport.py000066400000000000000000000014761477061133000220250ustar00rootroot00000000000000from sshuttle.ssh import parse_hostport def test_host_only(): assert parse_hostport("host") == (None, None, None, "host") assert parse_hostport("1.2.3.4") == (None, None, None, "1.2.3.4") assert parse_hostport("2001::1") == (None, None, None, "2001::1") assert parse_hostport("[2001::1]") == (None, None, None, "2001::1") def test_host_and_port(): assert parse_hostport("host:22") == (None, None, 22, "host") assert parse_hostport("1.2.3.4:22") == (None, None, 22, "1.2.3.4") assert parse_hostport("[2001::1]:22") == (None, None, 22, "2001::1") def test_username_and_host(): assert parse_hostport("user@host") == ("user", None, None, "host") assert parse_hostport("user:@host") == ("user", None, None, "host") assert parse_hostport("user:pass@host") == ("user", "pass", None, "host") sshuttle-1.3.1/tox.ini000066400000000000000000000006361477061133000147110ustar00rootroot00000000000000[tox] downloadcache = {toxworkdir}/cache/ envlist = py38, py39, py310, [testenv] basepython = py39: python3.9 py310: python3.10 py311: python3.11 py312: python3.12 commands = pip install -e . # actual flake8 test flake8 sshuttle tests # flake8 complexity warnings flake8 sshuttle tests --exit-zero --max-complexity=10 pytest deps = -rrequirements-tests.txt sshuttle-1.3.1/uv.lock000066400000000000000000005407661477061133000147170ustar00rootroot00000000000000version = 1 requires-python = ">=3.9, <4.0" resolution-markers = [ "python_full_version < '3.10'", "python_full_version >= '3.10'", ] [[package]] name = "alabaster" version = "0.7.16" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/c9/3e/13dd8e5ed9094e734ac430b5d0eb4f2bb001708a8b7856cbf8e084e001ba/alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65", size = 23776 } wheels = [ { url = "https://files.pythonhosted.org/packages/32/34/d4e1c02d3bee589efb5dfa17f88ea08bdb3e3eac12bc475462aec52ed223/alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92", size = 13511 }, ] [[package]] name = "attrs" version = "25.3.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032 } wheels = [ { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815 }, ] [[package]] name = "babel" version = "2.17.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852 } wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537 }, ] [[package]] name = "backports-tarfile" version = "1.2.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406 } wheels = [ { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181 }, ] [[package]] name = "beautifulsoup4" version = "4.13.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "soupsieve" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f0/3c/adaf39ce1fb4afdd21b611e3d530b183bb7759c9b673d60db0e347fd4439/beautifulsoup4-4.13.3.tar.gz", hash = "sha256:1bd32405dacc920b42b83ba01644747ed77456a65760e285fbc47633ceddaf8b", size = 619516 } wheels = [ { url = "https://files.pythonhosted.org/packages/f9/49/6abb616eb3cbab6a7cca303dc02fdf3836de2e0b834bf966a7f5271a34d8/beautifulsoup4-4.13.3-py3-none-any.whl", hash = "sha256:99045d7d3f08f91f0d656bc9b7efbae189426cd913d830294a15eefa0ea4df16", size = 186015 }, ] [[package]] name = "black" version = "25.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "mypy-extensions" }, { name = "packaging" }, { name = "pathspec" }, { name = "platformdirs" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/94/49/26a7b0f3f35da4b5a65f081943b7bcd22d7002f5f0fb8098ec1ff21cb6ef/black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666", size = 649449 } wheels = [ { url = "https://files.pythonhosted.org/packages/4d/3b/4ba3f93ac8d90410423fdd31d7541ada9bcee1df32fb90d26de41ed40e1d/black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32", size = 1629419 }, { url = "https://files.pythonhosted.org/packages/b4/02/0bde0485146a8a5e694daed47561785e8b77a0466ccc1f3e485d5ef2925e/black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da", size = 1461080 }, { url = "https://files.pythonhosted.org/packages/52/0e/abdf75183c830eaca7589144ff96d49bce73d7ec6ad12ef62185cc0f79a2/black-25.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7", size = 1766886 }, { url = "https://files.pythonhosted.org/packages/dc/a6/97d8bb65b1d8a41f8a6736222ba0a334db7b7b77b8023ab4568288f23973/black-25.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9", size = 1419404 }, { url = "https://files.pythonhosted.org/packages/7e/4f/87f596aca05c3ce5b94b8663dbfe242a12843caaa82dd3f85f1ffdc3f177/black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0", size = 1614372 }, { url = "https://files.pythonhosted.org/packages/e7/d0/2c34c36190b741c59c901e56ab7f6e54dad8df05a6272a9747ecef7c6036/black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299", size = 1442865 }, { url = "https://files.pythonhosted.org/packages/21/d4/7518c72262468430ead45cf22bd86c883a6448b9eb43672765d69a8f1248/black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096", size = 1749699 }, { url = "https://files.pythonhosted.org/packages/58/db/4f5beb989b547f79096e035c4981ceb36ac2b552d0ac5f2620e941501c99/black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2", size = 1428028 }, { url = "https://files.pythonhosted.org/packages/83/71/3fe4741df7adf015ad8dfa082dd36c94ca86bb21f25608eb247b4afb15b2/black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b", size = 1650988 }, { url = "https://files.pythonhosted.org/packages/13/f3/89aac8a83d73937ccd39bbe8fc6ac8860c11cfa0af5b1c96d081facac844/black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc", size = 1453985 }, { url = "https://files.pythonhosted.org/packages/6f/22/b99efca33f1f3a1d2552c714b1e1b5ae92efac6c43e790ad539a163d1754/black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f", size = 1783816 }, { url = "https://files.pythonhosted.org/packages/18/7e/a27c3ad3822b6f2e0e00d63d58ff6299a99a5b3aee69fa77cd4b0076b261/black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba", size = 1440860 }, { url = "https://files.pythonhosted.org/packages/98/87/0edf98916640efa5d0696e1abb0a8357b52e69e82322628f25bf14d263d1/black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f", size = 1650673 }, { url = "https://files.pythonhosted.org/packages/52/e5/f7bf17207cf87fa6e9b676576749c6b6ed0d70f179a3d812c997870291c3/black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3", size = 1453190 }, { url = "https://files.pythonhosted.org/packages/e3/ee/adda3d46d4a9120772fae6de454c8495603c37c4c3b9c60f25b1ab6401fe/black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171", size = 1782926 }, { url = "https://files.pythonhosted.org/packages/cc/64/94eb5f45dcb997d2082f097a3944cfc7fe87e071907f677e80788a2d7b7a/black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18", size = 1442613 }, { url = "https://files.pythonhosted.org/packages/d3/b6/ae7507470a4830dbbfe875c701e84a4a5fb9183d1497834871a715716a92/black-25.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1ee0a0c330f7b5130ce0caed9936a904793576ef4d2b98c40835d6a65afa6a0", size = 1628593 }, { url = "https://files.pythonhosted.org/packages/24/c1/ae36fa59a59f9363017ed397750a0cd79a470490860bc7713967d89cdd31/black-25.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3df5f1bf91d36002b0a75389ca8663510cf0531cca8aa5c1ef695b46d98655f", size = 1460000 }, { url = "https://files.pythonhosted.org/packages/ac/b6/98f832e7a6c49aa3a464760c67c7856363aa644f2f3c74cf7d624168607e/black-25.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6827d563a2c820772b32ce8a42828dc6790f095f441beef18f96aa6f8294e", size = 1765963 }, { url = "https://files.pythonhosted.org/packages/ce/e9/2cb0a017eb7024f70e0d2e9bdb8c5a5b078c5740c7f8816065d06f04c557/black-25.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:bacabb307dca5ebaf9c118d2d2f6903da0d62c9faa82bd21a33eecc319559355", size = 1419419 }, { url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646 }, ] [[package]] name = "bump2version" version = "1.0.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/29/2a/688aca6eeebfe8941235be53f4da780c6edee05dbbea5d7abaa3aab6fad2/bump2version-1.0.1.tar.gz", hash = "sha256:762cb2bfad61f4ec8e2bdf452c7c267416f8c70dd9ecb1653fd0bbb01fa936e6", size = 36236 } wheels = [ { url = "https://files.pythonhosted.org/packages/1d/e3/fa60c47d7c344533142eb3af0b73234ef8ea3fb2da742ab976b947e717df/bump2version-1.0.1-py2.py3-none-any.whl", hash = "sha256:37f927ea17cde7ae2d7baf832f8e80ce3777624554a653006c9144f8017fe410", size = 22030 }, ] [[package]] name = "cattrs" version = "24.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/64/65/af6d57da2cb32c076319b7489ae0958f746949d407109e3ccf4d115f147c/cattrs-24.1.2.tar.gz", hash = "sha256:8028cfe1ff5382df59dd36474a86e02d817b06eaf8af84555441bac915d2ef85", size = 426462 } wheels = [ { url = "https://files.pythonhosted.org/packages/c8/d5/867e75361fc45f6de75fe277dd085627a9db5ebb511a87f27dc1396b5351/cattrs-24.1.2-py3-none-any.whl", hash = "sha256:67c7495b760168d931a10233f979b28dc04daf853b30752246f4f8471c6d68d0", size = 66446 }, ] [[package]] name = "certifi" version = "2025.1.31" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } wheels = [ { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, ] [[package]] name = "cffi" version = "1.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } wheels = [ { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024 }, { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188 }, { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571 }, { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687 }, { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211 }, { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325 }, { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784 }, { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564 }, { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259 }, { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200 }, { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235 }, { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721 }, { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242 }, { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999 }, { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242 }, { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604 }, { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 }, { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 }, { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 }, { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 }, { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 }, { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 }, { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 }, { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 }, { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 }, { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 }, { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 }, { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 }, { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 }, { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 }, { url = "https://files.pythonhosted.org/packages/ed/65/25a8dc32c53bf5b7b6c2686b42ae2ad58743f7ff644844af7cdb29b49361/cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8", size = 424910 }, { url = "https://files.pythonhosted.org/packages/42/7a/9d086fab7c66bd7c4d0f27c57a1b6b068ced810afc498cc8c49e0088661c/cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576", size = 447200 }, { url = "https://files.pythonhosted.org/packages/da/63/1785ced118ce92a993b0ec9e0d0ac8dc3e5dbfbcaa81135be56c69cabbb6/cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87", size = 454565 }, { url = "https://files.pythonhosted.org/packages/74/06/90b8a44abf3556599cdec107f7290277ae8901a58f75e6fe8f970cd72418/cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0", size = 435635 }, { url = "https://files.pythonhosted.org/packages/bd/62/a1f468e5708a70b1d86ead5bab5520861d9c7eacce4a885ded9faa7729c3/cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3", size = 445218 }, { url = "https://files.pythonhosted.org/packages/5b/95/b34462f3ccb09c2594aa782d90a90b045de4ff1f70148ee79c69d37a0a5a/cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595", size = 460486 }, { url = "https://files.pythonhosted.org/packages/fc/fc/a1e4bebd8d680febd29cf6c8a40067182b64f00c7d105f8f26b5bc54317b/cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a", size = 437911 }, { url = "https://files.pythonhosted.org/packages/e6/c3/21cab7a6154b6a5ea330ae80de386e7665254835b9e98ecc1340b3a7de9a/cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e", size = 460632 }, ] [[package]] name = "charset-normalizer" version = "3.4.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 } wheels = [ { url = "https://files.pythonhosted.org/packages/0d/58/5580c1716040bc89206c77d8f74418caf82ce519aae06450393ca73475d1/charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de", size = 198013 }, { url = "https://files.pythonhosted.org/packages/d0/11/00341177ae71c6f5159a08168bcb98c6e6d196d372c94511f9f6c9afe0c6/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176", size = 141285 }, { url = "https://files.pythonhosted.org/packages/01/09/11d684ea5819e5a8f5100fb0b38cf8d02b514746607934134d31233e02c8/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037", size = 151449 }, { url = "https://files.pythonhosted.org/packages/08/06/9f5a12939db324d905dc1f70591ae7d7898d030d7662f0d426e2286f68c9/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f", size = 143892 }, { url = "https://files.pythonhosted.org/packages/93/62/5e89cdfe04584cb7f4d36003ffa2936681b03ecc0754f8e969c2becb7e24/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a", size = 146123 }, { url = "https://files.pythonhosted.org/packages/a9/ac/ab729a15c516da2ab70a05f8722ecfccc3f04ed7a18e45c75bbbaa347d61/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a", size = 147943 }, { url = "https://files.pythonhosted.org/packages/03/d2/3f392f23f042615689456e9a274640c1d2e5dd1d52de36ab8f7955f8f050/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247", size = 142063 }, { url = "https://files.pythonhosted.org/packages/f2/e3/e20aae5e1039a2cd9b08d9205f52142329f887f8cf70da3650326670bddf/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408", size = 150578 }, { url = "https://files.pythonhosted.org/packages/8d/af/779ad72a4da0aed925e1139d458adc486e61076d7ecdcc09e610ea8678db/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb", size = 153629 }, { url = "https://files.pythonhosted.org/packages/c2/b6/7aa450b278e7aa92cf7732140bfd8be21f5f29d5bf334ae987c945276639/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d", size = 150778 }, { url = "https://files.pythonhosted.org/packages/39/f4/d9f4f712d0951dcbfd42920d3db81b00dd23b6ab520419626f4023334056/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807", size = 146453 }, { url = "https://files.pythonhosted.org/packages/49/2b/999d0314e4ee0cff3cb83e6bc9aeddd397eeed693edb4facb901eb8fbb69/charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f", size = 95479 }, { url = "https://files.pythonhosted.org/packages/2d/ce/3cbed41cff67e455a386fb5e5dd8906cdda2ed92fbc6297921f2e4419309/charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f", size = 102790 }, { url = "https://files.pythonhosted.org/packages/72/80/41ef5d5a7935d2d3a773e3eaebf0a9350542f2cab4eac59a7a4741fbbbbe/charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125", size = 194995 }, { url = "https://files.pythonhosted.org/packages/7a/28/0b9fefa7b8b080ec492110af6d88aa3dea91c464b17d53474b6e9ba5d2c5/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1", size = 139471 }, { url = "https://files.pythonhosted.org/packages/71/64/d24ab1a997efb06402e3fc07317e94da358e2585165930d9d59ad45fcae2/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3", size = 149831 }, { url = "https://files.pythonhosted.org/packages/37/ed/be39e5258e198655240db5e19e0b11379163ad7070962d6b0c87ed2c4d39/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd", size = 142335 }, { url = "https://files.pythonhosted.org/packages/88/83/489e9504711fa05d8dde1574996408026bdbdbd938f23be67deebb5eca92/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00", size = 143862 }, { url = "https://files.pythonhosted.org/packages/c6/c7/32da20821cf387b759ad24627a9aca289d2822de929b8a41b6241767b461/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12", size = 145673 }, { url = "https://files.pythonhosted.org/packages/68/85/f4288e96039abdd5aeb5c546fa20a37b50da71b5cf01e75e87f16cd43304/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77", size = 140211 }, { url = "https://files.pythonhosted.org/packages/28/a3/a42e70d03cbdabc18997baf4f0227c73591a08041c149e710045c281f97b/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146", size = 148039 }, { url = "https://files.pythonhosted.org/packages/85/e4/65699e8ab3014ecbe6f5c71d1a55d810fb716bbfd74f6283d5c2aa87febf/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd", size = 151939 }, { url = "https://files.pythonhosted.org/packages/b1/82/8e9fe624cc5374193de6860aba3ea8070f584c8565ee77c168ec13274bd2/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6", size = 149075 }, { url = "https://files.pythonhosted.org/packages/3d/7b/82865ba54c765560c8433f65e8acb9217cb839a9e32b42af4aa8e945870f/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8", size = 144340 }, { url = "https://files.pythonhosted.org/packages/b5/b6/9674a4b7d4d99a0d2df9b215da766ee682718f88055751e1e5e753c82db0/charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b", size = 95205 }, { url = "https://files.pythonhosted.org/packages/1e/ab/45b180e175de4402dcf7547e4fb617283bae54ce35c27930a6f35b6bef15/charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76", size = 102441 }, { url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 }, { url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 }, { url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 }, { url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 }, { url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 }, { url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 }, { url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 }, { url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 }, { url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 }, { url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 }, { url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 }, { url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 }, { url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 }, { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 }, { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 }, { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 }, { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 }, { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 }, { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 }, { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 }, { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 }, { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 }, { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 }, { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 }, { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 }, { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 }, { url = "https://files.pythonhosted.org/packages/7f/c0/b913f8f02836ed9ab32ea643c6fe4d3325c3d8627cf6e78098671cafff86/charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41", size = 197867 }, { url = "https://files.pythonhosted.org/packages/0f/6c/2bee440303d705b6fb1e2ec789543edec83d32d258299b16eed28aad48e0/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f", size = 141385 }, { url = "https://files.pythonhosted.org/packages/3d/04/cb42585f07f6f9fd3219ffb6f37d5a39b4fd2db2355b23683060029c35f7/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2", size = 151367 }, { url = "https://files.pythonhosted.org/packages/54/54/2412a5b093acb17f0222de007cc129ec0e0df198b5ad2ce5699355269dfe/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770", size = 143928 }, { url = "https://files.pythonhosted.org/packages/5a/6d/e2773862b043dcf8a221342954f375392bb2ce6487bcd9f2c1b34e1d6781/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4", size = 146203 }, { url = "https://files.pythonhosted.org/packages/b9/f8/ca440ef60d8f8916022859885f231abb07ada3c347c03d63f283bec32ef5/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537", size = 148082 }, { url = "https://files.pythonhosted.org/packages/04/d2/42fd330901aaa4b805a1097856c2edf5095e260a597f65def493f4b8c833/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496", size = 142053 }, { url = "https://files.pythonhosted.org/packages/9e/af/3a97a4fa3c53586f1910dadfc916e9c4f35eeada36de4108f5096cb7215f/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78", size = 150625 }, { url = "https://files.pythonhosted.org/packages/26/ae/23d6041322a3556e4da139663d02fb1b3c59a23ab2e2b56432bd2ad63ded/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7", size = 153549 }, { url = "https://files.pythonhosted.org/packages/94/22/b8f2081c6a77cb20d97e57e0b385b481887aa08019d2459dc2858ed64871/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6", size = 150945 }, { url = "https://files.pythonhosted.org/packages/c7/0b/c5ec5092747f801b8b093cdf5610e732b809d6cb11f4c51e35fc28d1d389/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294", size = 146595 }, { url = "https://files.pythonhosted.org/packages/0c/5a/0b59704c38470df6768aa154cc87b1ac7c9bb687990a1559dc8765e8627e/charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5", size = 95453 }, { url = "https://files.pythonhosted.org/packages/85/2d/a9790237cb4d01a6d57afadc8573c8b73c609ade20b80f4cda30802009ee/charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765", size = 102811 }, { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 }, ] [[package]] name = "click" version = "8.1.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "platform_system == 'Windows'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } wheels = [ { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] [[package]] name = "coverage" version = "7.6.12" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/0c/d6/2b53ab3ee99f2262e6f0b8369a43f6d66658eab45510331c0b3d5c8c4272/coverage-7.6.12.tar.gz", hash = "sha256:48cfc4641d95d34766ad41d9573cc0f22a48aa88d22657a1fe01dca0dbae4de2", size = 805941 } wheels = [ { url = "https://files.pythonhosted.org/packages/ba/67/81dc41ec8f548c365d04a29f1afd492d3176b372c33e47fa2a45a01dc13a/coverage-7.6.12-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:704c8c8c6ce6569286ae9622e534b4f5b9759b6f2cd643f1c1a61f666d534fe8", size = 208345 }, { url = "https://files.pythonhosted.org/packages/33/43/17f71676016c8829bde69e24c852fef6bd9ed39f774a245d9ec98f689fa0/coverage-7.6.12-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ad7525bf0241e5502168ae9c643a2f6c219fa0a283001cee4cf23a9b7da75879", size = 208775 }, { url = "https://files.pythonhosted.org/packages/86/25/c6ff0775f8960e8c0840845b723eed978d22a3cd9babd2b996e4a7c502c6/coverage-7.6.12-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06097c7abfa611c91edb9e6920264e5be1d6ceb374efb4986f38b09eed4cb2fe", size = 237925 }, { url = "https://files.pythonhosted.org/packages/b0/3d/5f5bd37046243cb9d15fff2c69e498c2f4fe4f9b42a96018d4579ed3506f/coverage-7.6.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:220fa6c0ad7d9caef57f2c8771918324563ef0d8272c94974717c3909664e674", size = 235835 }, { url = "https://files.pythonhosted.org/packages/b5/f1/9e6b75531fe33490b910d251b0bf709142e73a40e4e38a3899e6986fe088/coverage-7.6.12-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3688b99604a24492bcfe1c106278c45586eb819bf66a654d8a9a1433022fb2eb", size = 236966 }, { url = "https://files.pythonhosted.org/packages/4f/bc/aef5a98f9133851bd1aacf130e754063719345d2fb776a117d5a8d516971/coverage-7.6.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d1a987778b9c71da2fc8948e6f2656da6ef68f59298b7e9786849634c35d2c3c", size = 236080 }, { url = "https://files.pythonhosted.org/packages/eb/d0/56b4ab77f9b12aea4d4c11dc11cdcaa7c29130b837eb610639cf3400c9c3/coverage-7.6.12-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:cec6b9ce3bd2b7853d4a4563801292bfee40b030c05a3d29555fd2a8ee9bd68c", size = 234393 }, { url = "https://files.pythonhosted.org/packages/0d/77/28ef95c5d23fe3dd191a0b7d89c82fea2c2d904aef9315daf7c890e96557/coverage-7.6.12-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ace9048de91293e467b44bce0f0381345078389814ff6e18dbac8fdbf896360e", size = 235536 }, { url = "https://files.pythonhosted.org/packages/29/62/18791d3632ee3ff3f95bc8599115707d05229c72db9539f208bb878a3d88/coverage-7.6.12-cp310-cp310-win32.whl", hash = "sha256:ea31689f05043d520113e0552f039603c4dd71fa4c287b64cb3606140c66f425", size = 211063 }, { url = "https://files.pythonhosted.org/packages/fc/57/b3878006cedfd573c963e5c751b8587154eb10a61cc0f47a84f85c88a355/coverage-7.6.12-cp310-cp310-win_amd64.whl", hash = "sha256:676f92141e3c5492d2a1596d52287d0d963df21bf5e55c8b03075a60e1ddf8aa", size = 211955 }, { url = "https://files.pythonhosted.org/packages/64/2d/da78abbfff98468c91fd63a73cccdfa0e99051676ded8dd36123e3a2d4d5/coverage-7.6.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e18aafdfb3e9ec0d261c942d35bd7c28d031c5855dadb491d2723ba54f4c3015", size = 208464 }, { url = "https://files.pythonhosted.org/packages/31/f2/c269f46c470bdabe83a69e860c80a82e5e76840e9f4bbd7f38f8cebbee2f/coverage-7.6.12-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66fe626fd7aa5982cdebad23e49e78ef7dbb3e3c2a5960a2b53632f1f703ea45", size = 208893 }, { url = "https://files.pythonhosted.org/packages/47/63/5682bf14d2ce20819998a49c0deadb81e608a59eed64d6bc2191bc8046b9/coverage-7.6.12-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ef01d70198431719af0b1f5dcbefc557d44a190e749004042927b2a3fed0702", size = 241545 }, { url = "https://files.pythonhosted.org/packages/6a/b6/6b6631f1172d437e11067e1c2edfdb7238b65dff965a12bce3b6d1bf2be2/coverage-7.6.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e92ae5a289a4bc4c0aae710c0948d3c7892e20fd3588224ebe242039573bf0", size = 239230 }, { url = "https://files.pythonhosted.org/packages/c7/01/9cd06cbb1be53e837e16f1b4309f6357e2dfcbdab0dd7cd3b1a50589e4e1/coverage-7.6.12-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e695df2c58ce526eeab11a2e915448d3eb76f75dffe338ea613c1201b33bab2f", size = 241013 }, { url = "https://files.pythonhosted.org/packages/4b/26/56afefc03c30871326e3d99709a70d327ac1f33da383cba108c79bd71563/coverage-7.6.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d74c08e9aaef995f8c4ef6d202dbd219c318450fe2a76da624f2ebb9c8ec5d9f", size = 239750 }, { url = "https://files.pythonhosted.org/packages/dd/ea/88a1ff951ed288f56aa561558ebe380107cf9132facd0b50bced63ba7238/coverage-7.6.12-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e995b3b76ccedc27fe4f477b349b7d64597e53a43fc2961db9d3fbace085d69d", size = 238462 }, { url = "https://files.pythonhosted.org/packages/6e/d4/1d9404566f553728889409eff82151d515fbb46dc92cbd13b5337fa0de8c/coverage-7.6.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b1f097878d74fe51e1ddd1be62d8e3682748875b461232cf4b52ddc6e6db0bba", size = 239307 }, { url = "https://files.pythonhosted.org/packages/12/c1/e453d3b794cde1e232ee8ac1d194fde8e2ba329c18bbf1b93f6f5eef606b/coverage-7.6.12-cp311-cp311-win32.whl", hash = "sha256:1f7ffa05da41754e20512202c866d0ebfc440bba3b0ed15133070e20bf5aeb5f", size = 211117 }, { url = "https://files.pythonhosted.org/packages/d5/db/829185120c1686fa297294f8fcd23e0422f71070bf85ef1cc1a72ecb2930/coverage-7.6.12-cp311-cp311-win_amd64.whl", hash = "sha256:e216c5c45f89ef8971373fd1c5d8d1164b81f7f5f06bbf23c37e7908d19e8558", size = 212019 }, { url = "https://files.pythonhosted.org/packages/e2/7f/4af2ed1d06ce6bee7eafc03b2ef748b14132b0bdae04388e451e4b2c529b/coverage-7.6.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b172f8e030e8ef247b3104902cc671e20df80163b60a203653150d2fc204d1ad", size = 208645 }, { url = "https://files.pythonhosted.org/packages/dc/60/d19df912989117caa95123524d26fc973f56dc14aecdec5ccd7d0084e131/coverage-7.6.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:641dfe0ab73deb7069fb972d4d9725bf11c239c309ce694dd50b1473c0f641c3", size = 208898 }, { url = "https://files.pythonhosted.org/packages/bd/10/fecabcf438ba676f706bf90186ccf6ff9f6158cc494286965c76e58742fa/coverage-7.6.12-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e549f54ac5f301e8e04c569dfdb907f7be71b06b88b5063ce9d6953d2d58574", size = 242987 }, { url = "https://files.pythonhosted.org/packages/4c/53/4e208440389e8ea936f5f2b0762dcd4cb03281a7722def8e2bf9dc9c3d68/coverage-7.6.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:959244a17184515f8c52dcb65fb662808767c0bd233c1d8a166e7cf74c9ea985", size = 239881 }, { url = "https://files.pythonhosted.org/packages/c4/47/2ba744af8d2f0caa1f17e7746147e34dfc5f811fb65fc153153722d58835/coverage-7.6.12-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bda1c5f347550c359f841d6614fb8ca42ae5cb0b74d39f8a1e204815ebe25750", size = 242142 }, { url = "https://files.pythonhosted.org/packages/e9/90/df726af8ee74d92ee7e3bf113bf101ea4315d71508952bd21abc3fae471e/coverage-7.6.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1ceeb90c3eda1f2d8c4c578c14167dbd8c674ecd7d38e45647543f19839dd6ea", size = 241437 }, { url = "https://files.pythonhosted.org/packages/f6/af/995263fd04ae5f9cf12521150295bf03b6ba940d0aea97953bb4a6db3e2b/coverage-7.6.12-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f16f44025c06792e0fb09571ae454bcc7a3ec75eeb3c36b025eccf501b1a4c3", size = 239724 }, { url = "https://files.pythonhosted.org/packages/1c/8e/5bb04f0318805e190984c6ce106b4c3968a9562a400180e549855d8211bd/coverage-7.6.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b076e625396e787448d27a411aefff867db2bffac8ed04e8f7056b07024eed5a", size = 241329 }, { url = "https://files.pythonhosted.org/packages/9e/9d/fa04d9e6c3f6459f4e0b231925277cfc33d72dfab7fa19c312c03e59da99/coverage-7.6.12-cp312-cp312-win32.whl", hash = "sha256:00b2086892cf06c7c2d74983c9595dc511acca00665480b3ddff749ec4fb2a95", size = 211289 }, { url = "https://files.pythonhosted.org/packages/53/40/53c7ffe3c0c3fff4d708bc99e65f3d78c129110d6629736faf2dbd60ad57/coverage-7.6.12-cp312-cp312-win_amd64.whl", hash = "sha256:7ae6eabf519bc7871ce117fb18bf14e0e343eeb96c377667e3e5dd12095e0288", size = 212079 }, { url = "https://files.pythonhosted.org/packages/76/89/1adf3e634753c0de3dad2f02aac1e73dba58bc5a3a914ac94a25b2ef418f/coverage-7.6.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:488c27b3db0ebee97a830e6b5a3ea930c4a6e2c07f27a5e67e1b3532e76b9ef1", size = 208673 }, { url = "https://files.pythonhosted.org/packages/ce/64/92a4e239d64d798535c5b45baac6b891c205a8a2e7c9cc8590ad386693dc/coverage-7.6.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d1095bbee1851269f79fd8e0c9b5544e4c00c0c24965e66d8cba2eb5bb535fd", size = 208945 }, { url = "https://files.pythonhosted.org/packages/b4/d0/4596a3ef3bca20a94539c9b1e10fd250225d1dec57ea78b0867a1cf9742e/coverage-7.6.12-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0533adc29adf6a69c1baa88c3d7dbcaadcffa21afbed3ca7a225a440e4744bf9", size = 242484 }, { url = "https://files.pythonhosted.org/packages/1c/ef/6fd0d344695af6718a38d0861408af48a709327335486a7ad7e85936dc6e/coverage-7.6.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53c56358d470fa507a2b6e67a68fd002364d23c83741dbc4c2e0680d80ca227e", size = 239525 }, { url = "https://files.pythonhosted.org/packages/0c/4b/373be2be7dd42f2bcd6964059fd8fa307d265a29d2b9bcf1d044bcc156ed/coverage-7.6.12-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64cbb1a3027c79ca6310bf101014614f6e6e18c226474606cf725238cf5bc2d4", size = 241545 }, { url = "https://files.pythonhosted.org/packages/a6/7d/0e83cc2673a7790650851ee92f72a343827ecaaea07960587c8f442b5cd3/coverage-7.6.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79cac3390bfa9836bb795be377395f28410811c9066bc4eefd8015258a7578c6", size = 241179 }, { url = "https://files.pythonhosted.org/packages/ff/8c/566ea92ce2bb7627b0900124e24a99f9244b6c8c92d09ff9f7633eb7c3c8/coverage-7.6.12-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9b148068e881faa26d878ff63e79650e208e95cf1c22bd3f77c3ca7b1d9821a3", size = 239288 }, { url = "https://files.pythonhosted.org/packages/7d/e4/869a138e50b622f796782d642c15fb5f25a5870c6d0059a663667a201638/coverage-7.6.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8bec2ac5da793c2685ce5319ca9bcf4eee683b8a1679051f8e6ec04c4f2fd7dc", size = 241032 }, { url = "https://files.pythonhosted.org/packages/ae/28/a52ff5d62a9f9e9fe9c4f17759b98632edd3a3489fce70154c7d66054dd3/coverage-7.6.12-cp313-cp313-win32.whl", hash = "sha256:200e10beb6ddd7c3ded322a4186313d5ca9e63e33d8fab4faa67ef46d3460af3", size = 211315 }, { url = "https://files.pythonhosted.org/packages/bc/17/ab849b7429a639f9722fa5628364c28d675c7ff37ebc3268fe9840dda13c/coverage-7.6.12-cp313-cp313-win_amd64.whl", hash = "sha256:2b996819ced9f7dbb812c701485d58f261bef08f9b85304d41219b1496b591ef", size = 212099 }, { url = "https://files.pythonhosted.org/packages/d2/1c/b9965bf23e171d98505eb5eb4fb4d05c44efd256f2e0f19ad1ba8c3f54b0/coverage-7.6.12-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:299cf973a7abff87a30609879c10df0b3bfc33d021e1adabc29138a48888841e", size = 209511 }, { url = "https://files.pythonhosted.org/packages/57/b3/119c201d3b692d5e17784fee876a9a78e1b3051327de2709392962877ca8/coverage-7.6.12-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4b467a8c56974bf06e543e69ad803c6865249d7a5ccf6980457ed2bc50312703", size = 209729 }, { url = "https://files.pythonhosted.org/packages/52/4e/a7feb5a56b266304bc59f872ea07b728e14d5a64f1ad3a2cc01a3259c965/coverage-7.6.12-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2458f275944db8129f95d91aee32c828a408481ecde3b30af31d552c2ce284a0", size = 253988 }, { url = "https://files.pythonhosted.org/packages/65/19/069fec4d6908d0dae98126aa7ad08ce5130a6decc8509da7740d36e8e8d2/coverage-7.6.12-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a9d8be07fb0832636a0f72b80d2a652fe665e80e720301fb22b191c3434d924", size = 249697 }, { url = "https://files.pythonhosted.org/packages/1c/da/5b19f09ba39df7c55f77820736bf17bbe2416bbf5216a3100ac019e15839/coverage-7.6.12-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14d47376a4f445e9743f6c83291e60adb1b127607a3618e3185bbc8091f0467b", size = 252033 }, { url = "https://files.pythonhosted.org/packages/1e/89/4c2750df7f80a7872267f7c5fe497c69d45f688f7b3afe1297e52e33f791/coverage-7.6.12-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b95574d06aa9d2bd6e5cc35a5bbe35696342c96760b69dc4287dbd5abd4ad51d", size = 251535 }, { url = "https://files.pythonhosted.org/packages/78/3b/6d3ae3c1cc05f1b0460c51e6f6dcf567598cbd7c6121e5ad06643974703c/coverage-7.6.12-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:ecea0c38c9079570163d663c0433a9af4094a60aafdca491c6a3d248c7432827", size = 249192 }, { url = "https://files.pythonhosted.org/packages/6e/8e/c14a79f535ce41af7d436bbad0d3d90c43d9e38ec409b4770c894031422e/coverage-7.6.12-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2251fabcfee0a55a8578a9d29cecfee5f2de02f11530e7d5c5a05859aa85aee9", size = 250627 }, { url = "https://files.pythonhosted.org/packages/cb/79/b7cee656cfb17a7f2c1b9c3cee03dd5d8000ca299ad4038ba64b61a9b044/coverage-7.6.12-cp313-cp313t-win32.whl", hash = "sha256:eb5507795caabd9b2ae3f1adc95f67b1104971c22c624bb354232d65c4fc90b3", size = 212033 }, { url = "https://files.pythonhosted.org/packages/b6/c3/f7aaa3813f1fa9a4228175a7bd368199659d392897e184435a3b66408dd3/coverage-7.6.12-cp313-cp313t-win_amd64.whl", hash = "sha256:f60a297c3987c6c02ffb29effc70eadcbb412fe76947d394a1091a3615948e2f", size = 213240 }, { url = "https://files.pythonhosted.org/packages/6c/eb/cf062b1c3dbdcafd64a2a154beea2e4aa8e9886c34e41f53fa04925c8b35/coverage-7.6.12-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e7575ab65ca8399c8c4f9a7d61bbd2d204c8b8e447aab9d355682205c9dd948d", size = 208343 }, { url = "https://files.pythonhosted.org/packages/95/42/4ebad0ab065228e29869a060644712ab1b0821d8c29bfefa20c2118c9e19/coverage-7.6.12-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8161d9fbc7e9fe2326de89cd0abb9f3599bccc1287db0aba285cb68d204ce929", size = 208769 }, { url = "https://files.pythonhosted.org/packages/44/9f/421e84f7f9455eca85ff85546f26cbc144034bb2587e08bfc214dd6e9c8f/coverage-7.6.12-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a1e465f398c713f1b212400b4e79a09829cd42aebd360362cd89c5bdc44eb87", size = 237553 }, { url = "https://files.pythonhosted.org/packages/c9/c4/a2c4f274bcb711ed5db2ccc1b851ca1c45f35ed6077aec9d6c61845d80e3/coverage-7.6.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f25d8b92a4e31ff1bd873654ec367ae811b3a943583e05432ea29264782dc32c", size = 235473 }, { url = "https://files.pythonhosted.org/packages/e0/10/a3d317e38e5627b06debe861d6c511b1611dd9dc0e2a47afbe6257ffd341/coverage-7.6.12-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a936309a65cc5ca80fa9f20a442ff9e2d06927ec9a4f54bcba9c14c066323f2", size = 236575 }, { url = "https://files.pythonhosted.org/packages/4d/49/51cd991b56257d2e07e3d5cb053411e9de5b0f4e98047167ec05e4e19b55/coverage-7.6.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:aa6f302a3a0b5f240ee201297fff0bbfe2fa0d415a94aeb257d8b461032389bd", size = 235690 }, { url = "https://files.pythonhosted.org/packages/f7/87/631e5883fe0a80683a1f20dadbd0f99b79e17a9d8ea9aff3a9b4cfe50b93/coverage-7.6.12-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f973643ef532d4f9be71dd88cf7588936685fdb576d93a79fe9f65bc337d9d73", size = 234040 }, { url = "https://files.pythonhosted.org/packages/7c/34/edd03f6933f766ec97dddd178a7295855f8207bb708dbac03777107ace5b/coverage-7.6.12-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:78f5243bb6b1060aed6213d5107744c19f9571ec76d54c99cc15938eb69e0e86", size = 235048 }, { url = "https://files.pythonhosted.org/packages/ee/1e/d45045b7d3012fe518c617a57b9f9396cdaebe6455f1b404858b32c38cdd/coverage-7.6.12-cp39-cp39-win32.whl", hash = "sha256:69e62c5034291c845fc4df7f8155e8544178b6c774f97a99e2734b05eb5bed31", size = 211085 }, { url = "https://files.pythonhosted.org/packages/df/ea/086cb06af14a84fe773b86aa140892006a906c5ec947e609ceb6a93f6257/coverage-7.6.12-cp39-cp39-win_amd64.whl", hash = "sha256:b01a840ecc25dce235ae4c1b6a0daefb2a203dba0e6e980637ee9c2f6ee0df57", size = 211965 }, { url = "https://files.pythonhosted.org/packages/7a/7f/05818c62c7afe75df11e0233bd670948d68b36cdbf2a339a095bc02624a8/coverage-7.6.12-pp39.pp310-none-any.whl", hash = "sha256:7e39e845c4d764208e7b8f6a21c541ade741e2c41afabdfa1caa28687a3c98cf", size = 200558 }, { url = "https://files.pythonhosted.org/packages/fb/b2/f655700e1024dec98b10ebaafd0cedbc25e40e4abe62a3c8e2ceef4f8f0a/coverage-7.6.12-py3-none-any.whl", hash = "sha256:eb8668cfbc279a536c633137deeb9435d2962caec279c3f8cf8b91fff6ff8953", size = 200552 }, ] [package.optional-dependencies] toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] [[package]] name = "cryptography" version = "44.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/25/4ce80c78963834b8a9fd1cc1266be5ed8d1840785c0f2e1b73b8d128d505/cryptography-44.0.2.tar.gz", hash = "sha256:c63454aa261a0cf0c5b4718349629793e9e634993538db841165b3df74f37ec0", size = 710807 } wheels = [ { url = "https://files.pythonhosted.org/packages/30/ec/7ea7c1e4c8fc8329506b46c6c4a52e2f20318425d48e0fe597977c71dbce/cryptography-44.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29ecec49f3ba3f3849362854b7253a9f59799e3763b0c9d0826259a88efa02f1", size = 3952350 }, { url = "https://files.pythonhosted.org/packages/27/61/72e3afdb3c5ac510330feba4fc1faa0fe62e070592d6ad00c40bb69165e5/cryptography-44.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc821e161ae88bfe8088d11bb39caf2916562e0a2dc7b6d56714a48b784ef0bb", size = 4166572 }, { url = "https://files.pythonhosted.org/packages/26/e4/ba680f0b35ed4a07d87f9e98f3ebccb05091f3bf6b5a478b943253b3bbd5/cryptography-44.0.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3c00b6b757b32ce0f62c574b78b939afab9eecaf597c4d624caca4f9e71e7843", size = 3958124 }, { url = "https://files.pythonhosted.org/packages/9c/e8/44ae3e68c8b6d1cbc59040288056df2ad7f7f03bbcaca6b503c737ab8e73/cryptography-44.0.2-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7bdcd82189759aba3816d1f729ce42ffded1ac304c151d0a8e89b9996ab863d5", size = 3678122 }, { url = "https://files.pythonhosted.org/packages/27/7b/664ea5e0d1eab511a10e480baf1c5d3e681c7d91718f60e149cec09edf01/cryptography-44.0.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4973da6ca3db4405c54cd0b26d328be54c7747e89e284fcff166132eb7bccc9c", size = 4191831 }, { url = "https://files.pythonhosted.org/packages/2a/07/79554a9c40eb11345e1861f46f845fa71c9e25bf66d132e123d9feb8e7f9/cryptography-44.0.2-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4e389622b6927d8133f314949a9812972711a111d577a5d1f4bee5e58736b80a", size = 3960583 }, { url = "https://files.pythonhosted.org/packages/bb/6d/858e356a49a4f0b591bd6789d821427de18432212e137290b6d8a817e9bf/cryptography-44.0.2-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f514ef4cd14bb6fb484b4a60203e912cfcb64f2ab139e88c2274511514bf7308", size = 4191753 }, { url = "https://files.pythonhosted.org/packages/b2/80/62df41ba4916067fa6b125aa8c14d7e9181773f0d5d0bd4dcef580d8b7c6/cryptography-44.0.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1bc312dfb7a6e5d66082c87c34c8a62176e684b6fe3d90fcfe1568de675e6688", size = 4079550 }, { url = "https://files.pythonhosted.org/packages/f3/cd/2558cc08f7b1bb40683f99ff4327f8dcfc7de3affc669e9065e14824511b/cryptography-44.0.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b721b8b4d948b218c88cb8c45a01793483821e709afe5f622861fc6182b20a7", size = 4298367 }, { url = "https://files.pythonhosted.org/packages/06/88/638865be7198a84a7713950b1db7343391c6066a20e614f8fa286eb178ed/cryptography-44.0.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81276f0ea79a208d961c433a947029e1a15948966658cf6710bbabb60fcc2639", size = 3951919 }, { url = "https://files.pythonhosted.org/packages/d7/fc/99fe639bcdf58561dfad1faa8a7369d1dc13f20acd78371bb97a01613585/cryptography-44.0.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a1e657c0f4ea2a23304ee3f964db058c9e9e635cc7019c4aa21c330755ef6fd", size = 4167812 }, { url = "https://files.pythonhosted.org/packages/53/7b/aafe60210ec93d5d7f552592a28192e51d3c6b6be449e7fd0a91399b5d07/cryptography-44.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6210c05941994290f3f7f175a4a57dbbb2afd9273657614c506d5976db061181", size = 3958571 }, { url = "https://files.pythonhosted.org/packages/16/32/051f7ce79ad5a6ef5e26a92b37f172ee2d6e1cce09931646eef8de1e9827/cryptography-44.0.2-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1c3572526997b36f245a96a2b1713bf79ce99b271bbcf084beb6b9b075f29ea", size = 3679832 }, { url = "https://files.pythonhosted.org/packages/78/2b/999b2a1e1ba2206f2d3bca267d68f350beb2b048a41ea827e08ce7260098/cryptography-44.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b042d2a275c8cee83a4b7ae30c45a15e6a4baa65a179a0ec2d78ebb90e4f6699", size = 4193719 }, { url = "https://files.pythonhosted.org/packages/72/97/430e56e39a1356e8e8f10f723211a0e256e11895ef1a135f30d7d40f2540/cryptography-44.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d03806036b4f89e3b13b6218fefea8d5312e450935b1a2d55f0524e2ed7c59d9", size = 3960852 }, { url = "https://files.pythonhosted.org/packages/89/33/c1cf182c152e1d262cac56850939530c05ca6c8d149aa0dcee490b417e99/cryptography-44.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c7362add18b416b69d58c910caa217f980c5ef39b23a38a0880dfd87bdf8cd23", size = 4193906 }, { url = "https://files.pythonhosted.org/packages/e1/99/87cf26d4f125380dc674233971069bc28d19b07f7755b29861570e513650/cryptography-44.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8cadc6e3b5a1f144a039ea08a0bdb03a2a92e19c46be3285123d32029f40a922", size = 4081572 }, { url = "https://files.pythonhosted.org/packages/b3/9f/6a3e0391957cc0c5f84aef9fbdd763035f2b52e998a53f99345e3ac69312/cryptography-44.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f101b1f780f7fc613d040ca4bdf835c6ef3b00e9bd7125a4255ec574c7916e4", size = 4298631 }, { url = "https://files.pythonhosted.org/packages/2f/b4/424ea2d0fce08c24ede307cead3409ecbfc2f566725d4701b9754c0a1174/cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:0529b1d5a0105dd3731fa65680b45ce49da4d8115ea76e9da77a875396727b41", size = 3892387 }, { url = "https://files.pythonhosted.org/packages/28/20/8eaa1a4f7c68a1cb15019dbaad59c812d4df4fac6fd5f7b0b9c5177f1edd/cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:7ca25849404be2f8e4b3c59483d9d3c51298a22c1c61a0e84415104dacaf5562", size = 4109922 }, { url = "https://files.pythonhosted.org/packages/11/25/5ed9a17d532c32b3bc81cc294d21a36c772d053981c22bd678396bc4ae30/cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:268e4e9b177c76d569e8a145a6939eca9a5fec658c932348598818acf31ae9a5", size = 3895715 }, { url = "https://files.pythonhosted.org/packages/63/31/2aac03b19c6329b62c45ba4e091f9de0b8f687e1b0cd84f101401bece343/cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9eb9d22b0a5d8fd9925a7764a054dca914000607dff201a24c791ff5c799e1fa", size = 4109876 }, { url = "https://files.pythonhosted.org/packages/d6/d7/f30e75a6aa7d0f65031886fa4a1485c2fbfe25a1896953920f6a9cfe2d3b/cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:909c97ab43a9c0c0b0ada7a1281430e4e5ec0458e6d9244c0e821bbf152f061d", size = 3887513 }, { url = "https://files.pythonhosted.org/packages/9c/b4/7a494ce1032323ca9db9a3661894c66e0d7142ad2079a4249303402d8c71/cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:96e7a5e9d6e71f9f4fca8eebfd603f8e86c5225bb18eb621b2c1e50b290a9471", size = 4107432 }, { url = "https://files.pythonhosted.org/packages/45/f8/6b3ec0bc56123b344a8d2b3264a325646d2dcdbdd9848b5e6f3d37db90b3/cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d1b3031093a366ac767b3feb8bcddb596671b3aaff82d4050f984da0c248b615", size = 3891421 }, { url = "https://files.pythonhosted.org/packages/57/ff/f3b4b2d007c2a646b0f69440ab06224f9cf37a977a72cdb7b50632174e8a/cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:04abd71114848aa25edb28e225ab5f268096f44cf0127f3d36975bdf1bdf3390", size = 4107081 }, ] [[package]] name = "docstring-to-markdown" version = "0.15" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7a/ad/6a66abd14676619bd56f6b924c96321a2e2d7d86558841d94a30023eec53/docstring-to-markdown-0.15.tar.gz", hash = "sha256:e146114d9c50c181b1d25505054a8d0f7a476837f0da2c19f07e06eaed52b73d", size = 29246 } wheels = [ { url = "https://files.pythonhosted.org/packages/c1/cf/4eee59f6c4111b3e80cc32cf6bac483a90646f5c8693e84496c9855e8e38/docstring_to_markdown-0.15-py3-none-any.whl", hash = "sha256:27afb3faedba81e34c33521c32bbd258d7fbb79eedf7d29bc4e81080e854aec0", size = 21640 }, ] [[package]] name = "docutils" version = "0.21.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444 } wheels = [ { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408 }, ] [[package]] name = "exceptiongroup" version = "1.2.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } wheels = [ { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, ] [[package]] name = "flake8" version = "7.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mccabe" }, { name = "pycodestyle" }, { name = "pyflakes" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/16/3f2a0bb700ad65ac9663262905a025917c020a3f92f014d2ba8964b4602c/flake8-7.1.2.tar.gz", hash = "sha256:c586ffd0b41540951ae41af572e6790dbd49fc12b3aa2541685d253d9bd504bd", size = 48119 } wheels = [ { url = "https://files.pythonhosted.org/packages/35/f8/08d37b2cd89da306e3520bd27f8a85692122b42b56c0c2c3784ff09c022f/flake8-7.1.2-py2.py3-none-any.whl", hash = "sha256:1cbc62e65536f65e6d754dfe6f1bada7f5cf392d6f5db3c2b85892466c3e7c1a", size = 57745 }, ] [[package]] name = "furo" version = "2024.8.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "beautifulsoup4" }, { name = "pygments" }, { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "sphinx-basic-ng" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a0/e2/d351d69a9a9e4badb4a5be062c2d0e87bd9e6c23b5e57337fef14bef34c8/furo-2024.8.6.tar.gz", hash = "sha256:b63e4cee8abfc3136d3bc03a3d45a76a850bada4d6374d24c1716b0e01394a01", size = 1661506 } wheels = [ { url = "https://files.pythonhosted.org/packages/27/48/e791a7ed487dbb9729ef32bb5d1af16693d8925f4366befef54119b2e576/furo-2024.8.6-py3-none-any.whl", hash = "sha256:6cd97c58b47813d3619e63e9081169880fbe331f0ca883c871ff1f3f11814f5c", size = 341333 }, ] [[package]] name = "id" version = "1.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, ] sdist = { url = "https://files.pythonhosted.org/packages/22/11/102da08f88412d875fa2f1a9a469ff7ad4c874b0ca6fed0048fe385bdb3d/id-1.5.0.tar.gz", hash = "sha256:292cb8a49eacbbdbce97244f47a97b4c62540169c976552e497fd57df0734c1d", size = 15237 } wheels = [ { url = "https://files.pythonhosted.org/packages/9f/cb/18326d2d89ad3b0dd143da971e77afd1e6ca6674f1b1c3df4b6bec6279fc/id-1.5.0-py3-none-any.whl", hash = "sha256:f1434e1cef91f2cbb8a4ec64663d5a23b9ed43ef44c4c957d02583d61714c658", size = 13611 }, ] [[package]] name = "idna" version = "3.10" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, ] [[package]] name = "imagesize" version = "1.4.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026 } wheels = [ { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769 }, ] [[package]] name = "importlib-metadata" version = "8.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "zipp" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/08/c1395a292bb23fd03bdf572a1357c5a733d3eecbab877641ceacab23db6e/importlib_metadata-8.6.1.tar.gz", hash = "sha256:310b41d755445d74569f993ccfc22838295d9fe005425094fad953d7f15c8580", size = 55767 } wheels = [ { url = "https://files.pythonhosted.org/packages/79/9d/0fb148dc4d6fa4a7dd1d8378168d9b4cd8d4560a6fbf6f0121c5fc34eb68/importlib_metadata-8.6.1-py3-none-any.whl", hash = "sha256:02a89390c1e15fdfdc0d7c6b25cb3e62650d0494005c97d6f148bf5b9787525e", size = 26971 }, ] [[package]] name = "iniconfig" version = "2.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } wheels = [ { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, ] [[package]] name = "jaraco-classes" version = "3.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "more-itertools" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780 } wheels = [ { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777 }, ] [[package]] name = "jaraco-context" version = "6.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/df/ad/f3777b81bf0b6e7bc7514a1656d3e637b2e8e15fab2ce3235730b3e7a4e6/jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", size = 13912 } wheels = [ { url = "https://files.pythonhosted.org/packages/ff/db/0c52c4cf5e4bd9f5d7135ec7669a3a767af21b3a308e1ed3674881e52b62/jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4", size = 6825 }, ] [[package]] name = "jaraco-functools" version = "4.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "more-itertools" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ab/23/9894b3df5d0a6eb44611c36aec777823fc2e07740dabbd0b810e19594013/jaraco_functools-4.1.0.tar.gz", hash = "sha256:70f7e0e2ae076498e212562325e805204fc092d7b4c17e0e86c959e249701a9d", size = 19159 } wheels = [ { url = "https://files.pythonhosted.org/packages/9f/4f/24b319316142c44283d7540e76c7b5a6dbd5db623abd86bb7b3491c21018/jaraco.functools-4.1.0-py3-none-any.whl", hash = "sha256:ad159f13428bc4acbf5541ad6dec511f91573b90fba04df61dafa2a1231cf649", size = 10187 }, ] [[package]] name = "jedi" version = "0.19.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "parso" }, ] sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287 } wheels = [ { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278 }, ] [[package]] name = "jedi-language-server" version = "0.44.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cattrs" }, { name = "docstring-to-markdown" }, { name = "jedi" }, { name = "lsprotocol" }, { name = "pygls" }, { name = "typing-extensions", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/51/ca/97ec6c7acc8e7718816283d3140ebd9601781d731be753c8d0eb97fca541/jedi_language_server-0.44.0.tar.gz", hash = "sha256:276536bd00e64e65753d54cd35237d62730daffd65292dc8510d3063ebaefe4d", size = 32564 } wheels = [ { url = "https://files.pythonhosted.org/packages/3b/04/52ec51105a38c6807162c9c48c006b754885574eb52a7ed0bf10369c1a30/jedi_language_server-0.44.0-py3-none-any.whl", hash = "sha256:17619fac5faf7111036c0b01d460c4eb848ce8df1af8208d85c255db34ec2eff", size = 31794 }, ] [[package]] name = "jeepney" version = "0.9.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758 } wheels = [ { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010 }, ] [[package]] name = "jinja2" version = "3.1.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] sdist = { url = "https://files.pythonhosted.org/packages/af/92/b3130cbbf5591acf9ade8708c365f3238046ac7cb8ccba6e81abccb0ccff/jinja2-3.1.5.tar.gz", hash = "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb", size = 244674 } wheels = [ { url = "https://files.pythonhosted.org/packages/bd/0f/2ba5fbcd631e3e88689309dbe978c5769e883e4b84ebfe7da30b43275c5a/jinja2-3.1.5-py3-none-any.whl", hash = "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb", size = 134596 }, ] [[package]] name = "keyring" version = "25.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, { name = "jaraco-classes" }, { name = "jaraco-context" }, { name = "jaraco-functools" }, { name = "jeepney", marker = "sys_platform == 'linux'" }, { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, { name = "secretstorage", marker = "sys_platform == 'linux'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/70/09/d904a6e96f76ff214be59e7aa6ef7190008f52a0ab6689760a98de0bf37d/keyring-25.6.0.tar.gz", hash = "sha256:0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66", size = 62750 } wheels = [ { url = "https://files.pythonhosted.org/packages/d3/32/da7f44bcb1105d3e88a0b74ebdca50c59121d2ddf71c9e34ba47df7f3a56/keyring-25.6.0-py3-none-any.whl", hash = "sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd", size = 39085 }, ] [[package]] name = "lsprotocol" version = "2023.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "cattrs" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9d/f6/6e80484ec078d0b50699ceb1833597b792a6c695f90c645fbaf54b947e6f/lsprotocol-2023.0.1.tar.gz", hash = "sha256:cc5c15130d2403c18b734304339e51242d3018a05c4f7d0f198ad6e0cd21861d", size = 69434 } wheels = [ { url = "https://files.pythonhosted.org/packages/8d/37/2351e48cb3309673492d3a8c59d407b75fb6630e560eb27ecd4da03adc9a/lsprotocol-2023.0.1-py3-none-any.whl", hash = "sha256:c75223c9e4af2f24272b14c6375787438279369236cd568f596d4951052a60f2", size = 70826 }, ] [[package]] name = "markdown-it-py" version = "3.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } wheels = [ { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, ] [[package]] name = "markupsafe" version = "3.0.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 } wheels = [ { url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", size = 14357 }, { url = "https://files.pythonhosted.org/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", size = 12393 }, { url = "https://files.pythonhosted.org/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", size = 21732 }, { url = "https://files.pythonhosted.org/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", size = 20866 }, { url = "https://files.pythonhosted.org/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", size = 20964 }, { url = "https://files.pythonhosted.org/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", size = 21977 }, { url = "https://files.pythonhosted.org/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", size = 21366 }, { url = "https://files.pythonhosted.org/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", size = 21091 }, { url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50", size = 15065 }, { url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", size = 15514 }, { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353 }, { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392 }, { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984 }, { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120 }, { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032 }, { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057 }, { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359 }, { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306 }, { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094 }, { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521 }, { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274 }, { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348 }, { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149 }, { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118 }, { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993 }, { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178 }, { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319 }, { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352 }, { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097 }, { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601 }, { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 }, { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 }, { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 }, { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 }, { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 }, { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 }, { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 }, { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 }, { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 }, { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 }, { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 }, { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 }, { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 }, { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 }, { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 }, { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 }, { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 }, { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 }, { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 }, { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 }, { url = "https://files.pythonhosted.org/packages/a7/ea/9b1530c3fdeeca613faeb0fb5cbcf2389d816072fab72a71b45749ef6062/MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a", size = 14344 }, { url = "https://files.pythonhosted.org/packages/4b/c2/fbdbfe48848e7112ab05e627e718e854d20192b674952d9042ebd8c9e5de/MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff", size = 12389 }, { url = "https://files.pythonhosted.org/packages/f0/25/7a7c6e4dbd4f867d95d94ca15449e91e52856f6ed1905d58ef1de5e211d0/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13", size = 21607 }, { url = "https://files.pythonhosted.org/packages/53/8f/f339c98a178f3c1e545622206b40986a4c3307fe39f70ccd3d9df9a9e425/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144", size = 20728 }, { url = "https://files.pythonhosted.org/packages/1a/03/8496a1a78308456dbd50b23a385c69b41f2e9661c67ea1329849a598a8f9/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29", size = 20826 }, { url = "https://files.pythonhosted.org/packages/e6/cf/0a490a4bd363048c3022f2f475c8c05582179bb179defcee4766fb3dcc18/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0", size = 21843 }, { url = "https://files.pythonhosted.org/packages/19/a3/34187a78613920dfd3cdf68ef6ce5e99c4f3417f035694074beb8848cd77/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0", size = 21219 }, { url = "https://files.pythonhosted.org/packages/17/d8/5811082f85bb88410ad7e452263af048d685669bbbfb7b595e8689152498/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178", size = 20946 }, { url = "https://files.pythonhosted.org/packages/7c/31/bd635fb5989440d9365c5e3c47556cfea121c7803f5034ac843e8f37c2f2/MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f", size = 15063 }, { url = "https://files.pythonhosted.org/packages/b3/73/085399401383ce949f727afec55ec3abd76648d04b9f22e1c0e99cb4bec3/MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a", size = 15506 }, ] [[package]] name = "mccabe" version = "0.7.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658 } wheels = [ { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350 }, ] [[package]] name = "mdurl" version = "0.1.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, ] [[package]] name = "more-itertools" version = "10.6.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/88/3b/7fa1fe835e2e93fd6d7b52b2f95ae810cf5ba133e1845f726f5a992d62c2/more-itertools-10.6.0.tar.gz", hash = "sha256:2cd7fad1009c31cc9fb6a035108509e6547547a7a738374f10bd49a09eb3ee3b", size = 125009 } wheels = [ { url = "https://files.pythonhosted.org/packages/23/62/0fe302c6d1be1c777cab0616e6302478251dfbf9055ad426f5d0def75c89/more_itertools-10.6.0-py3-none-any.whl", hash = "sha256:6eb054cb4b6db1473f6e15fcc676a08e4732548acd47c708f0e179c2c7c01e89", size = 63038 }, ] [[package]] name = "mypy" version = "1.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mypy-extensions" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ce/43/d5e49a86afa64bd3839ea0d5b9c7103487007d728e1293f52525d6d5486a/mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43", size = 3239717 } wheels = [ { url = "https://files.pythonhosted.org/packages/68/f8/65a7ce8d0e09b6329ad0c8d40330d100ea343bd4dd04c4f8ae26462d0a17/mypy-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:979e4e1a006511dacf628e36fadfecbcc0160a8af6ca7dad2f5025529e082c13", size = 10738433 }, { url = "https://files.pythonhosted.org/packages/b4/95/9c0ecb8eacfe048583706249439ff52105b3f552ea9c4024166c03224270/mypy-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c4bb0e1bd29f7d34efcccd71cf733580191e9a264a2202b0239da95984c5b559", size = 9861472 }, { url = "https://files.pythonhosted.org/packages/84/09/9ec95e982e282e20c0d5407bc65031dfd0f0f8ecc66b69538296e06fcbee/mypy-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be68172e9fd9ad8fb876c6389f16d1c1b5f100ffa779f77b1fb2176fcc9ab95b", size = 11611424 }, { url = "https://files.pythonhosted.org/packages/78/13/f7d14e55865036a1e6a0a69580c240f43bc1f37407fe9235c0d4ef25ffb0/mypy-1.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7be1e46525adfa0d97681432ee9fcd61a3964c2446795714699a998d193f1a3", size = 12365450 }, { url = "https://files.pythonhosted.org/packages/48/e1/301a73852d40c241e915ac6d7bcd7fedd47d519246db2d7b86b9d7e7a0cb/mypy-1.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2e2c2e6d3593f6451b18588848e66260ff62ccca522dd231cd4dd59b0160668b", size = 12551765 }, { url = "https://files.pythonhosted.org/packages/77/ba/c37bc323ae5fe7f3f15a28e06ab012cd0b7552886118943e90b15af31195/mypy-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:6983aae8b2f653e098edb77f893f7b6aca69f6cffb19b2cc7443f23cce5f4828", size = 9274701 }, { url = "https://files.pythonhosted.org/packages/03/bc/f6339726c627bd7ca1ce0fa56c9ae2d0144604a319e0e339bdadafbbb599/mypy-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2922d42e16d6de288022e5ca321cd0618b238cfc5570e0263e5ba0a77dbef56f", size = 10662338 }, { url = "https://files.pythonhosted.org/packages/e2/90/8dcf506ca1a09b0d17555cc00cd69aee402c203911410136cd716559efe7/mypy-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2ee2d57e01a7c35de00f4634ba1bbf015185b219e4dc5909e281016df43f5ee5", size = 9787540 }, { url = "https://files.pythonhosted.org/packages/05/05/a10f9479681e5da09ef2f9426f650d7b550d4bafbef683b69aad1ba87457/mypy-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973500e0774b85d9689715feeffcc980193086551110fd678ebe1f4342fb7c5e", size = 11538051 }, { url = "https://files.pythonhosted.org/packages/e9/9a/1f7d18b30edd57441a6411fcbc0c6869448d1a4bacbaee60656ac0fc29c8/mypy-1.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a95fb17c13e29d2d5195869262f8125dfdb5c134dc8d9a9d0aecf7525b10c2c", size = 12286751 }, { url = "https://files.pythonhosted.org/packages/72/af/19ff499b6f1dafcaf56f9881f7a965ac2f474f69f6f618b5175b044299f5/mypy-1.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1905f494bfd7d85a23a88c5d97840888a7bd516545fc5aaedff0267e0bb54e2f", size = 12421783 }, { url = "https://files.pythonhosted.org/packages/96/39/11b57431a1f686c1aed54bf794870efe0f6aeca11aca281a0bd87a5ad42c/mypy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f", size = 9265618 }, { url = "https://files.pythonhosted.org/packages/98/3a/03c74331c5eb8bd025734e04c9840532226775c47a2c39b56a0c8d4f128d/mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd", size = 10793981 }, { url = "https://files.pythonhosted.org/packages/f0/1a/41759b18f2cfd568848a37c89030aeb03534411eef981df621d8fad08a1d/mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f", size = 9749175 }, { url = "https://files.pythonhosted.org/packages/12/7e/873481abf1ef112c582db832740f4c11b2bfa510e829d6da29b0ab8c3f9c/mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464", size = 11455675 }, { url = "https://files.pythonhosted.org/packages/b3/d0/92ae4cde706923a2d3f2d6c39629134063ff64b9dedca9c1388363da072d/mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee", size = 12410020 }, { url = "https://files.pythonhosted.org/packages/46/8b/df49974b337cce35f828ba6fda228152d6db45fed4c86ba56ffe442434fd/mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e", size = 12498582 }, { url = "https://files.pythonhosted.org/packages/13/50/da5203fcf6c53044a0b699939f31075c45ae8a4cadf538a9069b165c1050/mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22", size = 9366614 }, { url = "https://files.pythonhosted.org/packages/6a/9b/fd2e05d6ffff24d912f150b87db9e364fa8282045c875654ce7e32fffa66/mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445", size = 10788592 }, { url = "https://files.pythonhosted.org/packages/74/37/b246d711c28a03ead1fd906bbc7106659aed7c089d55fe40dd58db812628/mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d", size = 9753611 }, { url = "https://files.pythonhosted.org/packages/a6/ac/395808a92e10cfdac8003c3de9a2ab6dc7cde6c0d2a4df3df1b815ffd067/mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5", size = 11438443 }, { url = "https://files.pythonhosted.org/packages/d2/8b/801aa06445d2de3895f59e476f38f3f8d610ef5d6908245f07d002676cbf/mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036", size = 12402541 }, { url = "https://files.pythonhosted.org/packages/c7/67/5a4268782eb77344cc613a4cf23540928e41f018a9a1ec4c6882baf20ab8/mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357", size = 12494348 }, { url = "https://files.pythonhosted.org/packages/83/3e/57bb447f7bbbfaabf1712d96f9df142624a386d98fb026a761532526057e/mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf", size = 9373648 }, { url = "https://files.pythonhosted.org/packages/5a/fa/79cf41a55b682794abe71372151dbbf856e3008f6767057229e6649d294a/mypy-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e601a7fa172c2131bff456bb3ee08a88360760d0d2f8cbd7a75a65497e2df078", size = 10737129 }, { url = "https://files.pythonhosted.org/packages/d3/33/dd8feb2597d648de29e3da0a8bf4e1afbda472964d2a4a0052203a6f3594/mypy-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:712e962a6357634fef20412699a3655c610110e01cdaa6180acec7fc9f8513ba", size = 9856335 }, { url = "https://files.pythonhosted.org/packages/e4/b5/74508959c1b06b96674b364ffeb7ae5802646b32929b7701fc6b18447592/mypy-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95579473af29ab73a10bada2f9722856792a36ec5af5399b653aa28360290a5", size = 11611935 }, { url = "https://files.pythonhosted.org/packages/6c/53/da61b9d9973efcd6507183fdad96606996191657fe79701b2c818714d573/mypy-1.15.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f8722560a14cde92fdb1e31597760dc35f9f5524cce17836c0d22841830fd5b", size = 12365827 }, { url = "https://files.pythonhosted.org/packages/c1/72/965bd9ee89540c79a25778cc080c7e6ef40aa1eeac4d52cec7eae6eb5228/mypy-1.15.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fbb8da62dc352133d7d7ca90ed2fb0e9d42bb1a32724c287d3c76c58cbaa9c2", size = 12541924 }, { url = "https://files.pythonhosted.org/packages/46/d0/f41645c2eb263e6c77ada7d76f894c580c9ddb20d77f0c24d34273a4dab2/mypy-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:d10d994b41fb3497719bbf866f227b3489048ea4bbbb5015357db306249f7980", size = 9271176 }, { url = "https://files.pythonhosted.org/packages/09/4e/a7d65c7322c510de2c409ff3828b03354a7c43f5a8ed458a7a131b41c7b9/mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e", size = 2221777 }, ] [[package]] name = "mypy-extensions" version = "1.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } wheels = [ { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, ] [[package]] name = "nh3" version = "0.2.21" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/37/30/2f81466f250eb7f591d4d193930df661c8c23e9056bdc78e365b646054d8/nh3-0.2.21.tar.gz", hash = "sha256:4990e7ee6a55490dbf00d61a6f476c9a3258e31e711e13713b2ea7d6616f670e", size = 16581 } wheels = [ { url = "https://files.pythonhosted.org/packages/7f/81/b83775687fcf00e08ade6d4605f0be9c4584cb44c4973d9f27b7456a31c9/nh3-0.2.21-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:fcff321bd60c6c5c9cb4ddf2554e22772bb41ebd93ad88171bbbb6f271255286", size = 1297678 }, { url = "https://files.pythonhosted.org/packages/22/ee/d0ad8fb4b5769f073b2df6807f69a5e57ca9cea504b78809921aef460d20/nh3-0.2.21-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31eedcd7d08b0eae28ba47f43fd33a653b4cdb271d64f1aeda47001618348fde", size = 733774 }, { url = "https://files.pythonhosted.org/packages/ea/76/b450141e2d384ede43fe53953552f1c6741a499a8c20955ad049555cabc8/nh3-0.2.21-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d426d7be1a2f3d896950fe263332ed1662f6c78525b4520c8e9861f8d7f0d243", size = 760012 }, { url = "https://files.pythonhosted.org/packages/97/90/1182275db76cd8fbb1f6bf84c770107fafee0cb7da3e66e416bcb9633da2/nh3-0.2.21-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9d67709bc0d7d1f5797b21db26e7a8b3d15d21c9c5f58ccfe48b5328483b685b", size = 923619 }, { url = "https://files.pythonhosted.org/packages/29/c7/269a7cfbec9693fad8d767c34a755c25ccb8d048fc1dfc7a7d86bc99375c/nh3-0.2.21-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:55823c5ea1f6b267a4fad5de39bc0524d49a47783e1fe094bcf9c537a37df251", size = 1000384 }, { url = "https://files.pythonhosted.org/packages/68/a9/48479dbf5f49ad93f0badd73fbb48b3d769189f04c6c69b0df261978b009/nh3-0.2.21-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:818f2b6df3763e058efa9e69677b5a92f9bc0acff3295af5ed013da544250d5b", size = 918908 }, { url = "https://files.pythonhosted.org/packages/d7/da/0279c118f8be2dc306e56819880b19a1cf2379472e3b79fc8eab44e267e3/nh3-0.2.21-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b3b5c58161e08549904ac4abd450dacd94ff648916f7c376ae4b2c0652b98ff9", size = 909180 }, { url = "https://files.pythonhosted.org/packages/26/16/93309693f8abcb1088ae143a9c8dbcece9c8f7fb297d492d3918340c41f1/nh3-0.2.21-cp313-cp313t-win32.whl", hash = "sha256:637d4a10c834e1b7d9548592c7aad760611415fcd5bd346f77fd8a064309ae6d", size = 532747 }, { url = "https://files.pythonhosted.org/packages/a2/3a/96eb26c56cbb733c0b4a6a907fab8408ddf3ead5d1b065830a8f6a9c3557/nh3-0.2.21-cp313-cp313t-win_amd64.whl", hash = "sha256:713d16686596e556b65e7f8c58328c2df63f1a7abe1277d87625dcbbc012ef82", size = 528908 }, { url = "https://files.pythonhosted.org/packages/ba/1d/b1ef74121fe325a69601270f276021908392081f4953d50b03cbb38b395f/nh3-0.2.21-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a772dec5b7b7325780922dd904709f0f5f3a79fbf756de5291c01370f6df0967", size = 1316133 }, { url = "https://files.pythonhosted.org/packages/b8/f2/2c7f79ce6de55b41e7715f7f59b159fd59f6cdb66223c05b42adaee2b645/nh3-0.2.21-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d002b648592bf3033adfd875a48f09b8ecc000abd7f6a8769ed86b6ccc70c759", size = 758328 }, { url = "https://files.pythonhosted.org/packages/6d/ad/07bd706fcf2b7979c51b83d8b8def28f413b090cf0cb0035ee6b425e9de5/nh3-0.2.21-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2a5174551f95f2836f2ad6a8074560f261cf9740a48437d6151fd2d4d7d617ab", size = 747020 }, { url = "https://files.pythonhosted.org/packages/75/99/06a6ba0b8a0d79c3d35496f19accc58199a1fb2dce5e711a31be7e2c1426/nh3-0.2.21-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:b8d55ea1fc7ae3633d758a92aafa3505cd3cc5a6e40470c9164d54dff6f96d42", size = 944878 }, { url = "https://files.pythonhosted.org/packages/79/d4/dc76f5dc50018cdaf161d436449181557373869aacf38a826885192fc587/nh3-0.2.21-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ae319f17cd8960d0612f0f0ddff5a90700fa71926ca800e9028e7851ce44a6f", size = 903460 }, { url = "https://files.pythonhosted.org/packages/cd/c3/d4f8037b2ab02ebf5a2e8637bd54736ed3d0e6a2869e10341f8d9085f00e/nh3-0.2.21-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ca02ac6f27fc80f9894409eb61de2cb20ef0a23740c7e29f9ec827139fa578", size = 839369 }, { url = "https://files.pythonhosted.org/packages/11/a9/1cd3c6964ec51daed7b01ca4686a5c793581bf4492cbd7274b3f544c9abe/nh3-0.2.21-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5f77e62aed5c4acad635239ac1290404c7e940c81abe561fd2af011ff59f585", size = 739036 }, { url = "https://files.pythonhosted.org/packages/fd/04/bfb3ff08d17a8a96325010ae6c53ba41de6248e63cdb1b88ef6369a6cdfc/nh3-0.2.21-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:087ffadfdcd497658c3adc797258ce0f06be8a537786a7217649fc1c0c60c293", size = 768712 }, { url = "https://files.pythonhosted.org/packages/9e/aa/cfc0bf545d668b97d9adea4f8b4598667d2b21b725d83396c343ad12bba7/nh3-0.2.21-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ac7006c3abd097790e611fe4646ecb19a8d7f2184b882f6093293b8d9b887431", size = 930559 }, { url = "https://files.pythonhosted.org/packages/78/9d/6f5369a801d3a1b02e6a9a097d56bcc2f6ef98cffebf03c4bb3850d8e0f0/nh3-0.2.21-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:6141caabe00bbddc869665b35fc56a478eb774a8c1dfd6fba9fe1dfdf29e6efa", size = 1008591 }, { url = "https://files.pythonhosted.org/packages/a6/df/01b05299f68c69e480edff608248313cbb5dbd7595c5e048abe8972a57f9/nh3-0.2.21-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:20979783526641c81d2f5bfa6ca5ccca3d1e4472474b162c6256745fbfe31cd1", size = 925670 }, { url = "https://files.pythonhosted.org/packages/3d/79/bdba276f58d15386a3387fe8d54e980fb47557c915f5448d8c6ac6f7ea9b/nh3-0.2.21-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a7ea28cd49293749d67e4fcf326c554c83ec912cd09cd94aa7ec3ab1921c8283", size = 917093 }, { url = "https://files.pythonhosted.org/packages/e7/d8/c6f977a5cd4011c914fb58f5ae573b071d736187ccab31bfb1d539f4af9f/nh3-0.2.21-cp38-abi3-win32.whl", hash = "sha256:6c9c30b8b0d291a7c5ab0967ab200598ba33208f754f2f4920e9343bdd88f79a", size = 537623 }, { url = "https://files.pythonhosted.org/packages/23/fc/8ce756c032c70ae3dd1d48a3552577a325475af2a2f629604b44f571165c/nh3-0.2.21-cp38-abi3-win_amd64.whl", hash = "sha256:bb0014948f04d7976aabae43fcd4cb7f551f9f8ce785a4c9ef66e6c2590f8629", size = 535283 }, ] [[package]] name = "packaging" version = "24.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } wheels = [ { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, ] [[package]] name = "parso" version = "0.8.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/66/94/68e2e17afaa9169cf6412ab0f28623903be73d1b32e208d9e8e541bb086d/parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d", size = 400609 } wheels = [ { url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650 }, ] [[package]] name = "pathspec" version = "0.12.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043 } wheels = [ { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 }, ] [[package]] name = "platformdirs" version = "4.3.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b6/2d/7d512a3913d60623e7eb945c6d1b4f0bddf1d0b7ada5225274c87e5b53d1/platformdirs-4.3.7.tar.gz", hash = "sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351", size = 21291 } wheels = [ { url = "https://files.pythonhosted.org/packages/6d/45/59578566b3275b8fd9157885918fcd0c4d74162928a5310926887b856a51/platformdirs-4.3.7-py3-none-any.whl", hash = "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94", size = 18499 }, ] [[package]] name = "pluggy" version = "1.5.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } wheels = [ { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, ] [[package]] name = "pycodestyle" version = "2.12.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/43/aa/210b2c9aedd8c1cbeea31a50e42050ad56187754b34eb214c46709445801/pycodestyle-2.12.1.tar.gz", hash = "sha256:6838eae08bbce4f6accd5d5572075c63626a15ee3e6f842df996bf62f6d73521", size = 39232 } wheels = [ { url = "https://files.pythonhosted.org/packages/3a/d8/a211b3f85e99a0daa2ddec96c949cac6824bd305b040571b82a03dd62636/pycodestyle-2.12.1-py2.py3-none-any.whl", hash = "sha256:46f0fb92069a7c28ab7bb558f05bfc0110dac69a0cd23c61ea0040283a9d78b3", size = 31284 }, ] [[package]] name = "pycparser" version = "2.22" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } wheels = [ { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, ] [[package]] name = "pyflakes" version = "3.2.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/57/f9/669d8c9c86613c9d568757c7f5824bd3197d7b1c6c27553bc5618a27cce2/pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f", size = 63788 } wheels = [ { url = "https://files.pythonhosted.org/packages/d4/d7/f1b7db88d8e4417c5d47adad627a93547f44bdc9028372dbd2313f34a855/pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a", size = 62725 }, ] [[package]] name = "pygls" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cattrs" }, { name = "lsprotocol" }, ] sdist = { url = "https://files.pythonhosted.org/packages/86/b9/41d173dad9eaa9db9c785a85671fc3d68961f08d67706dc2e79011e10b5c/pygls-1.3.1.tar.gz", hash = "sha256:140edceefa0da0e9b3c533547c892a42a7d2fd9217ae848c330c53d266a55018", size = 45527 } wheels = [ { url = "https://files.pythonhosted.org/packages/11/19/b74a10dd24548e96e8c80226cbacb28b021bc3a168a7d2709fb0d0185348/pygls-1.3.1-py3-none-any.whl", hash = "sha256:6e00f11efc56321bdeb6eac04f6d86131f654c7d49124344a9ebb968da3dd91e", size = 56031 }, ] [[package]] name = "pygments" version = "2.19.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, ] [[package]] name = "pylsp-mypy" version = "0.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mypy" }, { name = "python-lsp-server" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e9/4d/9683a57f2e8b9263910ef497a99d88622f4fb1c158decb867fd40a41bfdd/pylsp_mypy-0.7.0.tar.gz", hash = "sha256:e94f531d4ce523222c2af7471abe396cfeb4cc3c4b181d54462fb6d553e1e0b3", size = 18529 } wheels = [ { url = "https://files.pythonhosted.org/packages/a6/7d/324859fa4af565db32ff8d924fd10dd49922756736be12d783be3813ffc8/pylsp_mypy-0.7.0-py3-none-any.whl", hash = "sha256:756377d05d251d2e31d1963397654149b9c1ea5b0ba1aedd74adef76decd32e9", size = 12232 }, ] [[package]] name = "pytest" version = "8.3.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919 } wheels = [ { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 }, ] [[package]] name = "pytest-cov" version = "6.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage", extra = ["toml"] }, { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/be/45/9b538de8cef30e17c7b45ef42f538a94889ed6a16f2387a6c89e73220651/pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0", size = 66945 } wheels = [ { url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949 }, ] [[package]] name = "python-lsp-jsonrpc" version = "1.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ujson" }, ] sdist = { url = "https://files.pythonhosted.org/packages/48/b6/fd92e2ea4635d88966bb42c20198df1a981340f07843b5e3c6694ba3557b/python-lsp-jsonrpc-1.1.2.tar.gz", hash = "sha256:4688e453eef55cd952bff762c705cedefa12055c0aec17a06f595bcc002cc912", size = 15298 } wheels = [ { url = "https://files.pythonhosted.org/packages/cb/d9/656659d5b5d5f402b2b174cd0ba9bc827e07ce3c0bf88da65424baf64af8/python_lsp_jsonrpc-1.1.2-py3-none-any.whl", hash = "sha256:7339c2e9630ae98903fdaea1ace8c47fba0484983794d6aafd0bd8989be2b03c", size = 8805 }, ] [[package]] name = "python-lsp-server" version = "1.12.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docstring-to-markdown" }, { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, { name = "jedi" }, { name = "pluggy" }, { name = "python-lsp-jsonrpc" }, { name = "ujson" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cc/0f/3d63c5f37edca529a2a003a30add97dcce67a83a99dd932528f623aa1df9/python_lsp_server-1.12.2.tar.gz", hash = "sha256:fea039a36b3132774d0f803671184cf7dde0c688e7b924f23a6359a66094126d", size = 115054 } wheels = [ { url = "https://files.pythonhosted.org/packages/cb/e7/28010a326ef591e1409daf9d57a47de94156c147ad1befe74d8196d82729/python_lsp_server-1.12.2-py3-none-any.whl", hash = "sha256:750116459449184ba20811167cdf96f91296ae12f1f65ebd975c5c159388111b", size = 74773 }, ] [[package]] name = "pywin32-ctypes" version = "0.2.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471 } wheels = [ { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756 }, ] [[package]] name = "readme-renderer" version = "44.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docutils" }, { name = "nh3" }, { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5a/a9/104ec9234c8448c4379768221ea6df01260cd6c2ce13182d4eac531c8342/readme_renderer-44.0.tar.gz", hash = "sha256:8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1", size = 32056 } wheels = [ { url = "https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl", hash = "sha256:2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151", size = 13310 }, ] [[package]] name = "requests" version = "2.32.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "charset-normalizer" }, { name = "idna" }, { name = "urllib3" }, ] sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } wheels = [ { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, ] [[package]] name = "requests-toolbelt" version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888 } wheels = [ { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481 }, ] [[package]] name = "rfc3986" version = "2.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c", size = 49026 } wheels = [ { url = "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", size = 31326 }, ] [[package]] name = "rich" version = "13.9.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } wheels = [ { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, ] [[package]] name = "ruff" version = "0.11.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/90/61/fb87430f040e4e577e784e325351186976516faef17d6fcd921fe28edfd7/ruff-0.11.2.tar.gz", hash = "sha256:ec47591497d5a1050175bdf4e1a4e6272cddff7da88a2ad595e1e326041d8d94", size = 3857511 } wheels = [ { url = "https://files.pythonhosted.org/packages/62/99/102578506f0f5fa29fd7e0df0a273864f79af044757aef73d1cae0afe6ad/ruff-0.11.2-py3-none-linux_armv6l.whl", hash = "sha256:c69e20ea49e973f3afec2c06376eb56045709f0212615c1adb0eda35e8a4e477", size = 10113146 }, { url = "https://files.pythonhosted.org/packages/74/ad/5cd4ba58ab602a579997a8494b96f10f316e874d7c435bcc1a92e6da1b12/ruff-0.11.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2c5424cc1c4eb1d8ecabe6d4f1b70470b4f24a0c0171356290b1953ad8f0e272", size = 10867092 }, { url = "https://files.pythonhosted.org/packages/fc/3e/d3f13619e1d152c7b600a38c1a035e833e794c6625c9a6cea6f63dbf3af4/ruff-0.11.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ecf20854cc73f42171eedb66f006a43d0a21bfb98a2523a809931cda569552d9", size = 10224082 }, { url = "https://files.pythonhosted.org/packages/90/06/f77b3d790d24a93f38e3806216f263974909888fd1e826717c3ec956bbcd/ruff-0.11.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c543bf65d5d27240321604cee0633a70c6c25c9a2f2492efa9f6d4b8e4199bb", size = 10394818 }, { url = "https://files.pythonhosted.org/packages/99/7f/78aa431d3ddebfc2418cd95b786642557ba8b3cb578c075239da9ce97ff9/ruff-0.11.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20967168cc21195db5830b9224be0e964cc9c8ecf3b5a9e3ce19876e8d3a96e3", size = 9952251 }, { url = "https://files.pythonhosted.org/packages/30/3e/f11186d1ddfaca438c3bbff73c6a2fdb5b60e6450cc466129c694b0ab7a2/ruff-0.11.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:955a9ce63483999d9f0b8f0b4a3ad669e53484232853054cc8b9d51ab4c5de74", size = 11563566 }, { url = "https://files.pythonhosted.org/packages/22/6c/6ca91befbc0a6539ee133d9a9ce60b1a354db12c3c5d11cfdbf77140f851/ruff-0.11.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:86b3a27c38b8fce73bcd262b0de32e9a6801b76d52cdb3ae4c914515f0cef608", size = 12208721 }, { url = "https://files.pythonhosted.org/packages/19/b0/24516a3b850d55b17c03fc399b681c6a549d06ce665915721dc5d6458a5c/ruff-0.11.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3b66a03b248c9fcd9d64d445bafdf1589326bee6fc5c8e92d7562e58883e30f", size = 11662274 }, { url = "https://files.pythonhosted.org/packages/d7/65/76be06d28ecb7c6070280cef2bcb20c98fbf99ff60b1c57d2fb9b8771348/ruff-0.11.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0397c2672db015be5aa3d4dac54c69aa012429097ff219392c018e21f5085147", size = 13792284 }, { url = "https://files.pythonhosted.org/packages/ce/d2/4ceed7147e05852876f3b5f3fdc23f878ce2b7e0b90dd6e698bda3d20787/ruff-0.11.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:869bcf3f9abf6457fbe39b5a37333aa4eecc52a3b99c98827ccc371a8e5b6f1b", size = 11327861 }, { url = "https://files.pythonhosted.org/packages/c4/78/4935ecba13706fd60ebe0e3dc50371f2bdc3d9bc80e68adc32ff93914534/ruff-0.11.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2a2b50ca35457ba785cd8c93ebbe529467594087b527a08d487cf0ee7b3087e9", size = 10276560 }, { url = "https://files.pythonhosted.org/packages/81/7f/1b2435c3f5245d410bb5dc80f13ec796454c21fbda12b77d7588d5cf4e29/ruff-0.11.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7c69c74bf53ddcfbc22e6eb2f31211df7f65054bfc1f72288fc71e5f82db3eab", size = 9945091 }, { url = "https://files.pythonhosted.org/packages/39/c4/692284c07e6bf2b31d82bb8c32f8840f9d0627d92983edaac991a2b66c0a/ruff-0.11.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6e8fb75e14560f7cf53b15bbc55baf5ecbe373dd5f3aab96ff7aa7777edd7630", size = 10977133 }, { url = "https://files.pythonhosted.org/packages/94/cf/8ab81cb7dd7a3b0a3960c2769825038f3adcd75faf46dd6376086df8b128/ruff-0.11.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:842a472d7b4d6f5924e9297aa38149e5dcb1e628773b70e6387ae2c97a63c58f", size = 11378514 }, { url = "https://files.pythonhosted.org/packages/d9/3a/a647fa4f316482dacf2fd68e8a386327a33d6eabd8eb2f9a0c3d291ec549/ruff-0.11.2-py3-none-win32.whl", hash = "sha256:aca01ccd0eb5eb7156b324cfaa088586f06a86d9e5314b0eb330cb48415097cc", size = 10319835 }, { url = "https://files.pythonhosted.org/packages/86/54/3c12d3af58012a5e2cd7ebdbe9983f4834af3f8cbea0e8a8c74fa1e23b2b/ruff-0.11.2-py3-none-win_amd64.whl", hash = "sha256:3170150172a8f994136c0c66f494edf199a0bbea7a409f649e4bc8f4d7084080", size = 11373713 }, { url = "https://files.pythonhosted.org/packages/d6/d4/dd813703af8a1e2ac33bf3feb27e8a5ad514c9f219df80c64d69807e7f71/ruff-0.11.2-py3-none-win_arm64.whl", hash = "sha256:52933095158ff328f4c77af3d74f0379e34fd52f175144cefc1b192e7ccd32b4", size = 10441990 }, ] [[package]] name = "secretstorage" version = "3.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "jeepney" }, ] sdist = { url = "https://files.pythonhosted.org/packages/53/a4/f48c9d79cb507ed1373477dbceaba7401fd8a23af63b837fa61f1dcd3691/SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77", size = 19739 } wheels = [ { url = "https://files.pythonhosted.org/packages/54/24/b4293291fa1dd830f353d2cb163295742fa87f179fcc8a20a306a81978b7/SecretStorage-3.3.3-py3-none-any.whl", hash = "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99", size = 15221 }, ] [[package]] name = "snowballstemmer" version = "2.2.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/44/7b/af302bebf22c749c56c9c3e8ae13190b5b5db37a33d9068652e8f73b7089/snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1", size = 86699 } wheels = [ { url = "https://files.pythonhosted.org/packages/ed/dc/c02e01294f7265e63a7315fe086dd1df7dacb9f840a804da846b96d01b96/snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a", size = 93002 }, ] [[package]] name = "soupsieve" version = "2.6" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d7/ce/fbaeed4f9fb8b2daa961f90591662df6a86c1abf25c548329a86920aedfb/soupsieve-2.6.tar.gz", hash = "sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb", size = 101569 } wheels = [ { url = "https://files.pythonhosted.org/packages/d1/c2/fe97d779f3ef3b15f05c94a2f1e3d21732574ed441687474db9d342a7315/soupsieve-2.6-py3-none-any.whl", hash = "sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9", size = 36186 }, ] [[package]] name = "sphinx" version = "7.4.7" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ { name = "alabaster", marker = "python_full_version < '3.10'" }, { name = "babel", marker = "python_full_version < '3.10'" }, { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, { name = "docutils", marker = "python_full_version < '3.10'" }, { name = "imagesize", marker = "python_full_version < '3.10'" }, { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, { name = "jinja2", marker = "python_full_version < '3.10'" }, { name = "packaging", marker = "python_full_version < '3.10'" }, { name = "pygments", marker = "python_full_version < '3.10'" }, { name = "requests", marker = "python_full_version < '3.10'" }, { name = "snowballstemmer", marker = "python_full_version < '3.10'" }, { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.10'" }, { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.10'" }, { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.10'" }, { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.10'" }, { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.10'" }, { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.10'" }, { name = "tomli", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/be/50e50cb4f2eff47df05673d361095cafd95521d2a22521b920c67a372dcb/sphinx-7.4.7.tar.gz", hash = "sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe", size = 8067911 } wheels = [ { url = "https://files.pythonhosted.org/packages/0d/ef/153f6803c5d5f8917dbb7f7fcf6d34a871ede3296fa89c2c703f5f8a6c8e/sphinx-7.4.7-py3-none-any.whl", hash = "sha256:c2419e2135d11f1951cd994d6eb18a1835bd8fdd8429f9ca375dc1f3281bd239", size = 3401624 }, ] [[package]] name = "sphinx" version = "8.1.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.10'", ] dependencies = [ { name = "alabaster", marker = "python_full_version >= '3.10'" }, { name = "babel", marker = "python_full_version >= '3.10'" }, { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, { name = "docutils", marker = "python_full_version >= '3.10'" }, { name = "imagesize", marker = "python_full_version >= '3.10'" }, { name = "jinja2", marker = "python_full_version >= '3.10'" }, { name = "packaging", marker = "python_full_version >= '3.10'" }, { name = "pygments", marker = "python_full_version >= '3.10'" }, { name = "requests", marker = "python_full_version >= '3.10'" }, { name = "snowballstemmer", marker = "python_full_version >= '3.10'" }, { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.10'" }, { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.10'" }, { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.10'" }, { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.10'" }, { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.10'" }, { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.10'" }, { name = "tomli", marker = "python_full_version == '3.10.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611 } wheels = [ { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125 }, ] [[package]] name = "sphinx-basic-ng" version = "1.0.0b2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/0b/a866924ded68efec7a1759587a4e478aec7559d8165fac8b2ad1c0e774d6/sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9", size = 20736 } wheels = [ { url = "https://files.pythonhosted.org/packages/3c/dd/018ce05c532a22007ac58d4f45232514cd9d6dd0ee1dc374e309db830983/sphinx_basic_ng-1.0.0b2-py3-none-any.whl", hash = "sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b", size = 22496 }, ] [[package]] name = "sphinxcontrib-applehelp" version = "2.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053 } wheels = [ { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300 }, ] [[package]] name = "sphinxcontrib-devhelp" version = "2.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967 } wheels = [ { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530 }, ] [[package]] name = "sphinxcontrib-htmlhelp" version = "2.1.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617 } wheels = [ { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705 }, ] [[package]] name = "sphinxcontrib-jsmath" version = "1.0.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787 } wheels = [ { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071 }, ] [[package]] name = "sphinxcontrib-qthelp" version = "2.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165 } wheels = [ { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743 }, ] [[package]] name = "sphinxcontrib-serializinghtml" version = "2.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080 } wheels = [ { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072 }, ] [[package]] name = "sshuttle" version = "1.3.0" source = { editable = "." } [package.dev-dependencies] dev = [ { name = "black" }, { name = "bump2version" }, { name = "flake8" }, { name = "jedi-language-server" }, { name = "pyflakes" }, { name = "pylsp-mypy" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "python-lsp-server" }, { name = "ruff" }, { name = "twine" }, ] docs = [ { name = "furo" }, { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] [package.metadata] [package.metadata.requires-dev] dev = [ { name = "black", specifier = ">=25.1.0" }, { name = "bump2version", specifier = ">=1.0.1,<2.0.0" }, { name = "flake8", specifier = ">=7.0.0,<8.0.0" }, { name = "jedi-language-server", specifier = ">=0.44.0" }, { name = "pyflakes", specifier = ">=3.2.0,<4.0.0" }, { name = "pylsp-mypy", specifier = ">=0.7.0" }, { name = "pytest", specifier = ">=8.0.1,<9.0.0" }, { name = "pytest-cov", specifier = ">=4.1,<7.0" }, { name = "python-lsp-server", specifier = ">=1.12.2" }, { name = "ruff", specifier = ">=0.11.2" }, { name = "twine", specifier = ">=5,<7" }, ] docs = [ { name = "furo", specifier = "==2024.8.6" }, { name = "sphinx", marker = "python_full_version >= '3.10' and python_full_version < '4'", specifier = "==8.1.3" }, ] [[package]] name = "tomli" version = "2.2.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } wheels = [ { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, ] [[package]] name = "twine" version = "6.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "id" }, { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, { name = "keyring", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" }, { name = "packaging" }, { name = "readme-renderer" }, { name = "requests" }, { name = "requests-toolbelt" }, { name = "rfc3986" }, { name = "rich" }, { name = "urllib3" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c8/a2/6df94fc5c8e2170d21d7134a565c3a8fb84f9797c1dd65a5976aaf714418/twine-6.1.0.tar.gz", hash = "sha256:be324f6272eff91d07ee93f251edf232fc647935dd585ac003539b42404a8dbd", size = 168404 } wheels = [ { url = "https://files.pythonhosted.org/packages/7c/b6/74e927715a285743351233f33ea3c684528a0d374d2e43ff9ce9585b73fe/twine-6.1.0-py3-none-any.whl", hash = "sha256:a47f973caf122930bf0fbbf17f80b83bc1602c9ce393c7845f289a3001dc5384", size = 40791 }, ] [[package]] name = "typing-extensions" version = "4.12.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } wheels = [ { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, ] [[package]] name = "ujson" version = "5.10.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f0/00/3110fd566786bfa542adb7932d62035e0c0ef662a8ff6544b6643b3d6fd7/ujson-5.10.0.tar.gz", hash = "sha256:b3cd8f3c5d8c7738257f1018880444f7b7d9b66232c64649f562d7ba86ad4bc1", size = 7154885 } wheels = [ { url = "https://files.pythonhosted.org/packages/7d/91/91678e49a9194f527e60115db84368c237ac7824992224fac47dcb23a5c6/ujson-5.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2601aa9ecdbee1118a1c2065323bda35e2c5a2cf0797ef4522d485f9d3ef65bd", size = 55354 }, { url = "https://files.pythonhosted.org/packages/de/2f/1ed8c9b782fa4f44c26c1c4ec686d728a4865479da5712955daeef0b2e7b/ujson-5.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:348898dd702fc1c4f1051bc3aacbf894caa0927fe2c53e68679c073375f732cf", size = 51808 }, { url = "https://files.pythonhosted.org/packages/51/bf/a3a38b2912288143e8e613c6c4c3f798b5e4e98c542deabf94c60237235f/ujson-5.10.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22cffecf73391e8abd65ef5f4e4dd523162a3399d5e84faa6aebbf9583df86d6", size = 51995 }, { url = "https://files.pythonhosted.org/packages/b4/6d/0df8f7a6f1944ba619d93025ce468c9252aa10799d7140e07014dfc1a16c/ujson-5.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26b0e2d2366543c1bb4fbd457446f00b0187a2bddf93148ac2da07a53fe51569", size = 53566 }, { url = "https://files.pythonhosted.org/packages/d5/ec/370741e5e30d5f7dc7f31a478d5bec7537ce6bfb7f85e72acefbe09aa2b2/ujson-5.10.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:caf270c6dba1be7a41125cd1e4fc7ba384bf564650beef0df2dd21a00b7f5770", size = 58499 }, { url = "https://files.pythonhosted.org/packages/fe/29/72b33a88f7fae3c398f9ba3e74dc2e5875989b25f1c1f75489c048a2cf4e/ujson-5.10.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a245d59f2ffe750446292b0094244df163c3dc96b3ce152a2c837a44e7cda9d1", size = 997881 }, { url = "https://files.pythonhosted.org/packages/70/5c/808fbf21470e7045d56a282cf5e85a0450eacdb347d871d4eb404270ee17/ujson-5.10.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:94a87f6e151c5f483d7d54ceef83b45d3a9cca7a9cb453dbdbb3f5a6f64033f5", size = 1140631 }, { url = "https://files.pythonhosted.org/packages/8f/6a/e1e8281408e6270d6ecf2375af14d9e2f41c402ab6b161ecfa87a9727777/ujson-5.10.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:29b443c4c0a113bcbb792c88bea67b675c7ca3ca80c3474784e08bba01c18d51", size = 1043511 }, { url = "https://files.pythonhosted.org/packages/cb/ca/e319acbe4863919ec62498bc1325309f5c14a3280318dca10fe1db3cb393/ujson-5.10.0-cp310-cp310-win32.whl", hash = "sha256:c18610b9ccd2874950faf474692deee4223a994251bc0a083c114671b64e6518", size = 38626 }, { url = "https://files.pythonhosted.org/packages/78/ec/dc96ca379de33f73b758d72e821ee4f129ccc32221f4eb3f089ff78d8370/ujson-5.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:924f7318c31874d6bb44d9ee1900167ca32aa9b69389b98ecbde34c1698a250f", size = 42076 }, { url = "https://files.pythonhosted.org/packages/23/ec/3c551ecfe048bcb3948725251fb0214b5844a12aa60bee08d78315bb1c39/ujson-5.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a5b366812c90e69d0f379a53648be10a5db38f9d4ad212b60af00bd4048d0f00", size = 55353 }, { url = "https://files.pythonhosted.org/packages/8d/9f/4731ef0671a0653e9f5ba18db7c4596d8ecbf80c7922dd5fe4150f1aea76/ujson-5.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:502bf475781e8167f0f9d0e41cd32879d120a524b22358e7f205294224c71126", size = 51813 }, { url = "https://files.pythonhosted.org/packages/1f/2b/44d6b9c1688330bf011f9abfdb08911a9dc74f76926dde74e718d87600da/ujson-5.10.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b91b5d0d9d283e085e821651184a647699430705b15bf274c7896f23fe9c9d8", size = 51988 }, { url = "https://files.pythonhosted.org/packages/29/45/f5f5667427c1ec3383478092a414063ddd0dfbebbcc533538fe37068a0a3/ujson-5.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:129e39af3a6d85b9c26d5577169c21d53821d8cf68e079060602e861c6e5da1b", size = 53561 }, { url = "https://files.pythonhosted.org/packages/26/21/a0c265cda4dd225ec1be595f844661732c13560ad06378760036fc622587/ujson-5.10.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f77b74475c462cb8b88680471193064d3e715c7c6074b1c8c412cb526466efe9", size = 58497 }, { url = "https://files.pythonhosted.org/packages/28/36/8fde862094fd2342ccc427a6a8584fed294055fdee341661c78660f7aef3/ujson-5.10.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ec0ca8c415e81aa4123501fee7f761abf4b7f386aad348501a26940beb1860f", size = 997877 }, { url = "https://files.pythonhosted.org/packages/90/37/9208e40d53baa6da9b6a1c719e0670c3f474c8fc7cc2f1e939ec21c1bc93/ujson-5.10.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ab13a2a9e0b2865a6c6db9271f4b46af1c7476bfd51af1f64585e919b7c07fd4", size = 1140632 }, { url = "https://files.pythonhosted.org/packages/89/d5/2626c87c59802863d44d19e35ad16b7e658e4ac190b0dead17ff25460b4c/ujson-5.10.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:57aaf98b92d72fc70886b5a0e1a1ca52c2320377360341715dd3933a18e827b1", size = 1043513 }, { url = "https://files.pythonhosted.org/packages/2f/ee/03662ce9b3f16855770f0d70f10f0978ba6210805aa310c4eebe66d36476/ujson-5.10.0-cp311-cp311-win32.whl", hash = "sha256:2987713a490ceb27edff77fb184ed09acdc565db700ee852823c3dc3cffe455f", size = 38616 }, { url = "https://files.pythonhosted.org/packages/3e/20/952dbed5895835ea0b82e81a7be4ebb83f93b079d4d1ead93fcddb3075af/ujson-5.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:f00ea7e00447918ee0eff2422c4add4c5752b1b60e88fcb3c067d4a21049a720", size = 42071 }, { url = "https://files.pythonhosted.org/packages/e8/a6/fd3f8bbd80842267e2d06c3583279555e8354c5986c952385199d57a5b6c/ujson-5.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:98ba15d8cbc481ce55695beee9f063189dce91a4b08bc1d03e7f0152cd4bbdd5", size = 55642 }, { url = "https://files.pythonhosted.org/packages/a8/47/dd03fd2b5ae727e16d5d18919b383959c6d269c7b948a380fdd879518640/ujson-5.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9d2edbf1556e4f56e50fab7d8ff993dbad7f54bac68eacdd27a8f55f433578e", size = 51807 }, { url = "https://files.pythonhosted.org/packages/25/23/079a4cc6fd7e2655a473ed9e776ddbb7144e27f04e8fc484a0fb45fe6f71/ujson-5.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6627029ae4f52d0e1a2451768c2c37c0c814ffc04f796eb36244cf16b8e57043", size = 51972 }, { url = "https://files.pythonhosted.org/packages/04/81/668707e5f2177791869b624be4c06fb2473bf97ee33296b18d1cf3092af7/ujson-5.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8ccb77b3e40b151e20519c6ae6d89bfe3f4c14e8e210d910287f778368bb3d1", size = 53686 }, { url = "https://files.pythonhosted.org/packages/bd/50/056d518a386d80aaf4505ccf3cee1c40d312a46901ed494d5711dd939bc3/ujson-5.10.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3caf9cd64abfeb11a3b661329085c5e167abbe15256b3b68cb5d914ba7396f3", size = 58591 }, { url = "https://files.pythonhosted.org/packages/fc/d6/aeaf3e2d6fb1f4cfb6bf25f454d60490ed8146ddc0600fae44bfe7eb5a72/ujson-5.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6e32abdce572e3a8c3d02c886c704a38a1b015a1fb858004e03d20ca7cecbb21", size = 997853 }, { url = "https://files.pythonhosted.org/packages/f8/d5/1f2a5d2699f447f7d990334ca96e90065ea7f99b142ce96e85f26d7e78e2/ujson-5.10.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a65b6af4d903103ee7b6f4f5b85f1bfd0c90ba4eeac6421aae436c9988aa64a2", size = 1140689 }, { url = "https://files.pythonhosted.org/packages/f2/2c/6990f4ccb41ed93744aaaa3786394bca0875503f97690622f3cafc0adfde/ujson-5.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:604a046d966457b6cdcacc5aa2ec5314f0e8c42bae52842c1e6fa02ea4bda42e", size = 1043576 }, { url = "https://files.pythonhosted.org/packages/14/f5/a2368463dbb09fbdbf6a696062d0c0f62e4ae6fa65f38f829611da2e8fdd/ujson-5.10.0-cp312-cp312-win32.whl", hash = "sha256:6dea1c8b4fc921bf78a8ff00bbd2bfe166345f5536c510671bccececb187c80e", size = 38764 }, { url = "https://files.pythonhosted.org/packages/59/2d/691f741ffd72b6c84438a93749ac57bf1a3f217ac4b0ea4fd0e96119e118/ujson-5.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:38665e7d8290188b1e0d57d584eb8110951a9591363316dd41cf8686ab1d0abc", size = 42211 }, { url = "https://files.pythonhosted.org/packages/0d/69/b3e3f924bb0e8820bb46671979770c5be6a7d51c77a66324cdb09f1acddb/ujson-5.10.0-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:618efd84dc1acbd6bff8eaa736bb6c074bfa8b8a98f55b61c38d4ca2c1f7f287", size = 55646 }, { url = "https://files.pythonhosted.org/packages/32/8a/9b748eb543c6cabc54ebeaa1f28035b1bd09c0800235b08e85990734c41e/ujson-5.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:38d5d36b4aedfe81dfe251f76c0467399d575d1395a1755de391e58985ab1c2e", size = 51806 }, { url = "https://files.pythonhosted.org/packages/39/50/4b53ea234413b710a18b305f465b328e306ba9592e13a791a6a6b378869b/ujson-5.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67079b1f9fb29ed9a2914acf4ef6c02844b3153913eb735d4bf287ee1db6e557", size = 51975 }, { url = "https://files.pythonhosted.org/packages/b4/9d/8061934f960cdb6dd55f0b3ceeff207fcc48c64f58b43403777ad5623d9e/ujson-5.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7d0e0ceeb8fe2468c70ec0c37b439dd554e2aa539a8a56365fd761edb418988", size = 53693 }, { url = "https://files.pythonhosted.org/packages/f5/be/7bfa84b28519ddbb67efc8410765ca7da55e6b93aba84d97764cd5794dbc/ujson-5.10.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:59e02cd37bc7c44d587a0ba45347cc815fb7a5fe48de16bf05caa5f7d0d2e816", size = 58594 }, { url = "https://files.pythonhosted.org/packages/48/eb/85d465abafb2c69d9699cfa5520e6e96561db787d36c677370e066c7e2e7/ujson-5.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2a890b706b64e0065f02577bf6d8ca3b66c11a5e81fb75d757233a38c07a1f20", size = 997853 }, { url = "https://files.pythonhosted.org/packages/9f/76/2a63409fc05d34dd7d929357b7a45e3a2c96f22b4225cd74becd2ba6c4cb/ujson-5.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:621e34b4632c740ecb491efc7f1fcb4f74b48ddb55e65221995e74e2d00bbff0", size = 1140694 }, { url = "https://files.pythonhosted.org/packages/45/ed/582c4daba0f3e1688d923b5cb914ada1f9defa702df38a1916c899f7c4d1/ujson-5.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9500e61fce0cfc86168b248104e954fead61f9be213087153d272e817ec7b4f", size = 1043580 }, { url = "https://files.pythonhosted.org/packages/d7/0c/9837fece153051e19c7bade9f88f9b409e026b9525927824cdf16293b43b/ujson-5.10.0-cp313-cp313-win32.whl", hash = "sha256:4c4fc16f11ac1612f05b6f5781b384716719547e142cfd67b65d035bd85af165", size = 38766 }, { url = "https://files.pythonhosted.org/packages/d7/72/6cb6728e2738c05bbe9bd522d6fc79f86b9a28402f38663e85a28fddd4a0/ujson-5.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:4573fd1695932d4f619928fd09d5d03d917274381649ade4328091ceca175539", size = 42212 }, { url = "https://files.pythonhosted.org/packages/97/94/50ff2f1b61d668907f20216873640ab19e0eaa77b51e64ee893f6adfb266/ujson-5.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dfef2814c6b3291c3c5f10065f745a1307d86019dbd7ea50e83504950136ed5b", size = 55421 }, { url = "https://files.pythonhosted.org/packages/0c/b3/3d2ca621d8dbeaf6c5afd0725e1b4bbd465077acc69eff1e9302735d1432/ujson-5.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4734ee0745d5928d0ba3a213647f1c4a74a2a28edc6d27b2d6d5bd9fa4319e27", size = 51816 }, { url = "https://files.pythonhosted.org/packages/8d/af/5dc103cb4d08f051f82d162a738adb9da488d1e3fafb9fd9290ea3eabf8e/ujson-5.10.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d47ebb01bd865fdea43da56254a3930a413f0c5590372a1241514abae8aa7c76", size = 52023 }, { url = "https://files.pythonhosted.org/packages/5d/dd/b9a6027ba782b0072bf24a70929e15a58686668c32a37aebfcfaa9e00bdd/ujson-5.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dee5e97c2496874acbf1d3e37b521dd1f307349ed955e62d1d2f05382bc36dd5", size = 53622 }, { url = "https://files.pythonhosted.org/packages/1f/28/bcf6df25c1a9f1989dc2ddc4ac8a80e246857e089f91a9079fd8a0a01459/ujson-5.10.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7490655a2272a2d0b072ef16b0b58ee462f4973a8f6bbe64917ce5e0a256f9c0", size = 58563 }, { url = "https://files.pythonhosted.org/packages/9e/82/89404453a102d06d0937f6807c0a7ef2eec68b200b4ce4386127f3c28156/ujson-5.10.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ba17799fcddaddf5c1f75a4ba3fd6441f6a4f1e9173f8a786b42450851bd74f1", size = 998050 }, { url = "https://files.pythonhosted.org/packages/63/eb/2a4ea07165cad217bc842bb684b053bafa8ffdb818c47911c621e97a33fc/ujson-5.10.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2aff2985cef314f21d0fecc56027505804bc78802c0121343874741650a4d3d1", size = 1140672 }, { url = "https://files.pythonhosted.org/packages/72/53/d7bdf6afabeba3ed899f89d993c7f202481fa291d8c5be031c98a181eda4/ujson-5.10.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ad88ac75c432674d05b61184178635d44901eb749786c8eb08c102330e6e8996", size = 1043577 }, { url = "https://files.pythonhosted.org/packages/19/b1/75f5f0d18501fd34487e46829de3070724c7b350f1983ba7f07e0986720b/ujson-5.10.0-cp39-cp39-win32.whl", hash = "sha256:2544912a71da4ff8c4f7ab5606f947d7299971bdd25a45e008e467ca638d13c9", size = 38654 }, { url = "https://files.pythonhosted.org/packages/77/0d/50d2f9238f6d6683ead5ecd32d83d53f093a3c0047ae4c720b6d586cb80d/ujson-5.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:3ff201d62b1b177a46f113bb43ad300b424b7847f9c5d38b1b4ad8f75d4a282a", size = 42134 }, { url = "https://files.pythonhosted.org/packages/95/53/e5f5e733fc3525e65f36f533b0dbece5e5e2730b760e9beacf7e3d9d8b26/ujson-5.10.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:5b6fee72fa77dc172a28f21693f64d93166534c263adb3f96c413ccc85ef6e64", size = 51846 }, { url = "https://files.pythonhosted.org/packages/59/1f/f7bc02a54ea7b47f3dc2d125a106408f18b0f47b14fc737f0913483ae82b/ujson-5.10.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:61d0af13a9af01d9f26d2331ce49bb5ac1fb9c814964018ac8df605b5422dcb3", size = 48103 }, { url = "https://files.pythonhosted.org/packages/1a/3a/d3921b6f29bc744d8d6c56db5f8bbcbe55115fd0f2b79c3c43ff292cc7c9/ujson-5.10.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ecb24f0bdd899d368b715c9e6664166cf694d1e57be73f17759573a6986dd95a", size = 47257 }, { url = "https://files.pythonhosted.org/packages/f1/04/f4e3883204b786717038064afd537389ba7d31a72b437c1372297cb651ea/ujson-5.10.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fbd8fd427f57a03cff3ad6574b5e299131585d9727c8c366da4624a9069ed746", size = 48468 }, { url = "https://files.pythonhosted.org/packages/17/cd/9c6547169eb01a22b04cbb638804ccaeb3c2ec2afc12303464e0f9b2ee5a/ujson-5.10.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:beeaf1c48e32f07d8820c705ff8e645f8afa690cca1544adba4ebfa067efdc88", size = 54266 }, { url = "https://files.pythonhosted.org/packages/70/bf/ecd14d3cf6127f8a990b01f0ad20e257f5619a555f47d707c57d39934894/ujson-5.10.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:baed37ea46d756aca2955e99525cc02d9181de67f25515c468856c38d52b5f3b", size = 42224 }, { url = "https://files.pythonhosted.org/packages/8d/96/a3a2356ca5a4b67fe32a0c31e49226114d5154ba2464bb1220a93eb383e8/ujson-5.10.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ba43cc34cce49cf2d4bc76401a754a81202d8aa926d0e2b79f0ee258cb15d3a4", size = 51855 }, { url = "https://files.pythonhosted.org/packages/73/3d/41e78e7500e75eb6b5a7ab06907a6df35603b92ac6f939b86f40e9fe2c06/ujson-5.10.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:ac56eb983edce27e7f51d05bc8dd820586c6e6be1c5216a6809b0c668bb312b8", size = 48059 }, { url = "https://files.pythonhosted.org/packages/be/14/e435cbe5b5189483adbba5fe328e88418ccd54b2b1f74baa4172384bb5cd/ujson-5.10.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f44bd4b23a0e723bf8b10628288c2c7c335161d6840013d4d5de20e48551773b", size = 47238 }, { url = "https://files.pythonhosted.org/packages/e8/d9/b6f4d1e6bec20a3b582b48f64eaa25209fd70dc2892b21656b273bc23434/ujson-5.10.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c10f4654e5326ec14a46bcdeb2b685d4ada6911050aa8baaf3501e57024b804", size = 48457 }, { url = "https://files.pythonhosted.org/packages/23/1c/cfefabb5996e21a1a4348852df7eb7cfc69299143739e86e5b1071c78735/ujson-5.10.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0de4971a89a762398006e844ae394bd46991f7c385d7a6a3b93ba229e6dac17e", size = 54238 }, { url = "https://files.pythonhosted.org/packages/af/c4/fa70e77e1c27bbaf682d790bd09ef40e86807ada704c528ef3ea3418d439/ujson-5.10.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e1402f0564a97d2a52310ae10a64d25bcef94f8dd643fcf5d310219d915484f7", size = 42230 }, ] [[package]] name = "urllib3" version = "2.3.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 } wheels = [ { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, ] [[package]] name = "zipp" version = "3.21.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/3f/50/bad581df71744867e9468ebd0bcd6505de3b275e06f202c2cb016e3ff56f/zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4", size = 24545 } wheels = [ { url = "https://files.pythonhosted.org/packages/b7/1a/7e4798e9339adc931158c9d69ecc34f5e6791489d469f5e50ec15e35f458/zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931", size = 9630 }, ]