Compare commits

..

No commits in common. "master" and "v1.1.0" have entirely different histories.

65 changed files with 933 additions and 4226 deletions

1
.envrc
View File

@ -1 +0,0 @@
use flake .

View File

@ -1,7 +1,6 @@
version: 2
enable-beta-ecosystems: true
updates:
- package-ecosystem: uv
- package-ecosystem: pip
directory: "/"
schedule:
interval: daily

View File

@ -1,70 +0,0 @@
# 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

View File

@ -8,7 +8,8 @@ on:
branches: [ master ]
pull_request:
branches: [ master ]
workflow_dispatch: {}
workflow_dispatch:
branches: [ master ]
jobs:
build:
@ -16,23 +17,21 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12"]
poetry-version: ["main"]
python-version: [3.6, 3.7, 3.8, 3.9, "3.10"]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2.4.0
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
uses: actions/setup-python@v2.3.1
with:
python-version: ${{ matrix.python-version }}
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
version: "0.4.30"
enable-cache: true
cache-dependency-glob: "uv.lock"
- name: Install the project
run: uv sync --all-extras --dev
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-tests.txt
- name: Lint with flake8
run: uv run flake8 sshuttle tests --count --show-source --statistics
- name: Run the automated tests
run: uv run pytest -v
run: |
flake8 sshuttle tests --count --show-source --statistics
- name: Test with pytest
run: |
PYTHONPATH=$PWD pytest

View File

@ -1,66 +0,0 @@
on:
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@v6
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

6
.gitignore vendored
View File

@ -1,5 +1,5 @@
/sshuttle/version.py
/tmp/
/.coverage
/.cache/
/.eggs/
/.tox/
@ -15,6 +15,4 @@
/.redo
/.pytest_cache/
/.python-version
/.direnv/
/result
/.vscode/
.vscode/

View File

@ -3,11 +3,13 @@ version: 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
python: "3.9"
sphinx:
configuration: docs/conf.py
configuration: docs/conf.py
python:
install:
- requirements: requirements.txt
- method: setuptools
path: .

View File

@ -1 +0,0 @@
python 3.10.6

View File

@ -1,54 +0,0 @@
# 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))

View File

@ -4,7 +4,7 @@ sshuttle: 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.
- Your client machine (or router) is Linux, FreeBSD, or MacOS.
- You have access to a remote network via ssh.
@ -30,9 +30,80 @@ common case:
Obtaining sshuttle
------------------
Please see the documentation_.
- 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 py36-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::
virtualenv -p python3 /tmp/sshuttle
. /tmp/sshuttle/bin/activate
pip install sshuttle
- Clone::
virtualenv -p python3 /tmp/sshuttle
. /tmp/sshuttle/bin/activate
git clone https://github.com/sshuttle/sshuttle.git
cd sshuttle
./setup.py install
- Homebrew::
brew install sshuttle
- Nix::
nix-env -iA nixpkgs.sshuttle
.. _Documentation: https://sshuttle.readthedocs.io/en/stable/installation.html
Documentation
-------------

84
bin/sudoers-add Executable file
View File

@ -0,0 +1,84 @@
#!/usr/bin/env bash
# William Mantly <wmantly@gmail.com>
# MIT License
# https://github.com/wmantly/sudoers-add
NEWLINE=$'\n'
CONTENT=""
ME="$(basename "$(test -L "$0" && readlink "$0" || echo "$0")")"
if [ "$1" == "--help" ] || [ "$1" == "-h" ]; then
echo "Usage: $ME [file_path] [sudoers-file-name]"
echo "Usage: [content] | $ME sudoers-file-name"
echo "This will take a sudoers config validate it and add it to /etc/sudoers.d/{sudoers-file-name}"
echo "The config can come from a file, first usage example or piped in second example."
exit 0
fi
if [ "$1" == "" ]; then
(>&2 echo "This command take at lest one argument. See $ME --help")
exit 1
fi
if [ "$2" == "" ]; then
FILE_NAME=$1
shift
else
FILE_NAME=$2
fi
if [[ $EUID -ne 0 ]]; then
echo "This script must be run as root"
exit 1
fi
while read -r line
do
CONTENT+="${line}${NEWLINE}"
done < "${1:-/dev/stdin}"
if [ "$CONTENT" == "" ]; then
(>&2 echo "No config content specified. See $ME --help")
exit 1
fi
if [ "$FILE_NAME" == "" ]; then
(>&2 echo "No sudoers file name specified. See $ME --help")
exit 1
fi
# Verify that the resulting file name begins with /etc/sudoers.d
FILE_NAME="$(realpath "/etc/sudoers.d/$FILE_NAME")"
if [[ "$FILE_NAME" != "/etc/sudoers.d/"* ]] ; then
echo -n "Invalid sudoers filename: Final sudoers file "
echo "location ($FILE_NAME) does not begin with /etc/sudoers.d"
exit 1
fi
# Make a temp file to hold the sudoers config
umask 077
TEMP_FILE=$(mktemp)
echo "$CONTENT" > "$TEMP_FILE"
# Make sure the content is valid
visudo_STDOUT=$(visudo -c -f "$TEMP_FILE" 2>&1)
visudo_code=$?
# The temp file is no longer needed
rm "$TEMP_FILE"
if [ $visudo_code -eq 0 ]; then
echo "$CONTENT" > "$FILE_NAME"
chmod 0440 "$FILE_NAME"
echo "The sudoers file $FILE_NAME has been successfully created!"
exit 0
else
echo "Invalid sudoers config!"
echo "$visudo_STDOUT"
exit 1
fi

View File

@ -16,7 +16,7 @@
import sys
import os
sys.path.insert(0, os.path.abspath('..'))
import sshuttle # NOQA
import sshuttle.version # 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
@ -56,7 +56,7 @@ copyright = '2016, Brian May'
# built documents.
#
# The full version, including alpha/beta/rc tags.
release = sshuttle.__version__
release = sshuttle.version.version
# The short X.Y version.
version = '.'.join(release.split('.')[:2])
@ -103,7 +103,7 @@ pygments_style = 'sphinx'
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
html_theme = 'furo'
html_theme = 'default'
# 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

View File

@ -1,84 +1,24 @@
Installation
============
- 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
pip install sshuttle
- Debian package manager::
sudo apt install sshuttle
- Clone::
git clone https://github.com/sshuttle/sshuttle.git
cd sshuttle
sudo ./setup.py install
./setup.py install
- FreeBSD::
# ports
cd /usr/ports/net/py-sshuttle && make install clean
# pkg
pkg install py39-sshuttle
Optionally after installation
-----------------------------
- OpenBSD::
- Add to sudoers file::
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 --sudoers

View File

@ -181,18 +181,6 @@ Options
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
@ -254,8 +242,8 @@ Options
.. option:: --disable-ipv6
Disable IPv6 support for methods that support it (nat, nft,
tproxy, and pf).
Disable IPv6 support for methods that support it (nft, tproxy, and
pf).
.. option:: --firewall
@ -274,23 +262,28 @@ Options
makes it a lot easier to debug and test the :option:`--auto-hosts`
feature.
.. option:: --sudoers
sshuttle will auto generate the proper sudoers.d config file and add it.
Once this is completed, sshuttle will exit and tell the user if
it succeed or not. Do not call this options with sudo, it may generate a
incorrect config file.
.. 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.
sshuttle will auto generate the proper sudoers.d config and print it to
stdout. The option will not modify the system at all.
.. 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.
Set the user name or group with %group_name for passwordless operation.
Default is the current user.set ALL for all users. Only works with
--sudoers or --sudoers-no-modify option.
.. option:: --sudoers-filename
Set the file name for the sudoers.d file to be added. Default is
"sshuttle_auto". Only works with --sudoers.
.. option:: -t <mark>, --tmark=<mark>
@ -333,18 +326,6 @@ annotations. For example::
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
--------
@ -479,7 +460,7 @@ 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
in order to implement its congestion control agorithm; 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

View File

@ -6,7 +6,7 @@ Client side Requirements
- sudo, or root access on your client machine.
(The server doesn't need admin access.)
- Python 3.9 or greater.
- Python 3.6 or greater.
Linux with NAT method
@ -65,13 +65,14 @@ Requires:
Windows
~~~~~~~
Experimental built-in support available. See :doc:`windows` for more information.
Not officially supported, however can be made to work with Vagrant. Requires
cmd.exe with Administrator access. See :doc:`windows` for more information.
Server side Requirements
------------------------
- Python 3.9 or greater.
- Python 3.6 or greater.
Additional Suggested Software

View File

@ -11,7 +11,7 @@ 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`
One 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.
@ -71,23 +71,44 @@ admin access on the server.
Sudoers File
------------
sshuttle can auto-generate the proper sudoers.d file using the current user
for Linux and OSX. Doing this will allow sshuttle to run without asking for
the local sudo password and to give users who do not have sudo access
ability to run sshuttle::
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.
sshuttle --sudoers
To print a sudo configuration file and see a suggested way to install it, run::
DO NOT run this command with sudo, it will ask for your sudo password when
it is needed.
A costume user or group can be set with the :
option:`sshuttle --sudoers --sudoers-username {user_descriptor}` option. Valid
values for this vary based on how your system is configured. Values such as
usernames, groups pre-pended with `%` and sudoers user aliases will work. See
the sudoers manual for more information on valid user specif actions.
The options must be used with `--sudoers`::
sshuttle --sudoers --sudoers-user mike
sshuttle --sudoers --sudoers-user %sudo
The name of the file to be added to sudoers.d can be configured as well. This
is mostly not necessary but can be useful for giving more than one user
access to sshuttle. The default is `sshuttle_auto`::
sshuttle --sudoer --sudoers-filename sshuttle_auto_mike
sshuttle --sudoer --sudoers-filename sshuttle_auto_tommy
You can also see what configuration will be added to your system without
modifying anything. This can be helpful if the auto feature does not work, or
you want more control. This option also works with `--sudoers-username`.
`--sudoers-filename` has no effect with this option::
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`::
This will simply sprint the generated configuration to STDOUT. Example::
sshuttle --sudoers-no-modify --sudoers-user mike
sshuttle --sudoers-no-modify --sudoers-user %sudo
08:40 PM william$ sshuttle --sudoers-no-modify
Cmnd_Alias SSHUTTLE304 = /usr/bin/env PYTHONPATH=/usr/local/lib/python2.7/dist-packages/sshuttle-0.78.5.dev30+gba5e6b5.d20180909-py2.7.egg /usr/bin/python /usr/local/bin/sshuttle --method auto --firewall
william ALL=NOPASSWD: SSHUTTLE304

View File

@ -1,16 +1,7 @@
Microsoft 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::
Currently there is no built in support for running sshuttle directly on
Microsoft 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

133
flake.lock generated
View File

@ -1,133 +0,0 @@
{
"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
}

117
flake.nix
View File

@ -1,117 +0,0 @@
{
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
];
};
}
);
}

View File

@ -1,57 +0,0 @@
[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"
]

5
requirements-tests.txt Normal file
View File

@ -0,0 +1,5 @@
-r requirements.txt
pytest==6.2.5
pytest-cov==3.0.0
flake8==4.0.1
pyflakes==2.4.0

2
requirements.txt Normal file
View File

@ -0,0 +1,2 @@
setuptools-scm==6.4.2
Sphinx==4.3.2

View File

@ -1,39 +0,0 @@
# 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

View File

@ -1,21 +0,0 @@
# Container based test bed for sshuttle
```bash
test-bed up -d # start containers
exec-sshuttle <node-id> [--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
```
<https://learn.microsoft.com/en-us/windows-server/administration/openssh/openssh_server_configuration#configuring-the-default-shell-for-openssh-in-windows>

View File

@ -1,34 +0,0 @@
name: 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: true

View File

@ -1,65 +0,0 @@
#!/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 "<h5>Hello from $(hostname)</h5>
<pre>
<u>ip address</u>
$(ip address)
<u>ip route</u>
$(ip route)
</pre>" >/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 &

View File

@ -1,159 +0,0 @@
#!/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[@]}"

View File

@ -1,86 +0,0 @@
#!/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

View File

@ -1,40 +0,0 @@
#!/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

View File

@ -1,9 +0,0 @@
#!/usr/bin/env bash
set -e
cd "$(dirname "$0")/.."
export PYTHONPATH=.
set -x
python -m flake8 sshuttle tests
python -m pytest .

View File

@ -1,42 +0,0 @@
#!/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

View File

@ -1,30 +1,17 @@
[bumpversion]
current_version = 1.3.1
[bumpversion:file:setup.py]
[bumpversion:file:pyproject.toml]
[bumpversion:file:sshuttle/version.py]
[aliases]
test = pytest
test=pytest
[bdist_wheel]
universal = 1
[upload]
sign = true
identity = 0x1784577F811F6EAC
sign=true
identity=0x1784577F811F6EAC
[flake8]
count = true
show-source = true
statistics = true
max-line-length = 128
[pycodestyle]
max-line-length = 128
count=true
show-source=true
statistics=true
[tool:pytest]
addopts = --cov=sshuttle --cov-branch --cov-report=term-missing

74
setup.py Executable file
View File

@ -0,0 +1,74 @@
#!/usr/bin/env python
# Copyright 2012-2014 Brian May
#
# This file is part of sshuttle.
#
# sshuttle 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.
#
# sshuttle 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 sshuttle; If not, see <http://www.gnu.org/licenses/>.
from setuptools import setup, find_packages
def version_scheme(version):
from setuptools_scm.version import guess_next_dev_version
version = guess_next_dev_version(version)
return version.lstrip("v")
setup(
name="sshuttle",
use_scm_version={
'write_to': "sshuttle/version.py",
'version_scheme': version_scheme,
},
setup_requires=['setuptools_scm'],
# version=version,
url='https://github.com/sshuttle/sshuttle',
author='Brian May',
author_email='brian@linuxpenguins.xyz',
description='Full-featured" VPN over an SSH tunnel',
packages=find_packages(),
license="LGPL2.1+",
long_description=open('README.rst').read(),
long_description_content_type="text/x-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.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Topic :: System :: Networking",
],
scripts=['bin/sudoers-add'],
entry_points={
'console_scripts': [
'sshuttle = sshuttle.cmdline:main',
],
},
python_requires='>=3.6',
install_requires=[
],
tests_require=[
'pytest',
'pytest-cov',
'pytest-runner',
'flake8',
],
keywords="ssh vpn",
)

View File

@ -1 +1,4 @@
__version__ = "1.3.1"
try:
from sshuttle.version import version as __version__
except ImportError:
__version__ = "unknown"

View File

@ -1,10 +1,4 @@
"""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)
sys.exit(main())

View File

@ -3,27 +3,24 @@ 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
verbosity = verbosity # 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()
name = sys.stdin.readline().strip()
if name:
# python2 compat: in python2 stdin.readline().strip() -> str
# in python3 stdin.readline().strip() -> bytes
# python2 compat: in python2 sys.stdin.readline().strip() -> str
# in python3 sys.stdin.readline().strip() -> bytes
# (see #481)
if sys.version_info >= (3, 0):
name = name.decode("ASCII")
nbytes = int(stdin.readline())
nbytes = int(sys.stdin.readline())
if verbosity >= 2:
sys.stderr.write(' s: assembling %r (%d bytes)\n'
sys.stderr.write(' s: assembling %r (%d bytes)\r\n'
% (name, nbytes))
content = z.decompress(stdin.read(nbytes))
content = z.decompress(sys.stdin.read(nbytes))
module = types.ModuleType(name)
parents = name.rsplit(".", 1)
@ -47,7 +44,6 @@ 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)

View File

@ -5,7 +5,6 @@ import time
import subprocess as ssubprocess
import os
import sys
import base64
import platform
import sshuttle.helpers as helpers
@ -15,17 +14,13 @@ 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
resolvconf_nameservers, which
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
@ -128,14 +123,14 @@ class MultiListener:
self.bind_called = False
def setsockopt(self, level, optname, value):
assert self.bind_called
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
assert(self.bind_called)
socks = []
if self.v6:
socks.append(self.v6)
@ -150,7 +145,7 @@ class MultiListener:
)
def listen(self, backlog):
assert self.bind_called
assert(self.bind_called)
if self.v6:
self.v6.listen(backlog)
if self.v4:
@ -165,26 +160,11 @@ class MultiListener:
raise e
def bind(self, address_v6, address_v4):
assert not self.bind_called
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
self.v6.bind(address_v6)
else:
self.v6 = None
if address_v4 is not None:
@ -194,7 +174,7 @@ class MultiListener:
self.v4 = None
def print_listening(self, what):
assert self.bind_called
assert(self.bind_called)
if self.v6:
listenip = self.v6.getsockname()
debug1('%s listening on %r.' % (what, listenip))
@ -210,10 +190,7 @@ 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]) +
argvbase = ([sys.executable, sys.argv[0]] +
['-v'] * (helpers.verbose or 0) +
['--method', method_name] +
['--firewall'])
@ -223,22 +200,13 @@ class FirewallClient:
# 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
if os.getuid() == 0: # 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']
sudo_cmd = ['sudo', '-p', '[local sudo] Password: ']+argvbase
doas_cmd = ['doas']+argvbase
# For clarity, try to replace executable name with the
# full path.
@ -257,17 +225,13 @@ class FirewallClient:
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
sudo_cmd = pp_prefix + sudo_cmd
doas_cmd = pp_prefix + doas_cmd
# 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'):
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]
@ -277,58 +241,20 @@ class FirewallClient:
# successful, set 'success' variable and break.
success = False
for argv in argv_tries:
# 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()
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 setup():
# run in the child process
s2.close()
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)
self.p = ssubprocess.Popen(argv, stdout=s1, preexec_fn=setup)
# No env: Talking to `FirewallClient.start`, which has no i18n.
except OSError as e:
# This exception will occur if the program isn't
@ -336,14 +262,11 @@ class FirewallClient:
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 = ''
self.argv = argv
s1.close()
self.pfile = s2.makefile('rwb')
line = self.pfile.readline()
rv = self.p.poll() # Check if process is still running
if rv:
@ -355,28 +278,10 @@ class FirewallClient:
'%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))
'Command=%r' % (line, self.argv))
continue
method_name = line[6:-1]
@ -386,11 +291,11 @@ class FirewallClient:
break
if not success:
raise Fatal("All attempts to run firewall client process with elevated privileges were failed.")
raise Fatal("All attempts to elevate privileges failed.")
def setup(self, subnets_include, subnets_exclude, nslist,
redirectport_v6, redirectport_v4, dnsport_v6, dnsport_v4, udp,
user, group, tmark):
user, tmark):
self.subnets_include = subnets_include
self.subnets_exclude = subnets_exclude
self.nslist = nslist
@ -400,7 +305,6 @@ class FirewallClient:
self.dnsport_v4 = dnsport_v4
self.udp = udp
self.user = user
self.group = group
self.tmark = tmark
def check(self):
@ -439,14 +343,9 @@ class FirewallClient:
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.write(b'GO %d %s %s %d\n' %
(udp, user, bytes(self.tmark, 'ascii'), os.getpid()))
self.pfile.flush()
line = self.pfile.readline()
@ -455,8 +354,8 @@ class FirewallClient:
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)
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()
@ -591,7 +490,7 @@ def ondns(listener, method, mux, handlers):
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):
to_nameserver):
helpers.logprefix = 'c : '
debug1('Starting client with Python version %s'
@ -603,11 +502,9 @@ def _main(tcp_listener, udp_listener, fw, ssh_cmd, remotename,
debug1('Connecting to server...')
try:
(serverproc, rfile, wfile) = ssh.connect(
(serverproc, serversock) = 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,
@ -615,25 +512,24 @@ def _main(tcp_listener, udp_listener, fw, ssh_cmd, remotename,
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)
mux = Mux(serversock.makefile("rb"), serversock.makefile("wb"))
handlers.append(mux)
expected = b'SSHUTTLE0001'
try:
v = 'x'
while v and v != b'\0':
v = rfile.read(1)
v = serversock.recv(1)
v = 'x'
while v and v != b'\0':
v = rfile.read(1)
initstring = rfile.read(len(expected))
v = serversock.recv(1)
initstring = serversock.recv(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
@ -810,7 +706,7 @@ def main(listenip_v6, listenip_v4,
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):
user, sudo_pythonpath, tmark):
if not remotename:
raise Fatal("You must use -r/--remote to specify a remote "
@ -875,8 +771,7 @@ def main(listenip_v6, listenip_v4,
# 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_v4 = ('127.0.0.1', 0)
# listenip_v6 is...
# None when IPv6 is disabled.
@ -886,8 +781,8 @@ def main(listenip_v6, listenip_v4,
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])
debug1("IPv6 enabled: Using default IPv6 listen address ::1")
listenip_v6 = ('::1', 0)
else:
debug1("IPv6 disabled since it isn't supported by method "
"%s." % fw.method.name)
@ -914,15 +809,6 @@ def main(listenip_v6, listenip_v4,
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.")
@ -1067,7 +953,7 @@ def main(listenip_v6, listenip_v4,
raise e
if not bound:
assert last_e
assert(last_e)
raise last_e
tcp_listener.listen(10)
tcp_listener.print_listening("TCP redirector")
@ -1113,7 +999,7 @@ def main(listenip_v6, listenip_v4,
dns_listener.print_listening("DNS")
if not bound:
assert last_e
assert(last_e)
raise last_e
else:
dnsport_v6 = 0
@ -1152,14 +1038,14 @@ def main(listenip_v6, listenip_v4,
# start the firewall
fw.setup(subnets_include, subnets_exclude, nslist,
redirectport_v6, redirectport_v4, dnsport_v6, dnsport_v4,
required.udp, user, group, tmark)
required.udp, user, 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)
daemon, to_nameserver)
finally:
try:
if daemon:

View File

@ -1,8 +1,6 @@
import os
import re
import shlex
import socket
import sys
import platform
import sshuttle.helpers as helpers
import sshuttle.client as client
import sshuttle.firewall as firewall
@ -11,21 +9,25 @@ 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()
opt = parser.parse_args(args)
if opt.sudoers or opt.sudoers_no_modify:
if platform.platform().startswith('OpenBSD'):
log('Automatic sudoers does not work on BSD')
return 1
if opt.sudoers_no_modify:
# sudoers() calls exit() when it completes
sudoers(user_name=opt.sudoers_user)
if not opt.sudoers_filename:
log('--sudoers-file must be set or omitted.')
return 1
sudoers(
user_name=opt.sudoers_user,
no_modify=opt.sudoers_no_modify,
file_name=opt.sudoers_filename
)
if opt.daemon:
opt.syslog = 1
@ -38,23 +40,12 @@ def main():
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
return hostwatch.hw_main(opt.subnets, opt.auto_hosts)
else:
# parse_subnetports() is used to create a list of includes
# and excludes. It is called once for each parameter and
@ -124,10 +115,7 @@ def main():
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:

View File

@ -1,5 +1,4 @@
import errno
import shutil
import socket
import signal
import sys
@ -7,18 +6,13 @@ 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.helpers import log, debug1, debug2, 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'
HOSTSFILE = '/etc/hosts'
sshuttle_pid = None
@ -36,11 +30,7 @@ def rewrite_etc_hosts(hostmap, port):
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)
os.link(HOSTSFILE, BAKFILE)
tmpname = "%s.%d.tmp" % (HOSTSFILE, port)
f = open(tmpname, 'w')
for line in old_content.rstrip().split('\n'):
@ -51,20 +41,13 @@ def rewrite_etc_hosts(hostmap, port):
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)
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, 0o600)
os.rename(tmpname, HOSTSFILE)
def restore_etc_hosts(hostmap, port):
@ -88,17 +71,14 @@ def firewall_exit(signum, frame):
# 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)
debug1("Relaying SIGINT to sshuttle process %d\n" % sshuttle_pid)
os.kill(sshuttle_pid, signal.SIGINT)
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')
# Isolate function that needs to be replaced for tests
def setup_daemon():
if os.getuid() != 0:
raise Fatal('You must be root (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.
@ -119,34 +99,12 @@ def _setup_daemon_for_unix_like():
# setsid() fails if sudo is configured with the use_pty option.
pass
return sys.stdin.buffer, sys.stdout.buffer
# because of limitations of the 'su' command, the *real* stdin/stdout
# are both attached to stdout initially. Clone stdout into stdin so we
# can read from it.
os.dup2(1, 0)
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
return sys.stdin, sys.stdout
# Note that we're sorting in a very particular order:
@ -220,43 +178,29 @@ def main(method_name, syslog):
"PATH." % method_name)
debug1('ready method name %s.' % method.name)
stdout.write(('READY %s\n' % method.name).encode('ASCII'))
stdout.write('READY %s\n' % method.name)
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
line = stdin.readline(128)
if not line:
return # parent died; nothing to do
subnets = []
if line != 'ROUTES':
if line != 'ROUTES\n':
raise Fatal('expected ROUTES but got %r' % line)
while 1:
line = _read_next_string_line()
line = stdin.readline(128)
if not line:
raise Fatal('expected route but got %r' % line)
elif line.startswith("NSLIST"):
elif line.startswith("NSLIST\n"):
break
try:
(family, width, exclude, ip, fport, lport) = line.split(',', 5)
except Exception:
(family, width, exclude, ip, fport, lport) = \
line.strip().split(',', 5)
except BaseException:
raise Fatal('expected route or NSLIST but got %r' % line)
subnets.append((
int(family),
@ -268,17 +212,17 @@ def main(method_name, syslog):
debug2('Got subnets: %r' % subnets)
nslist = []
if line != 'NSLIST':
if line != 'NSLIST\n':
raise Fatal('expected NSLIST but got %r' % line)
while 1:
line = _read_next_string_line()
line = stdin.readline(128)
if not line:
raise Fatal('expected nslist but got %r' % line)
elif line.startswith("PORTS "):
break
try:
(family, ip) = line.split(',', 1)
except Exception:
(family, ip) = line.strip().split(',', 1)
except BaseException:
raise Fatal('expected nslist or PORTS but got %r' % line)
nslist.append((int(family), ip))
debug2('Got partial nslist: %r' % nslist)
@ -295,33 +239,33 @@ def main(method_name, syslog):
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
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 "):
line = stdin.readline(128)
if not line:
raise Fatal('expected GO but got %r' % line)
elif 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, user, tmark, sshuttle_pid = args.strip().split(" ", 3)
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))
debug2('Got udp: %r, user: %r, tmark: %s, sshuttle_pid: %d' %
(udp, user, 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]
@ -336,41 +280,32 @@ def main(method_name, syslog):
method.setup_firewall(
port_v6, dnsport_v6, nslist_v6,
socket.AF_INET6, subnets_v6, udp,
user, group, tmark)
user, 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)
user, tmark)
flush_systemd_dns_cache()
stdout.write('STARTED\n')
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,))
except IOError:
# the parent process died for some reason; he's surely been loud
# enough, so no reason to report another error
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
line = stdin.readline(128)
if line.startswith('HOST '):
(name, ip) = line[5:].split(',', 1)
(name, ip) = line[5:].strip().split(',', 1)
hostmap[name] = ip
debug2('setting up /etc/hosts.')
rewrite_etc_hosts(hostmap, port_v6 or port_v4)
@ -382,47 +317,46 @@ def main(method_name, syslog):
finally:
try:
debug1('undoing changes.')
except Exception:
except BaseException:
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:
method.restore_firewall(port_v6, socket.AF_INET6, udp, user)
except BaseException:
try:
debug1("Error trying to undo IPv6 firewall.")
debug1(traceback.format_exc())
except Exception:
except BaseException:
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:
method.restore_firewall(port_v4, socket.AF_INET, udp, user)
except BaseException:
try:
debug1("Error trying to undo IPv4 firewall.")
debug1(traceback.format_exc())
except Exception:
except BaseException:
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:
except BaseException:
try:
debug1("Error trying to undo /etc/hosts changes.")
debug1(traceback.format_exc())
except Exception:
except BaseException:
debug2('An error occurred, ignoring it.')
if sys.platform == 'linux':
try:
flush_systemd_dns_cache()
except BaseException:
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.")
debug1("Error trying to flush systemd dns cache.")
debug1(traceback.format_exc())
except BaseException:
debug2("An error occurred, ignoring it.")

View File

@ -2,13 +2,6 @@ import sys
import socket
import errno
import os
import threading
import subprocess
import traceback
import re
if sys.platform != "win32":
import fcntl
logprefix = ''
verbose = 0
@ -18,17 +11,10 @@ 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"
@ -36,10 +22,17 @@ def log(s):
prefix = logprefix
s = s.rstrip("\n")
for line in s.split("\n"):
sys.stderr.write(prefix + line + "\n")
# We output with \r\n instead of \n because when we use
# sudo with the use_pty option, the firewall process, the
# other processes printing to the terminal will have the
# \n move to the next line, but they will fail to reset
# cursor to the beginning of the line. Printing output
# with \r\n endings fixes that problem and does not appear
# to cause problems elsewhere.
sys.stderr.write(prefix + line + "\r\n")
prefix = " "
sys.stderr.flush()
except (IOError, ValueError): # ValueError ~ I/O operation on closed file
except IOError:
# 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
@ -116,43 +109,18 @@ def resolvconf_nameservers(systemd_resolved):
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():
def resolvconf_random_nameserver(systemd_resolved):
"""Return a random nameserver selected from servers produced by
resolvconf_nameservers()/windows_nameservers()
resolvconf_nameservers(). See documentation for
resolvconf_nameservers() for a description of the parameter.
"""
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:
lines = resolvconf_nameservers(systemd_resolved)
if lines:
if len(lines) > 1:
# don't import this unless we really need it
import random
random.shuffle(ns_list)
return ns_list[0]
random.shuffle(lines)
return lines[0]
else:
return (socket.AF_INET, '127.0.0.1')
@ -259,91 +227,3 @@ def which(file, mode=os.F_OK | os.X_OK):
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)

View File

@ -18,8 +18,6 @@ CACHEFILE = os.path.expanduser('~/.sshuttle.hosts')
# Have we already failed to write CACHEFILE?
CACHE_WRITE_FAILED = False
SHOULD_WRITE_CACHE = False
hostnames = {}
queue = {}
try:
@ -57,7 +55,7 @@ def write_host_cache():
try:
os.unlink(tmpname)
except Exception:
except BaseException:
pass
@ -83,11 +81,6 @@ def read_host_cache():
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):
@ -104,13 +97,12 @@ def found_host(name, ip):
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
write_host_cache()
def _check_etc_hosts():

View File

@ -20,7 +20,7 @@ def ipt_chain_exists(family, table, name):
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'):
for line in output.decode('ASCII').split('\n'):
if line.startswith('Chain %s ' % name):
return True
except ssubprocess.CalledProcessError as e:

View File

@ -1,13 +1,14 @@
import importlib
import socket
import struct
import sys
import errno
import ipaddress
from sshuttle.helpers import Fatal, debug3
def original_dst(sock):
ip = "0.0.0.0"
port = -1
try:
family = sock.family
SO_ORIGINAL_DST = 80
@ -46,13 +47,11 @@ class BaseMethod(object):
@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
@ -73,8 +72,8 @@ class BaseMethod(object):
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))
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):
@ -92,13 +91,10 @@ class BaseMethod(object):
(key, self.name))
def setup_firewall(self, port, dnsport, nslist, family, subnets, udp,
user, group, tmark):
user, tmark):
raise NotImplementedError()
def restore_firewall(self, port, family, udp, user, group):
raise NotImplementedError()
def wait_for_firewall_ready(self, sshuttle_pid):
def restore_firewall(self, port, family, udp, user):
raise NotImplementedError()
@staticmethod
@ -114,7 +110,7 @@ def get_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"]
methods_to_try = ["nat", "nft", "pf", "ipfw"]
for m in methods_to_try:
method = get_method(m)
if method.is_supported():

View File

@ -52,7 +52,7 @@ def _fill_oldctls(prefix):
p = ssubprocess.Popen(argv, stdout=ssubprocess.PIPE, env=get_env())
for line in p.stdout:
line = line.decode()
assert line[-1] == '\n'
assert(line[-1] == '\n')
(k, v) = line[:-1].split(': ', 1)
_oldctls[k] = v.strip()
rv = p.wait()
@ -74,7 +74,7 @@ _changedctls = []
def sysctl_set(name, val, permanent=False):
PREFIX = 'net.inet.ip'
assert name.startswith(PREFIX + '.')
assert(name.startswith(PREFIX + '.'))
val = str(val)
if not _oldctls:
_fill_oldctls(PREFIX)
@ -156,7 +156,7 @@ class Method(BaseMethod):
# udp_listener.v6.setsockopt(SOL_IPV6, IPV6_RECVDSTADDR, 1)
def setup_firewall(self, port, dnsport, nslist, family, subnets, udp,
user, group, tmark):
user, tmark):
# IPv6 not supported
if family not in [socket.AF_INET]:
raise Exception(
@ -207,7 +207,7 @@ class Method(BaseMethod):
else:
ipfw('table', '126', 'add', '%s/%s' % (snet, swidth))
def restore_firewall(self, port, family, udp, user, group):
def restore_firewall(self, port, family, udp, user):
if family not in [socket.AF_INET]:
raise Exception(
'Address family "%s" unsupported by ipfw method'

View File

@ -13,7 +13,7 @@ class Method(BaseMethod):
# 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):
user, tmark):
if family != socket.AF_INET and family != socket.AF_INET6:
raise Exception(
'Address family "%s" unsupported by nat method_name'
@ -31,18 +31,13 @@ class Method(BaseMethod):
chain = 'sshuttle-%s' % port
# basic cleanup/setup of chains
self.restore_firewall(port, family, udp, user, group)
self.restore_firewall(port, family, udp, user)
_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)
if user is not None:
_ipm('-I', 'OUTPUT', '1', '-m', 'owner', '--uid-owner', str(user),
'-j', 'MARK', '--set-mark', str(port))
args = '-m', 'mark', '--mark', str(port), '-j', chain
else:
args = '-j', chain
@ -59,6 +54,11 @@ class Method(BaseMethod):
'--dport', '53',
'--to-ports', str(dnsport))
# Don't route any remaining local traffic through sshuttle.
_ipt('-A', chain, '-j', 'RETURN',
'-m', 'addrtype',
'--dst-type', 'LOCAL')
# create new subnet entries.
for _, swidth, sexclude, snet, fport, lport \
in sorted(subnets, key=subnet_weight, reverse=True):
@ -75,12 +75,7 @@ class Method(BaseMethod):
'--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):
def restore_firewall(self, port, family, udp, user):
# only ipv4 supported with NAT
if family != socket.AF_INET and family != socket.AF_INET6:
raise Exception(
@ -101,15 +96,9 @@ class Method(BaseMethod):
# 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)
if user is not None:
nonfatal(_ipm, '-D', 'OUTPUT', '-m', 'owner', '--uid-owner',
str(user), '-j', 'MARK', '--set-mark', str(port))
args = '-m', 'mark', '--mark', str(port), '-j', chain
else:
args = '-j', chain
@ -122,7 +111,6 @@ class Method(BaseMethod):
result = super(Method, self).get_supported_features()
result.user = True
result.ipv6 = True
result.group = True
return result
def is_supported(self):

View File

@ -13,7 +13,7 @@ class Method(BaseMethod):
# 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):
user, tmark):
if udp:
raise Exception("UDP not supported by nft")
@ -87,7 +87,7 @@ class Method(BaseMethod):
ip_version, 'daddr %s/%s' % (snet, swidth),
('redirect to :' + str(port)))))
def restore_firewall(self, port, family, udp, user, group):
def restore_firewall(self, port, family, udp, user):
if udp:
raise Exception("UDP not supported by nft method_name")

View File

@ -266,7 +266,7 @@ class OpenBsd(Generic):
("proto_variant", c_uint8),
("direction", c_uint8)]
self.pfioc_rule = c_char * 3408
self.pfioc_rule = c_char * 3424
self.pfioc_natlook = pfioc_natlook
super(OpenBsd, self).__init__()
@ -448,7 +448,7 @@ class Method(BaseMethod):
return sock.getsockname()
def setup_firewall(self, port, dnsport, nslist, family, subnets, udp,
user, group, tmark):
user, tmark):
if family not in [socket.AF_INET, socket.AF_INET6]:
raise Exception(
'Address family "%s" unsupported by pf method_name'
@ -473,7 +473,7 @@ class Method(BaseMethod):
pf.add_rules(anchor, includes, port, dnsport, nslist, family)
pf.enable()
def restore_firewall(self, port, family, udp, user, group):
def restore_firewall(self, port, family, udp, user):
if family not in [socket.AF_INET, socket.AF_INET6]:
raise Exception(
'Address family "%s" unsupported by pf method_name'

View File

@ -114,7 +114,7 @@ class Method(BaseMethod):
udp_listener.v6.setsockopt(SOL_IPV6, IPV6_RECVORIGDSTADDR, 1)
def setup_firewall(self, port, dnsport, nslist, family, subnets, udp,
user, group, tmark):
user, tmark):
if family not in [socket.AF_INET, socket.AF_INET6]:
raise Exception(
'Address family "%s" unsupported by tproxy method'
@ -127,14 +127,14 @@ class Method(BaseMethod):
def _ipt_proto_ports(proto, fport, lport):
return proto + ('--dport', '%d:%d' % (fport, lport)) \
if fport else proto
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)
self.restore_firewall(port, family, udp, user)
_ipt('-N', mark_chain)
_ipt('-F', mark_chain)
@ -145,18 +145,8 @@ class Method(BaseMethod):
_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).
# through the tproxy or mark chains.
#
# Without this fix, if a large subnet is redirected through
# sshuttle (i.e., 0/0), then the user may be unable to receive
@ -179,6 +169,16 @@ class Method(BaseMethod):
_ipt('-A', tproxy_chain, '-m', 'socket', '-j', divert_chain,
'-m', 'udp', '-p', 'udp')
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))
for _, swidth, sexclude, snet, fport, lport \
in sorted(subnets, key=subnet_weight, reverse=True):
tcp_ports = ('-p', 'tcp')
@ -228,7 +228,7 @@ class Method(BaseMethod):
'-m', 'udp',
*(udp_ports + ('--on-port', str(port))))
def restore_firewall(self, port, family, udp, user, group):
def restore_firewall(self, port, family, udp, user):
if family not in [socket.AF_INET, socket.AF_INET6]:
raise Exception(
'Address family "%s" unsupported by tproxy method'

View File

@ -1,533 +0,0 @@
import 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"<Pkt {r}>"
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"<ConnTrack(n={len(self.used_slots) if self.is_owner else '?'},cap={len(self.shm_list)},owner={self.is_owner})>"
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()

View File

@ -1,40 +0,0 @@
import 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)

View File

@ -1,6 +1,5 @@
import re
import socket
import sys
from argparse import ArgumentParser, Action, ArgumentTypeError as Fatal
from sshuttle import __version__
@ -38,9 +37,9 @@ def parse_subnetport_file(s):
def parse_subnetport(s):
if s.count(':') > 1:
rx = r'(?:\[?(?:\*\.)?([\w\:]+)(?:/(\d+))?]?)(?::(\d+)(?:-(\d+))?)?$'
rx = r'(?:\[?([\w\:]+)(?:/(\d+))?]?)(?::(\d+)(?:-(\d+))?)?$'
else:
rx = r'((?:\*\.)?[\w\.\-]+)(?:/(\d+))?(?::(\d+)(?:-(\d+))?)?$'
rx = r'([\w\.\-]+)(?:/(\d+))?(?::(\d+)(?:-(\d+))?)?$'
m = re.match(rx, s)
if not m:
@ -137,15 +136,6 @@ def parse_list(lst):
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:
@ -244,14 +234,9 @@ parser.add_argument(
"""
)
if sys.platform == 'win32':
method_choices = ["auto", "windivert"]
else:
method_choices = ["auto", "nft", "nat", "tproxy", "pf", "ipfw"]
parser.add_argument(
"--method",
choices=method_choices,
choices=["auto", "nat", "nft", "tproxy", "pf", "ipfw"],
metavar="TYPE",
default="auto",
help="""
@ -316,22 +301,6 @@ parser.add_argument(
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]",
@ -413,12 +382,6 @@ parser.add_argument(
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",
@ -433,15 +396,18 @@ parser.add_argument(
(internal use only)
"""
)
parser.add_argument(
"--sudoers",
action="store_true",
help="""
Add sshuttle to the sudoers for this user
"""
)
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.
Prints the sudoers config to STDOUT and DOES NOT modify anything.
"""
)
parser.add_argument(
@ -449,7 +415,16 @@ parser.add_argument(
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.
Default is the current user.set ALL for all users. Only works with
--sudoers or --sudoers-no-modify option.
"""
)
parser.add_argument(
"--sudoers-filename",
default="sshuttle_auto",
help="""
Set the file name for the sudoers.d file to be added. Default is
"sshuttle_auto". Only works with --sudoers or --sudoers-no-modify option.
"""
)
parser.add_argument(
@ -469,20 +444,3 @@ parser.add_argument(
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."""
)

View File

@ -5,7 +5,6 @@ import traceback
import time
import sys
import os
import io
import sshuttle.ssnet as ssnet
@ -14,7 +13,7 @@ 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
resolvconf_random_nameserver, which, get_env
def _ipmatch(ipstr):
@ -35,6 +34,7 @@ def _ipmatch(ipstr):
elif g[3] is None:
ips += '.0'
width = min(width, 24)
ips = ips
return (struct.unpack('!I', socket.inet_aton(ips))[0], width)
@ -79,20 +79,6 @@ def _route_iproute(line):
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())
@ -115,17 +101,14 @@ def _list_routes(argv, extract_route):
def list_routes():
if sys.platform == 'win32':
routes = _list_routes(['route', 'PRINT', '-4'], _route_windows)
if which('ip'):
routes = _list_routes(['ip', 'route'], _route_iproute)
elif which('netstat'):
routes = _list_routes(['netstat', '-rn'], _route_netstat)
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 = []
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.'):
@ -199,7 +182,7 @@ class DnsProxy(Handler):
self.tries += 1
if self.to_nameserver is None:
_, peer = get_random_nameserver()
_, peer = resolvconf_random_nameserver(False)
port = 53
else:
peer = self.to_ns_peer
@ -299,16 +282,7 @@ def main(latency_control, latency_buffer_size, auto_hosts, to_nameserver,
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)
mux = Mux(sys.stdin, sys.stdout)
handlers.append(mux)
debug1('auto-nets:' + str(auto_nets))
@ -329,7 +303,7 @@ def main(latency_control, latency_buffer_size, auto_hosts, to_nameserver,
hw.leftover = b('')
def hostwatch_ready(sock):
assert hw.pid
assert(hw.pid)
content = hw.sock.recv(4096)
if content:
lines = (hw.leftover + content).split(b('\n'))
@ -407,7 +381,7 @@ def main(latency_control, latency_buffer_size, auto_hosts, to_nameserver,
while mux.ok:
if hw.pid:
assert hw.pid > 0
assert(hw.pid > 0)
(rpid, rv) = os.waitpid(hw.pid, os.WNOHANG)
if rpid:
raise Fatal(

View File

@ -12,7 +12,7 @@ import ipaddress
from urllib.parse import urlparse
import sshuttle.helpers as helpers
from sshuttle.helpers import debug2, which, get_path, SocketRWShim, Fatal
from sshuttle.helpers import debug2, which, get_path, Fatal
def get_module_source(name):
@ -56,7 +56,7 @@ def parse_hostport(rhostport):
# Fix #410 bad username error detect
if ":" in username:
# this will even allow for the username to be empty
username, password = username.split(":", 1)
username, password = username.split(":")
if ":" in host:
# IPv6 address and/or got a port specified
@ -84,7 +84,7 @@ def parse_hostport(rhostport):
return username, password, port, host
def connect(ssh_cmd, rhostport, python, stderr, add_cmd_delimiter, remote_shell, options):
def connect(ssh_cmd, rhostport, python, stderr, options):
username, password, port, host = parse_hostport(rhostport)
if username:
rhost = "{}@{}".format(username, host)
@ -115,8 +115,8 @@ def connect(ssh_cmd, rhostport, python, stderr, add_cmd_delimiter, remote_shell,
pyscript = r"""
import sys, os;
verbosity=%d;
stdin = os.fdopen(0, 'rb');
exec(compile(stdin.read(%d), 'assembler.py', 'exec'));
sys.stdin = os.fdopen(0, "rb");
exec(compile(sys.stdin.read(%d), "assembler.py", "exec"));
sys.exit(98);
""" % (helpers.verbose or 0, len(content))
pyscript = re.sub(r'\s+', ' ', pyscript.strip())
@ -134,72 +134,62 @@ def connect(ssh_cmd, rhostport, python, stderr, add_cmd_delimiter, remote_shell,
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 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.
pycmd = ("P=python3; $P -V 2>%s || P=python; "
"exec \"$P\" -c %s; exit 97") % \
(os.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])
portl +
[rhost, '--', pycmd])
else:
argv = (sshl + portl + [rhost])
if add_cmd_delimiter:
argv += ['--', pycmd]
else:
argv += [pycmd]
argv = (sshl +
portl +
[rhost, '--', pycmd])
# Our which() function searches for programs in get_path()
# directories (which include PATH). This step isn't strictly
@ -211,45 +201,19 @@ def connect(ssh_cmd, rhostport, python, stderr, add_cmd_delimiter, remote_shell,
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())
(s1, s2) = socket.socketpair()
def preexec_fn():
# runs in the child process
s2.close()
s1.close()
def setup():
# runs in the child process
s2.close()
s1a, s1b = os.dup(s1.fileno()), os.dup(s1.fileno())
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
debug2('executing: %r' % argv)
p = ssubprocess.Popen(argv, stdin=s1a, stdout=s1b, preexec_fn=setup,
close_fds=True, stderr=stderr)
os.close(s1a)
os.close(s1b)
s2.sendall(content)
s2.sendall(content2)
return p, s2

View File

@ -4,8 +4,9 @@ import socket
import errno
import select
import os
import fcntl
from sshuttle.helpers import b, log, debug1, debug2, debug3, Fatal, set_non_blocking_io
from sshuttle.helpers import b, log, debug1, debug2, debug3, Fatal
MAX_CHANNEL = 65535
LATENCY_BUFFER_SIZE = 32768
@ -77,8 +78,7 @@ def _fds(socks):
def _nb_clean(func, *args):
try:
return func(*args)
except (OSError, socket.error):
# Note: In python2 socket.error != OSError (In python3, they are same)
except OSError:
_, e = sys.exc_info()[:2]
if e.errno not in (errno.EWOULDBLOCK, errno.EAGAIN):
raise
@ -168,25 +168,19 @@ class SockWrapper:
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
# 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)
# connected successfully (BSD)
self.connect_to = None
elif e.args[0] in NET_ERRS + [errno.EACCES, errno.EPERM]:
# a "normal" kind of error
@ -199,6 +193,7 @@ class SockWrapper:
if not self.shut_read:
debug2('%r: done reading' % self)
self.shut_read = True
# self.rsock.shutdown(SHUT_RD) # doesn't do anything anyway
def nowrite(self):
if not self.shut_write:
@ -219,7 +214,7 @@ class SockWrapper:
return 0 # still connecting
self.wsock.setblocking(False)
try:
return _nb_clean(self.wsock.send, buf)
return _nb_clean(os.write, self.wsock.fileno(), buf)
except OSError:
_, e = sys.exc_info()[:2]
if e.errno == errno.EPIPE:
@ -232,7 +227,7 @@ class SockWrapper:
return 0
def write(self, buf):
assert buf
assert(buf)
return self.uwrite(buf)
def uread(self):
@ -242,7 +237,7 @@ class SockWrapper:
return
self.rsock.setblocking(False)
try:
return _nb_clean(self.rsock.recv, 65536)
return _nb_clean(os.read, self.rsock.fileno(), 65536)
except OSError:
_, e = sys.exc_info()[:2]
self.seterr('uread: %s' % e)
@ -378,6 +373,11 @@ class Mux(Handler):
if not self.too_full:
self.send(0, CMD_PING, b('rttest'))
self.too_full = True
# ob = []
# for b in self.outbuf:
# (s1,s2,c) = struct.unpack('!ccH', b[:4])
# ob.append(c)
# log('outbuf: %d %r' % (self.amount_queued(), ob))
def send(self, channel, cmd, data):
assert isinstance(data, bytes)
@ -388,13 +388,11 @@ class Mux(Handler):
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:
@ -404,15 +402,15 @@ class Mux(Handler):
elif cmd == CMD_EXIT:
self.ok = False
elif cmd == CMD_TCP_CONNECT:
assert not self.channels.get(channel)
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)
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)
assert(not self.channels.get(channel))
if self.got_udp_open:
self.got_udp_open(channel, data)
elif cmd == CMD_ROUTES:
@ -439,10 +437,15 @@ class Mux(Handler):
callback(cmd, data)
def flush(self):
set_non_blocking_io(self.wfile.fileno())
try:
os.set_blocking(self.wfile.fileno(), False)
except AttributeError:
# python < 3.5
flags = fcntl.fcntl(self.wfile.fileno(), fcntl.F_GETFL)
flags |= os.O_NONBLOCK
flags = fcntl.fcntl(self.wfile.fileno(), fcntl.F_SETFL, flags)
if self.outbuf and self.outbuf[0]:
wrote = _nb_clean(self.wfile.write, self.outbuf[0])
# self.wfile.flush()
wrote = _nb_clean(os.write, self.wfile.fileno(), self.outbuf[0])
debug2('mux wrote: %r/%d' % (wrote, len(self.outbuf[0])))
if wrote:
self.outbuf[0] = self.outbuf[0][wrote:]
@ -450,12 +453,18 @@ class Mux(Handler):
self.outbuf[0:1] = []
def fill(self):
set_non_blocking_io(self.rfile.fileno())
try:
os.set_blocking(self.rfile.fileno(), False)
except AttributeError:
# python < 3.5
flags = fcntl.fcntl(self.rfile.fileno(), fcntl.F_GETFL)
flags |= os.O_NONBLOCK
flags = fcntl.fcntl(self.rfile.fileno(), fcntl.F_SETFL, flags)
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))
read = _nb_clean(os.read, self.rfile.fileno(),
min(1048576, LATENCY_BUFFER_SIZE))
except OSError:
_, e = sys.exc_info()[:2]
raise Fatal('other end: %r' % e)
@ -467,12 +476,14 @@ class Mux(Handler):
def handle(self):
self.fill()
# log('inbuf is: (%d,%d) %r'
# % (self.want, len(self.inbuf), self.inbuf))
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')
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]

View File

@ -10,7 +10,7 @@ def start_syslog():
global _p
with open(os.devnull, 'w') as devnull:
_p = ssubprocess.Popen(
['logger', '-p', 'daemon.err', '-t', 'sshuttle'],
['logger', '-p', 'daemon.notice', '-t', 'sshuttle'],
stdin=ssubprocess.PIPE,
stdout=devnull,
stderr=devnull

89
sshuttle/stresstest.py Executable file
View File

@ -0,0 +1,89 @@
#!/usr/bin/env python
import socket
import select
import struct
import time
listener = socket.socket()
listener.bind(('127.0.0.1', 0))
listener.listen(500)
servers = []
clients = []
remain = {}
NUMCLIENTS = 50
count = 0
while 1:
if len(clients) < NUMCLIENTS:
c = socket.socket()
c.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
c.bind(('0.0.0.0', 0))
c.connect(listener.getsockname())
count += 1
if count >= 16384:
count = 1
print('cli CREATING %d' % count)
b = struct.pack('I', count) + 'x' * count
remain[c] = count
print('cli >> %r' % len(b))
c.send(b)
c.shutdown(socket.SHUT_WR)
clients.append(c)
r = [listener]
time.sleep(0.1)
else:
r = [listener] + servers + clients
print('select(%d)' % len(r))
r, w, x = select.select(r, [], [], 5)
assert(r)
for i in r:
if i == listener:
s, addr = listener.accept()
servers.append(s)
elif i in servers:
b = i.recv(4096)
print('srv << %r' % len(b))
if i not in remain:
assert(len(b) >= 4)
want = struct.unpack('I', b[:4])[0]
b = b[4:]
# i.send('y'*want)
else:
want = remain[i]
if want < len(b):
print('weird wanted %d bytes, got %d: %r' % (want, len(b), b))
assert(want >= len(b))
want -= len(b)
remain[i] = want
if not b: # EOF
if want:
print('weird: eof but wanted %d more' % want)
assert(want == 0)
i.close()
servers.remove(i)
del remain[i]
else:
print('srv >> %r' % len(b))
i.send('y' * len(b))
if not want:
i.shutdown(socket.SHUT_WR)
elif i in clients:
b = i.recv(4096)
print('cli << %r' % len(b))
want = remain[i]
if want < len(b):
print('weird wanted %d bytes, got %d: %r' % (want, len(b), b))
assert(want >= len(b))
want -= len(b)
remain[i] = want
if not b: # EOF
if want:
print('weird: eof but wanted %d more' % want)
assert(want == 0)
i.close()
clients.remove(i)
del remain[i]
listener.accept()

View File

@ -2,44 +2,70 @@ import os
import sys
import getpass
from uuid import uuid4
from subprocess import Popen, PIPE
from sshuttle.helpers import log, debug1
from distutils import spawn
path_to_sshuttle = sys.argv[0]
path_to_dist_packages = os.path.dirname(os.path.abspath(__file__))[:-9]
# randomize command alias to avoid collisions
command_alias = 'SSHUTTLE%(num)s' % {'num': uuid4().hex[-3:].upper()}
# Template for the sudoers file
template = '''
Cmnd_Alias %(ca)s = /usr/bin/env PYTHONPATH=%(dist_packages)s %(py)s %(path)s *
%(user_name)s ALL=NOPASSWD: %(ca)s
'''
warning_msg = "# WARNING: When you allow a user to run sshuttle as root,\n" \
"# they can then use sshuttle's --ssh-cmd option to run any\n" \
"# command as root.\n"
def build_config(user_name):
"""Generates a sudoers configuration to allow passwordless execution of sshuttle."""
content = warning_msg
content += template % {
'ca': command_alias,
'dist_packages': path_to_dist_packages,
'py': sys.executable,
'path': path_to_sshuttle,
'user_name': user_name,
}
argv0 = os.path.abspath(sys.argv[0])
is_python_script = argv0.endswith('.py')
executable = f"{sys.executable} {argv0}" if is_python_script else argv0
dist_packages = os.path.dirname(os.path.abspath(__file__))
cmd_alias = f"SSHUTTLE{uuid4().hex[-3:].upper()}"
template = f"""
# 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 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 {cmd_alias} = /usr/bin/env PYTHONPATH={dist_packages} {executable} *
{user_name} ALL=NOPASSWD: {cmd_alias}
"""
return template
return content
def sudoers(user_name=None):
def save_config(content, file_name):
process = Popen([
'/usr/bin/sudo',
spawn.find_executable('sudoers-add'),
file_name,
], stdout=PIPE, stdin=PIPE)
process.stdin.write(content.encode())
streamdata = process.communicate()[0]
sys.stdout.write(streamdata.decode("ASCII"))
returncode = process.returncode
if returncode:
log('Failed updating sudoers file.')
debug1(streamdata)
exit(returncode)
else:
log('Success, sudoers file update.')
exit(0)
def sudoers(user_name=None, no_modify=None, file_name=None):
user_name = user_name or getpass.getuser()
content = build_config(user_name)
sys.stdout.write(content)
exit(0)
if no_modify:
sys.stdout.write(content)
exit(0)
else:
sys.stdout.write(warning_msg)
save_config(content, file_name)

View File

@ -1,16 +1,12 @@
import 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
stdin = io.StringIO(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
@ -19,9 +15,9 @@ NSLIST
{inet},1.2.3.33
{inet6},2404:6800:4004:80c::33
PORTS 1024,1025,1026,1027
GO 1 - - 0x01 12345
GO 1 - 0x01 12345
HOST 1.2.3.3,existing
""".format(inet=AF_INET, inet6=AF_INET6).encode('ASCII'))
""".format(inet=AF_INET, inet6=AF_INET6))
stdout = Mock()
return stdin, stdout
@ -63,21 +59,6 @@ def test_rewrite_etc_hosts(tmpdir):
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),
@ -127,9 +108,9 @@ def test_main(mock_get_method, mock_setup_daemon, mock_rewrite_etc_hosts):
]
assert stdout.mock_calls == [
call.write(b'READY test\n'),
call.write('READY test\n'),
call.flush(),
call.write(b'STARTED\n'),
call.write('STARTED\n'),
call.flush()
]
assert mock_setup_daemon.mock_calls == [call()]
@ -142,22 +123,19 @@ def test_main(mock_get_method, mock_setup_daemon, mock_rewrite_etc_hosts):
[(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)],
(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)],
(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),
call().restore_firewall(1024, AF_INET6, True, None),
call().restore_firewall(1025, AF_INET, True, None),
]

View File

@ -24,19 +24,19 @@ def test_log(mock_stderr, mock_stdout):
call.flush(),
]
assert mock_stderr.mock_calls == [
call.write('prefix: message\n'),
call.write('prefix: message\r\n'),
call.flush(),
call.write('prefix: abc\n'),
call.write('prefix: abc\r\n'),
call.flush(),
call.write('prefix: message 1\n'),
call.write('prefix: message 1\r\n'),
call.flush(),
call.write('prefix: message 2\n'),
call.write(' line2\n'),
call.write(' line3\n'),
call.write('prefix: message 2\r\n'),
call.write(' line2\r\n'),
call.write(' line3\r\n'),
call.flush(),
call.write('prefix: message 3\n'),
call.write(' line2\n'),
call.write(' line3\n'),
call.write('prefix: message 3\r\n'),
call.write(' line2\r\n'),
call.write(' line3\r\n'),
call.flush(),
]
@ -51,7 +51,7 @@ def test_debug1(mock_stderr, mock_stdout):
call.flush(),
]
assert mock_stderr.mock_calls == [
call.write('prefix: message\n'),
call.write('prefix: message\r\n'),
call.flush(),
]
@ -76,7 +76,7 @@ def test_debug2(mock_stderr, mock_stdout):
call.flush(),
]
assert mock_stderr.mock_calls == [
call.write('prefix: message\n'),
call.write('prefix: message\r\n'),
call.flush(),
]
@ -101,7 +101,7 @@ def test_debug3(mock_stderr, mock_stdout):
call.flush(),
]
assert mock_stderr.mock_calls == [
call.write('prefix: message\n'),
call.write('prefix: message\r\n'),
call.flush(),
]
@ -143,7 +143,7 @@ nameserver 2404:6800:4004:80c::4
@patch('sshuttle.helpers.open', create=True)
def test_get_random_nameserver(mock_open):
def test_resolvconf_random_nameserver(mock_open):
mock_open.return_value = io.StringIO(u"""
# Generated by NetworkManager
search pri
@ -156,7 +156,7 @@ nameserver 2404:6800:4004:80c::2
nameserver 2404:6800:4004:80c::3
nameserver 2404:6800:4004:80c::4
""")
ns = sshuttle.helpers.get_random_nameserver()
ns = sshuttle.helpers.resolvconf_random_nameserver(False)
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'),
@ -192,4 +192,5 @@ def test_family_ip_tuple():
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)
expected = 'AddressFamily.AF_UNIX'
assert sshuttle.helpers.family_to_string(socket.AF_UNIX) == expected

View File

@ -81,7 +81,7 @@ def test_assert_features():
def test_firewall_command():
method = get_method('nat')
assert not method.firewall_command("something")
assert not method.firewall_command("somthing")
@patch('sshuttle.methods.nat.ipt')
@ -101,7 +101,6 @@ def test_setup_firewall(mock_ipt_chain_exists, mock_ipt):
(AF_INET6, 128, True, u'2404:6800:4004:80c::101f', 80, 80)],
False,
None,
None,
'0x01')
assert mock_ipt_chain_exists.mock_calls == [
@ -119,14 +118,14 @@ def test_setup_firewall(mock_ipt_chain_exists, mock_ipt):
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',
'-m', 'addrtype', '--dst-type', 'LOCAL'),
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')
'--to-ports', '1024')
]
mock_ipt_chain_exists.reset_mock()
mock_ipt.reset_mock()
@ -143,7 +142,6 @@ def test_setup_firewall(mock_ipt_chain_exists, mock_ipt):
(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 == []
@ -157,7 +155,6 @@ def test_setup_firewall(mock_ipt_chain_exists, mock_ipt):
(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')
@ -174,18 +171,18 @@ def test_setup_firewall(mock_ipt_chain_exists, mock_ipt):
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',
'-m', 'addrtype', '--dst-type', 'LOCAL'),
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'),
'--to-ports', '1025')
]
mock_ipt_chain_exists.reset_mock()
mock_ipt.reset_mock()
method.restore_firewall(1025, AF_INET, False, None, None)
method.restore_firewall(1025, AF_INET, False, None)
assert mock_ipt_chain_exists.mock_calls == [
call(AF_INET, 'nat', 'sshuttle-1025')
]
@ -200,7 +197,7 @@ def test_setup_firewall(mock_ipt_chain_exists, mock_ipt):
mock_ipt_chain_exists.reset_mock()
mock_ipt.reset_mock()
method.restore_firewall(1025, AF_INET6, False, None, None)
method.restore_firewall(1025, AF_INET6, False, None)
assert mock_ipt_chain_exists.mock_calls == [
call(AF_INET6, 'nat', 'sshuttle-1025')
]

View File

@ -92,7 +92,7 @@ def test_assert_features():
@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")
assert not method.firewall_command("somthing")
command = "QUERY_PF_NAT %d,%d,%s,%d,%s,%d\n" % (
AF_INET, socket.IPPROTO_TCP,
@ -115,7 +115,7 @@ def test_firewall_command_darwin(mock_pf_get_dev, mock_ioctl, mock_stdout):
@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")
assert not method.firewall_command("somthing")
command = "QUERY_PF_NAT %d,%d,%s,%d,%s,%d\n" % (
AF_INET, socket.IPPROTO_TCP,
@ -138,7 +138,7 @@ def test_firewall_command_freebsd(mock_pf_get_dev, mock_ioctl, mock_stdout):
@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")
assert not method.firewall_command("somthing")
command = "QUERY_PF_NAT %d,%d,%s,%d,%s,%d\n" % (
AF_INET, socket.IPPROTO_TCP,
@ -187,7 +187,6 @@ def test_setup_firewall_darwin(mock_pf_get_dev, mock_ioctl, mock_pfctl):
(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),
@ -228,7 +227,6 @@ def test_setup_firewall_darwin(mock_pf_get_dev, mock_ioctl, mock_pfctl):
(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 == []
@ -243,7 +241,6 @@ def test_setup_firewall_darwin(mock_pf_get_dev, mock_ioctl, mock_pfctl):
(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),
@ -273,7 +270,7 @@ def test_setup_firewall_darwin(mock_pf_get_dev, mock_ioctl, mock_pfctl):
mock_ioctl.reset_mock()
mock_pfctl.reset_mock()
method.restore_firewall(1025, AF_INET, False, None, None)
method.restore_firewall(1025, AF_INET, False, None)
assert mock_ioctl.mock_calls == []
assert mock_pfctl.mock_calls == [
call('-a sshuttle-1025 -F all'),
@ -305,7 +302,6 @@ def test_setup_firewall_freebsd(mock_pf_get_dev, mock_ioctl, mock_pfctl,
(AF_INET6, 128, True, u'2404:6800:4004:80c::101f', 8080, 8080)],
False,
None,
None,
'0x01')
assert mock_pfctl.mock_calls == [
@ -339,7 +335,6 @@ def test_setup_firewall_freebsd(mock_pf_get_dev, mock_ioctl, mock_pfctl,
(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 == []
@ -354,7 +349,6 @@ def test_setup_firewall_freebsd(mock_pf_get_dev, mock_ioctl, mock_pfctl,
(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),
@ -382,8 +376,8 @@ def test_setup_firewall_freebsd(mock_pf_get_dev, mock_ioctl, mock_pfctl,
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)
method.restore_firewall(1025, AF_INET, False, None)
method.restore_firewall(1024, AF_INET6, False, None)
assert mock_ioctl.mock_calls == []
assert mock_pfctl.mock_calls == [
call('-a sshuttle-1025 -F all'),
@ -414,12 +408,11 @@ def test_setup_firewall_openbsd(mock_pf_get_dev, mock_ioctl, mock_pfctl):
(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),
call(mock_pf_get_dev(), 0xcd60441a, ANY),
call(mock_pf_get_dev(), 0xcd60441a, ANY),
]
assert mock_pfctl.mock_calls == [
call('-s Interfaces -i lo -v'),
@ -452,7 +445,6 @@ def test_setup_firewall_openbsd(mock_pf_get_dev, mock_ioctl, mock_pfctl):
(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 == []
@ -467,11 +459,10 @@ def test_setup_firewall_openbsd(mock_pf_get_dev, mock_ioctl, mock_pfctl):
(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),
call(mock_pf_get_dev(), 0xcd60441a, ANY),
call(mock_pf_get_dev(), 0xcd60441a, ANY),
]
assert mock_pfctl.mock_calls == [
call('-s Interfaces -i lo -v'),
@ -493,8 +484,8 @@ def test_setup_firewall_openbsd(mock_pf_get_dev, mock_ioctl, mock_pfctl):
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)
method.restore_firewall(1025, AF_INET, False, None)
method.restore_firewall(1024, AF_INET6, False, None)
assert mock_ioctl.mock_calls == []
assert mock_pfctl.mock_calls == [
call('-a sshuttle-1025 -F all'),

View File

@ -78,7 +78,7 @@ def test_assert_features():
def test_firewall_command():
method = get_method('tproxy')
assert not method.firewall_command("something")
assert not method.firewall_command("somthing")
@patch('sshuttle.methods.tproxy.ipt')
@ -98,7 +98,6 @@ def test_setup_firewall(mock_ipt_chain_exists, mock_ipt):
(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'),
@ -123,13 +122,6 @@ def test_setup_firewall(mock_ipt_chain_exists, mock_ipt):
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',
@ -141,6 +133,13 @@ def test_setup_firewall(mock_ipt_chain_exists, mock_ipt):
'-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', '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-m-1024', '-j', 'RETURN',
'--dest', u'2404:6800:4004:80c::101f/128',
'-m', 'tcp', '-p', 'tcp', '--dport', '8080:8080'),
@ -173,7 +172,7 @@ def test_setup_firewall(mock_ipt_chain_exists, mock_ipt):
mock_ipt_chain_exists.reset_mock()
mock_ipt.reset_mock()
method.restore_firewall(1025, AF_INET6, True, None, None)
method.restore_firewall(1025, AF_INET6, True, None)
assert mock_ipt_chain_exists.mock_calls == [
call(AF_INET6, 'mangle', 'sshuttle-m-1025'),
call(AF_INET6, 'mangle', 'sshuttle-t-1025'),
@ -202,7 +201,6 @@ def test_setup_firewall(mock_ipt_chain_exists, mock_ipt):
(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'),
@ -227,12 +225,6 @@ def test_setup_firewall(mock_ipt_chain_exists, mock_ipt):
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',
@ -244,6 +236,12 @@ def test_setup_firewall(mock_ipt_chain_exists, mock_ipt):
'-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', '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-m-1025', '-j', 'RETURN',
'--dest', u'1.2.3.66/32', '-m', 'tcp', '-p', 'tcp',
'--dport', '80:80'),
@ -272,7 +270,7 @@ def test_setup_firewall(mock_ipt_chain_exists, mock_ipt):
mock_ipt_chain_exists.reset_mock()
mock_ipt.reset_mock()
method.restore_firewall(1025, AF_INET, True, None, None)
method.restore_firewall(1025, AF_INET, True, None)
assert mock_ipt_chain_exists.mock_calls == [
call(AF_INET, 'mangle', 'sshuttle-m-1025'),
call(AF_INET, 'mangle', 'sshuttle-t-1025'),

View File

@ -1,6 +1,5 @@
import socket
from argparse import ArgumentTypeError as Fatal
from unittest.mock import patch
import pytest
@ -28,23 +27,6 @@ _ip6_reprs = {
_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) \
@ -123,86 +105,3 @@ def test_parse_subnetport_ip6_with_mask_and_port():
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)

View File

@ -1,16 +1,17 @@
[tox]
downloadcache = {toxworkdir}/cache/
envlist =
py36,
py37,
py38,
py39,
py310,
[testenv]
basepython =
py36: python3.6
py37: python3.7
py38: python3.8
py39: python3.9
py310: python3.10
py311: python3.11
py312: python3.12
commands =
pip install -e .
# actual flake8 test

1425
uv.lock generated

File diff suppressed because it is too large Load Diff