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 version: 2
enable-beta-ecosystems: true
updates: updates:
- package-ecosystem: uv - package-ecosystem: pip
directory: "/" directory: "/"
schedule: schedule:
interval: daily 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 ] branches: [ master ]
pull_request: pull_request:
branches: [ master ] branches: [ master ]
workflow_dispatch: {} workflow_dispatch:
branches: [ master ]
jobs: jobs:
build: build:
@ -16,23 +17,21 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: ["3.9", "3.10", "3.11", "3.12"] python-version: [3.6, 3.7, 3.8, 3.9, "3.10"]
poetry-version: ["main"]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v2.4.0
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5 uses: actions/setup-python@v2.3.1
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- name: Install uv - name: Install dependencies
uses: astral-sh/setup-uv@v6 run: |
with: python -m pip install --upgrade pip
version: "0.4.30" pip install -r requirements-tests.txt
enable-cache: true
cache-dependency-glob: "uv.lock"
- name: Install the project
run: uv sync --all-extras --dev
- name: Lint with flake8 - name: Lint with flake8
run: uv run flake8 sshuttle tests --count --show-source --statistics run: |
- name: Run the automated tests flake8 sshuttle tests --count --show-source --statistics
run: uv run pytest -v - 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/ /tmp/
/.coverage
/.cache/ /.cache/
/.eggs/ /.eggs/
/.tox/ /.tox/
@ -15,6 +15,4 @@
/.redo /.redo
/.pytest_cache/ /.pytest_cache/
/.python-version /.python-version
/.direnv/ .vscode/
/result
/.vscode/

View File

@ -3,11 +3,13 @@ version: 2
build: build:
os: ubuntu-20.04 os: ubuntu-20.04
tools: tools:
python: "3.10" python: "3.9"
jobs:
post_install:
- pip install uv
- UV_PROJECT_ENVIRONMENT=$READTHEDOCS_VIRTUALENV_PATH uv sync --all-extras --group docs --link-mode=copy
sphinx: 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 As far as I know, sshuttle is the only program that solves the following
common case: 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. - You have access to a remote network via ssh.
@ -30,9 +30,80 @@ common case:
Obtaining sshuttle 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 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 sys
import os import os
sys.path.insert(0, os.path.abspath('..')) 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, # 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 # add these directories to sys.path here. If the directory is relative to the
@ -56,7 +56,7 @@ copyright = '2016, Brian May'
# built documents. # built documents.
# #
# The full version, including alpha/beta/rc tags. # The full version, including alpha/beta/rc tags.
release = sshuttle.__version__ release = sshuttle.version.version
# The short X.Y version. # The short X.Y version.
version = '.'.join(release.split('.')[:2]) 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 # The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes. # 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 # 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 # further. For a list of options available for each theme, see the

View File

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

View File

@ -181,18 +181,6 @@ Options
in a non-standard location or you want to provide extra in a non-standard location or you want to provide extra
options to the ssh command, for example, ``-e 'ssh -v'``. 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 .. option:: --seed-hosts
A comma-separated list of hostnames to use to A comma-separated list of hostnames to use to
@ -254,8 +242,8 @@ Options
.. option:: --disable-ipv6 .. option:: --disable-ipv6
Disable IPv6 support for methods that support it (nat, nft, Disable IPv6 support for methods that support it (nft, tproxy, and
tproxy, and pf). pf).
.. option:: --firewall .. option:: --firewall
@ -274,23 +262,28 @@ Options
makes it a lot easier to debug and test the :option:`--auto-hosts` makes it a lot easier to debug and test the :option:`--auto-hosts`
feature. 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 .. option:: --sudoers-no-modify
sshuttle prints a configuration to stdout which allows a user to sshuttle will auto generate the proper sudoers.d config and print it to
run sshuttle without a password. This option is INSECURE because, stdout. The option will not modify the system at all.
with some cleverness, it also allows the user to run any command
as root without a password. The output also includes a suggested
method for you to install the configuration.
Use --sudoers-user to modify the user that it applies to.
.. option:: --sudoers-user .. option:: --sudoers-user
Set the user name or group with %group_name for passwordless Set the user name or group with %group_name for passwordless operation.
operation. Default is the current user. Set to ALL for all users Default is the current user.set ALL for all users. Only works with
(NOT RECOMMENDED: See note about security in --sudoers-no-modify --sudoers or --sudoers-no-modify option.
documentation above). Only works with the --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> .. option:: -t <mark>, --tmark=<mark>
@ -333,18 +326,6 @@ annotations. For example::
192.168.63.0/24 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 Examples
-------- --------
@ -479,7 +460,7 @@ Packet-level forwarding (eg. using the tun/tap devices on
Linux) seems elegant at first, but it results in Linux) seems elegant at first, but it results in
several problems, notably the 'tcp over tcp' problem. The several problems, notably the 'tcp over tcp' problem. The
tcp protocol depends fundamentally on packets being dropped 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 you pass tcp packets through a tcp-based tunnel (such as
ssh), the inner tcp packets will never be dropped, and so ssh), the inner tcp packets will never be dropped, and so
the inner tcp stream's congestion control will be 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. - sudo, or root access on your client machine.
(The server doesn't need admin access.) (The server doesn't need admin access.)
- Python 3.9 or greater. - Python 3.6 or greater.
Linux with NAT method Linux with NAT method
@ -65,13 +65,14 @@ Requires:
Windows 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 Server side Requirements
------------------------ ------------------------
- Python 3.9 or greater. - Python 3.6 or greater.
Additional Suggested Software Additional Suggested Software

View File

@ -11,7 +11,7 @@ Forward all traffic::
sshuttle -r username@sshserver 0.0.0.0/0 sshuttle -r username@sshserver 0.0.0.0/0
- Use the :option:`sshuttle -r` parameter to specify a remote server. - 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 parameter to exclude sshserver or sshserver:22 so that your local
machine can communicate directly to sshserver without it being machine can communicate directly to sshserver without it being
redirected by sshuttle. redirected by sshuttle.
@ -71,23 +71,44 @@ admin access on the server.
Sudoers File 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 sshuttle --sudoers
allows one or more users to run sshuttle without entering the
local sudo password. **WARNING:** This option is *insecure*
because, with some cleverness, it also allows these users to run any
command (via the --ssh-cmd option) as root without a password.
To print a sudo configuration file and see a suggested way to install it, run:: 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 sshuttle --sudoers-no-modify
A custom user or group can be set with the This will simply sprint the generated configuration to STDOUT. Example::
:option:`sshuttle --sudoers-no-modify --sudoers-user {user_descriptor}`
option. Valid values for this vary based on how your system is configured.
Values such as usernames, groups prepended with `%` and sudoers user
aliases will work. See the sudoers manual for more information on valid
user-specified actions. The option must be used with `--sudoers-no-modify`::
sshuttle --sudoers-no-modify --sudoers-user mike 08:40 PM william$ sshuttle --sudoers-no-modify
sshuttle --sudoers-no-modify --sudoers-user %sudo
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 Microsoft Windows
================= =================
Currently there is no built in support for running sshuttle directly on
Experimental native support:: Microsoft Windows.
Experimental built-in support for Windows is available through `windivert` method.
You have to install https://pypi.org/project/pydivert package. You need Administrator privileges to use windivert method
Notes
- sshuttle should be executed from admin shell (Automatic firewall process admin elevation is not available)
- TCP/IPv4 supported (IPv6/UDP/DNS are not available)
Use Linux VM on Windows::
What we can really do is to create a Linux VM with Vagrant (or simply 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 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] [aliases]
test = pytest test=pytest
[bdist_wheel] [bdist_wheel]
universal = 1 universal = 1
[upload] [upload]
sign = true sign=true
identity = 0x1784577F811F6EAC identity=0x1784577F811F6EAC
[flake8] [flake8]
count = true count=true
show-source = true show-source=true
statistics = true statistics=true
max-line-length = 128
[pycodestyle]
max-line-length = 128
[tool:pytest] [tool:pytest]
addopts = --cov=sshuttle --cov-branch --cov-report=term-missing 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.""" """Coverage.py's main entry point."""
import sys import sys
import os
from sshuttle.cmdline import main from sshuttle.cmdline import main
from sshuttle.helpers import debug3 sys.exit(main())
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)

View File

@ -3,27 +3,24 @@ import zlib
import types import types
import platform import platform
stdin = stdin # type: typing.BinaryIO # noqa: F821 must be a previously defined global verbosity = verbosity # noqa: F821 must be a previously defined global
verbosity = verbosity # type: int # noqa: F821 must be a previously defined global
if verbosity > 0: if verbosity > 0:
sys.stderr.write(' s: Running server on remote host with %s (version %s)\n' sys.stderr.write(' s: Running server on remote host with %s (version %s)\n'
% (sys.executable, platform.python_version())) % (sys.executable, platform.python_version()))
z = zlib.decompressobj() z = zlib.decompressobj()
while 1: while 1:
name = stdin.readline().strip() name = sys.stdin.readline().strip()
if name: if name:
# python2 compat: in python2 stdin.readline().strip() -> str # python2 compat: in python2 sys.stdin.readline().strip() -> str
# in python3 stdin.readline().strip() -> bytes # in python3 sys.stdin.readline().strip() -> bytes
# (see #481) # (see #481)
if sys.version_info >= (3, 0): if sys.version_info >= (3, 0):
name = name.decode("ASCII") name = name.decode("ASCII")
nbytes = int(stdin.readline()) nbytes = int(sys.stdin.readline())
if verbosity >= 2: if verbosity >= 2:
sys.stderr.write(' s: assembling %r (%d bytes)\n' sys.stderr.write(' s: assembling %r (%d bytes)\r\n'
% (name, nbytes)) % (name, nbytes))
content = z.decompress(stdin.read(nbytes)) content = z.decompress(sys.stdin.read(nbytes))
module = types.ModuleType(name) module = types.ModuleType(name)
parents = name.rsplit(".", 1) parents = name.rsplit(".", 1)
@ -47,7 +44,6 @@ sshuttle.helpers.verbose = verbosity
import sshuttle.cmdline_options as options # noqa: E402 import sshuttle.cmdline_options as options # noqa: E402
from sshuttle.server import main # noqa: E402 from sshuttle.server import main # noqa: E402
main(options.latency_control, options.latency_buffer_size, main(options.latency_control, options.latency_buffer_size,
options.auto_hosts, options.to_nameserver, options.auto_hosts, options.to_nameserver,
options.auto_nets) options.auto_nets)

View File

@ -5,7 +5,6 @@ import time
import subprocess as ssubprocess import subprocess as ssubprocess
import os import os
import sys import sys
import base64
import platform import platform
import sshuttle.helpers as helpers import sshuttle.helpers as helpers
@ -15,17 +14,13 @@ import sshuttle.ssyslog as ssyslog
import sshuttle.sdnotify as sdnotify import sshuttle.sdnotify as sdnotify
from sshuttle.ssnet import SockWrapper, Handler, Proxy, Mux, MuxWrapper from sshuttle.ssnet import SockWrapper, Handler, Proxy, Mux, MuxWrapper
from sshuttle.helpers import log, debug1, debug2, debug3, Fatal, islocal, \ 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.methods import get_method, Features
from sshuttle import __version__ from sshuttle import __version__
try: try:
from pwd import getpwnam from pwd import getpwnam
except ImportError: except ImportError:
getpwnam = None getpwnam = None
try:
from grp import getgrnam
except ImportError:
getgrnam = None
import socket import socket
@ -128,14 +123,14 @@ class MultiListener:
self.bind_called = False self.bind_called = False
def setsockopt(self, level, optname, value): def setsockopt(self, level, optname, value):
assert self.bind_called assert(self.bind_called)
if self.v6: if self.v6:
self.v6.setsockopt(level, optname, value) self.v6.setsockopt(level, optname, value)
if self.v4: if self.v4:
self.v4.setsockopt(level, optname, value) self.v4.setsockopt(level, optname, value)
def add_handler(self, handlers, callback, method, mux): def add_handler(self, handlers, callback, method, mux):
assert self.bind_called assert(self.bind_called)
socks = [] socks = []
if self.v6: if self.v6:
socks.append(self.v6) socks.append(self.v6)
@ -150,7 +145,7 @@ class MultiListener:
) )
def listen(self, backlog): def listen(self, backlog):
assert self.bind_called assert(self.bind_called)
if self.v6: if self.v6:
self.v6.listen(backlog) self.v6.listen(backlog)
if self.v4: if self.v4:
@ -165,26 +160,11 @@ class MultiListener:
raise e raise e
def bind(self, address_v6, address_v4): def bind(self, address_v6, address_v4):
assert not self.bind_called assert(not self.bind_called)
self.bind_called = True self.bind_called = True
if address_v6 is not None: if address_v6 is not None:
self.v6 = socket.socket(socket.AF_INET6, self.type, self.proto) self.v6 = socket.socket(socket.AF_INET6, self.type, self.proto)
try: self.v6.bind(address_v6)
self.v6.bind(address_v6)
except OSError as e:
if e.errno == errno.EADDRNOTAVAIL:
# On an IPv6 Linux machine, this situation occurs
# if you run the following prior to running
# sshuttle:
#
# echo 1 > /proc/sys/net/ipv6/conf/all/disable_ipv6
# echo 1 > /proc/sys/net/ipv6/conf/default/disable_ipv6
raise Fatal("Could not bind to an IPv6 socket with "
"address %s and port %s. "
"Potential workaround: Run sshuttle "
"with '--disable-ipv6'."
% (str(address_v6[0]), str(address_v6[1])))
raise e
else: else:
self.v6 = None self.v6 = None
if address_v4 is not None: if address_v4 is not None:
@ -194,7 +174,7 @@ class MultiListener:
self.v4 = None self.v4 = None
def print_listening(self, what): def print_listening(self, what):
assert self.bind_called assert(self.bind_called)
if self.v6: if self.v6:
listenip = self.v6.getsockname() listenip = self.v6.getsockname()
debug1('%s listening on %r.' % (what, listenip)) debug1('%s listening on %r.' % (what, listenip))
@ -210,10 +190,7 @@ class FirewallClient:
def __init__(self, method_name, sudo_pythonpath): def __init__(self, method_name, sudo_pythonpath):
self.auto_nets = [] self.auto_nets = []
argv0 = sys.argv[0] argvbase = ([sys.executable, sys.argv[0]] +
# argv0 is either be a normal Python file or an executable.
# After installed as a package, sshuttle command points to an .exe in Windows and Python shebang script elsewhere.
argvbase = (([sys.executable, sys.argv[0]] if argv0.endswith('.py') else [argv0]) +
['-v'] * (helpers.verbose or 0) + ['-v'] * (helpers.verbose or 0) +
['--method', method_name] + ['--method', method_name] +
['--firewall']) ['--firewall'])
@ -223,22 +200,13 @@ class FirewallClient:
# A list of commands that we can try to run to start the firewall. # A list of commands that we can try to run to start the firewall.
argv_tries = [] argv_tries = []
if is_admin_user(): # No need to elevate privileges if os.getuid() == 0: # No need to elevate privileges
argv_tries.append(argvbase) argv_tries.append(argvbase)
else: 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 typically uses sudo; OpenBSD uses doas. However, some
# Linux distributions are starting to use doas. # Linux distributions are starting to use doas.
sudo_cmd = ['sudo', '-p', '[local sudo] Password: '] sudo_cmd = ['sudo', '-p', '[local sudo] Password: ']+argvbase
doas_cmd = ['doas'] doas_cmd = ['doas']+argvbase
# For clarity, try to replace executable name with the # For clarity, try to replace executable name with the
# full path. # full path.
@ -257,17 +225,13 @@ class FirewallClient:
pp_prefix = ['/usr/bin/env', pp_prefix = ['/usr/bin/env',
'PYTHONPATH=%s' % 'PYTHONPATH=%s' %
os.path.dirname(os.path.dirname(__file__))] os.path.dirname(os.path.dirname(__file__))]
sudo_cmd = sudo_cmd + pp_prefix sudo_cmd = pp_prefix + sudo_cmd
doas_cmd = doas_cmd + pp_prefix doas_cmd = pp_prefix + doas_cmd
# Final order should be: sudo/doas command, env
# pythonpath, and then argvbase (sshuttle command).
sudo_cmd = sudo_cmd + argvbase
doas_cmd = doas_cmd + argvbase
# If we can find doas and not sudo or if we are on # If we can find doas and not sudo or if we are on
# OpenBSD, try using doas first. # 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] argv_tries = [doas_cmd, sudo_cmd, argvbase]
else: else:
argv_tries = [sudo_cmd, doas_cmd, argvbase] argv_tries = [sudo_cmd, doas_cmd, argvbase]
@ -277,58 +241,20 @@ class FirewallClient:
# successful, set 'success' variable and break. # successful, set 'success' variable and break.
success = False success = False
for argv in argv_tries: 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': def setup():
# we can't use stdin/stdout=subprocess.PIPE here, as we # run in the child process
# normally would, because stupid Linux 'su' requires that s2.close()
# stdin be attached to a tty. Instead, attach a
# *bidirectional* socket to its stdout, and use that for
# talking in both directions.
(s1, s2) = socket.socketpair()
pstdout = s1
pstdin = s1
penv = None
def preexec_fn():
# run in the child process
s2.close()
def get_pfile():
s1.close()
return s2.makefile('rwb')
else:
# In Windows CPython, BSD sockets are not supported as subprocess stdio.
# if client (and firewall) processes is running as admin user, pipe based stdio can be used for communication.
# But if firewall process is spwaned in elevated mode by non-admin client process, access to stdio is lost.
# To work around this, we can use a socketpair.
# But socket need to be "shared" to child process as it can't be directly set as stdio.
can_use_stdio = is_admin_user()
preexec_fn = None
penv = os.environ.copy()
if can_use_stdio:
pstdout = ssubprocess.PIPE
pstdin = ssubprocess.PIPE
def get_pfile():
return RWPair(self.p.stdout, self.p.stdin)
penv['SSHUTTLE_FW_COM_CHANNEL'] = 'stdio'
else:
pstdout = None
pstdin = None
(s1, s2) = socket.socketpair()
socket_share_data = s1.share(self.p.pid)
socket_share_data_b64 = base64.b64encode(socket_share_data)
penv['SSHUTTLE_FW_COM_CHANNEL'] = socket_share_data_b64
def get_pfile():
s1.close()
return s2.makefile('rwb')
try: try:
debug1("Starting firewall manager with command: %r" % argv) debug1("Starting firewall manager with command: %r" % argv)
self.p = ssubprocess.Popen(argv, stdout=pstdout, stdin=pstdin, env=penv, self.p = ssubprocess.Popen(argv, stdout=s1, preexec_fn=setup)
preexec_fn=preexec_fn)
# No env: Talking to `FirewallClient.start`, which has no i18n. # No env: Talking to `FirewallClient.start`, which has no i18n.
except OSError as e: except OSError as e:
# This exception will occur if the program isn't # This exception will occur if the program isn't
@ -336,14 +262,11 @@ class FirewallClient:
debug1('Unable to start firewall manager. Popen failed. ' debug1('Unable to start firewall manager. Popen failed. '
'Command=%r Exception=%s' % (argv, e)) 'Command=%r Exception=%s' % (argv, e))
continue continue
self.argv = argv
self.pfile = get_pfile()
try: self.argv = argv
line = self.pfile.readline() s1.close()
except IOError: self.pfile = s2.makefile('rwb')
# happens when firewall subprocess exists line = self.pfile.readline()
line = ''
rv = self.p.poll() # Check if process is still running rv = self.p.poll() # Check if process is still running
if rv: if rv:
@ -355,28 +278,10 @@ class FirewallClient:
'%r returned %d' % (self.argv, rv)) '%r returned %d' % (self.argv, rv))
continue 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': if line[0:5] != b'READY':
debug1('Unable to start firewall manager. ' debug1('Unable to start firewall manager. '
'Expected READY, got %r. ' 'Expected READY, got %r. '
'Command=%r' % (skipped_text, self.argv)) 'Command=%r' % (line, self.argv))
continue continue
method_name = line[6:-1] method_name = line[6:-1]
@ -386,11 +291,11 @@ class FirewallClient:
break break
if not success: 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, def setup(self, subnets_include, subnets_exclude, nslist,
redirectport_v6, redirectport_v4, dnsport_v6, dnsport_v4, udp, redirectport_v6, redirectport_v4, dnsport_v6, dnsport_v4, udp,
user, group, tmark): user, tmark):
self.subnets_include = subnets_include self.subnets_include = subnets_include
self.subnets_exclude = subnets_exclude self.subnets_exclude = subnets_exclude
self.nslist = nslist self.nslist = nslist
@ -400,7 +305,6 @@ class FirewallClient:
self.dnsport_v4 = dnsport_v4 self.dnsport_v4 = dnsport_v4
self.udp = udp self.udp = udp
self.user = user self.user = user
self.group = group
self.tmark = tmark self.tmark = tmark
def check(self): def check(self):
@ -439,14 +343,9 @@ class FirewallClient:
user = bytes(self.user, 'utf-8') user = bytes(self.user, 'utf-8')
else: else:
user = b'%d' % self.user user = b'%d' % self.user
if self.group is None:
group = b'-' self.pfile.write(b'GO %d %s %s %d\n' %
elif isinstance(self.group, str): (udp, user, bytes(self.tmark, 'ascii'), os.getpid()))
group = bytes(self.group, 'utf-8')
else:
group = b'%d' % self.group
self.pfile.write(b'GO %d %s %s %s %d\n' %
(udp, user, group, bytes(self.tmark, 'ascii'), os.getpid()))
self.pfile.flush() self.pfile.flush()
line = self.pfile.readline() line = self.pfile.readline()
@ -455,8 +354,8 @@ class FirewallClient:
raise Fatal('%r expected STARTED, got %r' % (self.argv, line)) raise Fatal('%r expected STARTED, got %r' % (self.argv, line))
def sethostip(self, hostname, ip): def sethostip(self, hostname, ip):
assert not re.search(br'[^-\w\.]', hostname) assert(not re.search(br'[^-\w\.]', hostname))
assert not re.search(br'[^0-9.]', ip) assert(not re.search(br'[^0-9.]', ip))
self.pfile.write(b'HOST %s,%s\n' % (hostname, ip)) self.pfile.write(b'HOST %s,%s\n' % (hostname, ip))
self.pfile.flush() self.pfile.flush()
@ -591,7 +490,7 @@ def ondns(listener, method, mux, handlers):
def _main(tcp_listener, udp_listener, fw, ssh_cmd, remotename, def _main(tcp_listener, udp_listener, fw, ssh_cmd, remotename,
python, latency_control, latency_buffer_size, python, latency_control, latency_buffer_size,
dns_listener, seed_hosts, auto_hosts, auto_nets, daemon, dns_listener, seed_hosts, auto_hosts, auto_nets, daemon,
to_nameserver, add_cmd_delimiter, remote_shell): to_nameserver):
helpers.logprefix = 'c : ' helpers.logprefix = 'c : '
debug1('Starting client with Python version %s' 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...') debug1('Connecting to server...')
try: try:
(serverproc, rfile, wfile) = ssh.connect( (serverproc, serversock) = ssh.connect(
ssh_cmd, remotename, python, ssh_cmd, remotename, python,
stderr=ssyslog._p and ssyslog._p.stdin, stderr=ssyslog._p and ssyslog._p.stdin,
add_cmd_delimiter=add_cmd_delimiter,
remote_shell=remote_shell,
options=dict(latency_control=latency_control, options=dict(latency_control=latency_control,
latency_buffer_size=latency_buffer_size, latency_buffer_size=latency_buffer_size,
auto_hosts=auto_hosts, auto_hosts=auto_hosts,
@ -615,25 +512,24 @@ def _main(tcp_listener, udp_listener, fw, ssh_cmd, remotename,
auto_nets=auto_nets)) auto_nets=auto_nets))
except socket.error as e: except socket.error as e:
if e.args[0] == errno.EPIPE: if e.args[0] == errno.EPIPE:
debug3('Error: EPIPE: ' + repr(e))
raise Fatal("failed to establish ssh session (1)") raise Fatal("failed to establish ssh session (1)")
else: else:
raise raise
mux = Mux(rfile, wfile) mux = Mux(serversock.makefile("rb"), serversock.makefile("wb"))
handlers.append(mux) handlers.append(mux)
expected = b'SSHUTTLE0001' expected = b'SSHUTTLE0001'
try: try:
v = 'x' v = 'x'
while v and v != b'\0': while v and v != b'\0':
v = rfile.read(1) v = serversock.recv(1)
v = 'x' v = 'x'
while v and v != b'\0': while v and v != b'\0':
v = rfile.read(1) v = serversock.recv(1)
initstring = rfile.read(len(expected)) initstring = serversock.recv(len(expected))
except socket.error as e: except socket.error as e:
if e.args[0] == errno.ECONNRESET: if e.args[0] == errno.ECONNRESET:
debug3('Error: ECONNRESET ' + repr(e))
raise Fatal("failed to establish ssh session (2)") raise Fatal("failed to establish ssh session (2)")
else: else:
raise raise
@ -810,7 +706,7 @@ def main(listenip_v6, listenip_v4,
latency_buffer_size, dns, nslist, latency_buffer_size, dns, nslist,
method_name, seed_hosts, auto_hosts, auto_nets, method_name, seed_hosts, auto_hosts, auto_nets,
subnets_include, subnets_exclude, daemon, to_nameserver, pidfile, 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: if not remotename:
raise Fatal("You must use -r/--remote to specify a remote " 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". # listenip_v4 contains user specified value or it is set to "auto".
if listenip_v4 == "auto": if listenip_v4 == "auto":
listenip_v4 = ('127.0.0.1' if avail.loopback_proxy_port else '0.0.0.0', 0) listenip_v4 = ('127.0.0.1', 0)
debug1("Using default IPv4 listen address " + listenip_v4[0])
# listenip_v6 is... # listenip_v6 is...
# None when IPv6 is disabled. # None when IPv6 is disabled.
@ -886,8 +781,8 @@ def main(listenip_v6, listenip_v4,
debug1("IPv6 disabled by --disable-ipv6") debug1("IPv6 disabled by --disable-ipv6")
if listenip_v6 == "auto": if listenip_v6 == "auto":
if avail.ipv6: if avail.ipv6:
listenip_v6 = ('::1' if avail.loopback_proxy_port else '::', 0) debug1("IPv6 enabled: Using default IPv6 listen address ::1")
debug1("IPv6 enabled: Using default IPv6 listen address " + listenip_v6[0]) listenip_v6 = ('::1', 0)
else: else:
debug1("IPv6 disabled since it isn't supported by method " debug1("IPv6 disabled since it isn't supported by method "
"%s." % fw.method.name) "%s." % fw.method.name)
@ -914,15 +809,6 @@ def main(listenip_v6, listenip_v4,
raise Fatal("User %s does not exist." % user) raise Fatal("User %s does not exist." % user)
required.user = False if user is None else True 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: if not required.ipv6 and len(subnets_v6) > 0:
print("WARNING: IPv6 subnets were ignored because IPv6 is disabled " print("WARNING: IPv6 subnets were ignored because IPv6 is disabled "
"in sshuttle.") "in sshuttle.")
@ -1067,7 +953,7 @@ def main(listenip_v6, listenip_v4,
raise e raise e
if not bound: if not bound:
assert last_e assert(last_e)
raise last_e raise last_e
tcp_listener.listen(10) tcp_listener.listen(10)
tcp_listener.print_listening("TCP redirector") tcp_listener.print_listening("TCP redirector")
@ -1113,7 +999,7 @@ def main(listenip_v6, listenip_v4,
dns_listener.print_listening("DNS") dns_listener.print_listening("DNS")
if not bound: if not bound:
assert last_e assert(last_e)
raise last_e raise last_e
else: else:
dnsport_v6 = 0 dnsport_v6 = 0
@ -1152,14 +1038,14 @@ def main(listenip_v6, listenip_v4,
# start the firewall # start the firewall
fw.setup(subnets_include, subnets_exclude, nslist, fw.setup(subnets_include, subnets_exclude, nslist,
redirectport_v6, redirectport_v4, dnsport_v6, dnsport_v4, redirectport_v6, redirectport_v4, dnsport_v6, dnsport_v4,
required.udp, user, group, tmark) required.udp, user, tmark)
# start the client process # start the client process
try: try:
return _main(tcp_listener, udp_listener, fw, ssh_cmd, remotename, return _main(tcp_listener, udp_listener, fw, ssh_cmd, remotename,
python, latency_control, latency_buffer_size, python, latency_control, latency_buffer_size,
dns_listener, seed_hosts, auto_hosts, auto_nets, dns_listener, seed_hosts, auto_hosts, auto_nets,
daemon, to_nameserver, add_cmd_delimiter, remote_shell) daemon, to_nameserver)
finally: finally:
try: try:
if daemon: if daemon:

View File

@ -1,8 +1,6 @@
import os
import re import re
import shlex
import socket import socket
import sys import platform
import sshuttle.helpers as helpers import sshuttle.helpers as helpers
import sshuttle.client as client import sshuttle.client as client
import sshuttle.firewall as firewall import sshuttle.firewall as firewall
@ -11,21 +9,25 @@ import sshuttle.ssyslog as ssyslog
from sshuttle.options import parser, parse_ipport from sshuttle.options import parser, parse_ipport
from sshuttle.helpers import family_ip_tuple, log, Fatal from sshuttle.helpers import family_ip_tuple, log, Fatal
from sshuttle.sudoers import sudoers from sshuttle.sudoers import sudoers
from sshuttle.namespace import enter_namespace
def main(): def main():
if 'SSHUTTLE_ARGS' in os.environ: opt = parser.parse_args()
env_args = shlex.split(os.environ['SSHUTTLE_ARGS'])
else:
env_args = []
args = [*env_args, *sys.argv[1:]]
opt = parser.parse_args(args) if opt.sudoers 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: if not opt.sudoers_filename:
# sudoers() calls exit() when it completes log('--sudoers-file must be set or omitted.')
sudoers(user_name=opt.sudoers_user) return 1
sudoers(
user_name=opt.sudoers_user,
no_modify=opt.sudoers_no_modify,
file_name=opt.sudoers_filename
)
if opt.daemon: if opt.daemon:
opt.syslog = 1 opt.syslog = 1
@ -38,23 +40,12 @@ def main():
helpers.verbose = opt.verbose helpers.verbose = opt.verbose
try: 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.firewall:
if opt.subnets or opt.subnets_file: if opt.subnets or opt.subnets_file:
parser.error('exactly zero arguments expected') parser.error('exactly zero arguments expected')
return firewall.main(opt.method, opt.syslog) return firewall.main(opt.method, opt.syslog)
elif opt.hostwatch: elif opt.hostwatch:
hostwatch.hw_main(opt.subnets, opt.auto_hosts) return hostwatch.hw_main(opt.subnets, opt.auto_hosts)
return 0
else: else:
# parse_subnetports() is used to create a list of includes # parse_subnetports() is used to create a list of includes
# and excludes. It is called once for each parameter and # and excludes. It is called once for each parameter and
@ -124,10 +115,7 @@ def main():
opt.to_ns, opt.to_ns,
opt.pidfile, opt.pidfile,
opt.user, opt.user,
opt.group,
opt.sudo_pythonpath, opt.sudo_pythonpath,
opt.add_cmd_delimiter,
opt.remote_shell,
opt.tmark) opt.tmark)
if return_code == 0: if return_code == 0:

View File

@ -1,5 +1,4 @@
import errno import errno
import shutil
import socket import socket
import signal import signal
import sys import sys
@ -7,18 +6,13 @@ import os
import platform import platform
import traceback import traceback
import subprocess as ssubprocess import subprocess as ssubprocess
import base64
import io
import sshuttle.ssyslog as ssyslog import sshuttle.ssyslog as ssyslog
import sshuttle.helpers as helpers 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 from sshuttle.methods import get_auto_method, get_method
if sys.platform == 'win32': HOSTSFILE = '/etc/hosts'
HOSTSFILE = r"C:\Windows\System32\drivers\etc\hosts"
else:
HOSTSFILE = '/etc/hosts'
sshuttle_pid = None sshuttle_pid = None
@ -36,11 +30,7 @@ def rewrite_etc_hosts(hostmap, port):
else: else:
raise raise
if old_content.strip() and not os.path.exists(BAKFILE): if old_content.strip() and not os.path.exists(BAKFILE):
try: os.link(HOSTSFILE, BAKFILE)
os.link(HOSTSFILE, BAKFILE)
except OSError:
# file is locked - performing non-atomic copy
shutil.copyfile(HOSTSFILE, BAKFILE)
tmpname = "%s.%d.tmp" % (HOSTSFILE, port) tmpname = "%s.%d.tmp" % (HOSTSFILE, port)
f = open(tmpname, 'w') f = open(tmpname, 'w')
for line in old_content.rstrip().split('\n'): 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.write('%-30s %s\n' % ('%s %s' % (ip, name), APPEND))
f.close() f.close()
if sys.platform != 'win32': if st is not None:
if st is not None: os.chown(tmpname, st.st_uid, st.st_gid)
os.chown(tmpname, st.st_uid, st.st_gid) os.chmod(tmpname, st.st_mode)
os.chmod(tmpname, st.st_mode) else:
else: os.chown(tmpname, 0, 0)
os.chown(tmpname, 0, 0) os.chmod(tmpname, 0o600)
os.chmod(tmpname, 0o644) os.rename(tmpname, HOSTSFILE)
try:
os.rename(tmpname, HOSTSFILE)
except OSError:
# file is locked - performing non-atomic copy
log('Warning: Using a non-atomic way to overwrite %s that can corrupt the file if '
'multiple processes write to it simultaneously.' % HOSTSFILE)
shutil.move(tmpname, HOSTSFILE)
def restore_etc_hosts(hostmap, port): def restore_etc_hosts(hostmap, port):
@ -88,17 +71,14 @@ def firewall_exit(signum, frame):
# the typical exit process as described above. # the typical exit process as described above.
global sshuttle_pid global sshuttle_pid
if sshuttle_pid: if sshuttle_pid:
debug1("Relaying interupt signal to sshuttle process %d" % sshuttle_pid) debug1("Relaying SIGINT to sshuttle process %d\n" % sshuttle_pid)
if sys.platform == 'win32': os.kill(sshuttle_pid, signal.SIGINT)
sig = signal.CTRL_C_EVENT
else:
sig = signal.SIGINT
os.kill(sshuttle_pid, sig)
def _setup_daemon_for_unix_like(): # Isolate function that needs to be replaced for tests
if not is_admin_user(): def setup_daemon():
raise Fatal('You must have root privileges (or enable su/sudo) to set the firewall') 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 # don't disappear if our controlling terminal or stdout/stderr
# disappears; we still have to clean up. # 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. # setsid() fails if sudo is configured with the use_pty option.
pass 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)
return sys.stdin, sys.stdout
def _setup_daemon_for_windows():
if not is_admin_user():
raise Fatal('You must be administrator to set the firewall')
signal.signal(signal.SIGTERM, firewall_exit)
signal.signal(signal.SIGINT, firewall_exit)
com_chan = os.environ.get('SSHUTTLE_FW_COM_CHANNEL')
if com_chan == 'stdio':
debug3('Using inherited stdio for communicating with sshuttle client process')
else:
debug3('Using shared socket for communicating with sshuttle client process')
socket_share_data = base64.b64decode(com_chan)
sock = socket.fromshare(socket_share_data) # type: socket.socket
sys.stdin = io.TextIOWrapper(sock.makefile('rb', buffering=0))
sys.stdout = io.TextIOWrapper(sock.makefile('wb', buffering=0), write_through=True)
sock.close()
return sys.stdin.buffer, sys.stdout.buffer
# Isolate function that needs to be replaced for tests
if sys.platform == 'win32':
setup_daemon = _setup_daemon_for_windows
else:
setup_daemon = _setup_daemon_for_unix_like
# Note that we're sorting in a very particular order: # Note that we're sorting in a very particular order:
@ -220,43 +178,29 @@ def main(method_name, syslog):
"PATH." % method_name) "PATH." % method_name)
debug1('ready method name %s.' % 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() 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, # 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 # sshuttle can launch us as early as possible (and get sudo password
# authentication as early in the startup process as possible). # authentication as early in the startup process as possible).
try: line = stdin.readline(128)
line = _read_next_string_line() if not line:
if not line: return # parent died; nothing to do
return # parent probably exited
except IOError as e:
# On windows, ConnectionResetError is thrown when parent process closes it's socket pair end
debug3('read from stdin failed: %s' % (e,))
return
subnets = [] subnets = []
if line != 'ROUTES': if line != 'ROUTES\n':
raise Fatal('expected ROUTES but got %r' % line) raise Fatal('expected ROUTES but got %r' % line)
while 1: while 1:
line = _read_next_string_line() line = stdin.readline(128)
if not line: if not line:
raise Fatal('expected route but got %r' % line) raise Fatal('expected route but got %r' % line)
elif line.startswith("NSLIST"): elif line.startswith("NSLIST\n"):
break break
try: try:
(family, width, exclude, ip, fport, lport) = line.split(',', 5) (family, width, exclude, ip, fport, lport) = \
except Exception: line.strip().split(',', 5)
except BaseException:
raise Fatal('expected route or NSLIST but got %r' % line) raise Fatal('expected route or NSLIST but got %r' % line)
subnets.append(( subnets.append((
int(family), int(family),
@ -268,17 +212,17 @@ def main(method_name, syslog):
debug2('Got subnets: %r' % subnets) debug2('Got subnets: %r' % subnets)
nslist = [] nslist = []
if line != 'NSLIST': if line != 'NSLIST\n':
raise Fatal('expected NSLIST but got %r' % line) raise Fatal('expected NSLIST but got %r' % line)
while 1: while 1:
line = _read_next_string_line() line = stdin.readline(128)
if not line: if not line:
raise Fatal('expected nslist but got %r' % line) raise Fatal('expected nslist but got %r' % line)
elif line.startswith("PORTS "): elif line.startswith("PORTS "):
break break
try: try:
(family, ip) = line.split(',', 1) (family, ip) = line.strip().split(',', 1)
except Exception: except BaseException:
raise Fatal('expected nslist or PORTS but got %r' % line) raise Fatal('expected nslist or PORTS but got %r' % line)
nslist.append((int(family), ip)) nslist.append((int(family), ip))
debug2('Got partial nslist: %r' % nslist) debug2('Got partial nslist: %r' % nslist)
@ -295,33 +239,33 @@ def main(method_name, syslog):
dnsport_v6 = int(ports[2]) dnsport_v6 = int(ports[2])
dnsport_v4 = int(ports[3]) dnsport_v4 = int(ports[3])
assert port_v6 >= 0 assert(port_v6 >= 0)
assert port_v6 <= 65535 assert(port_v6 <= 65535)
assert port_v4 >= 0 assert(port_v4 >= 0)
assert port_v4 <= 65535 assert(port_v4 <= 65535)
assert dnsport_v6 >= 0 assert(dnsport_v6 >= 0)
assert dnsport_v6 <= 65535 assert(dnsport_v6 <= 65535)
assert dnsport_v4 >= 0 assert(dnsport_v4 >= 0)
assert dnsport_v4 <= 65535 assert(dnsport_v4 <= 65535)
debug2('Got ports: %d,%d,%d,%d' debug2('Got ports: %d,%d,%d,%d'
% (port_v6, port_v4, dnsport_v6, dnsport_v4)) % (port_v6, port_v4, dnsport_v6, dnsport_v4))
line = _read_next_string_line() line = stdin.readline(128)
if not line or not line.startswith("GO "): if not line:
raise Fatal('expected GO but got %r' % line)
elif not line.startswith("GO "):
raise Fatal('expected GO but got %r' % line) raise Fatal('expected GO but got %r' % line)
_, _, args = line.partition(" ") _, _, args = line.partition(" ")
global sshuttle_pid 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)) udp = bool(int(udp))
sshuttle_pid = int(sshuttle_pid) sshuttle_pid = int(sshuttle_pid)
if user == '-': if user == '-':
user = None user = None
if group == '-': debug2('Got udp: %r, user: %r, tmark: %s, sshuttle_pid: %d' %
group = None (udp, user, tmark, sshuttle_pid))
debug2('Got udp: %r, user: %r, group: %r, tmark: %s, sshuttle_pid: %d' %
(udp, user, group, tmark, sshuttle_pid))
subnets_v6 = [i for i in subnets if i[0] == socket.AF_INET6] 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] 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( method.setup_firewall(
port_v6, dnsport_v6, nslist_v6, port_v6, dnsport_v6, nslist_v6,
socket.AF_INET6, subnets_v6, udp, socket.AF_INET6, subnets_v6, udp,
user, group, tmark) user, tmark)
if subnets_v4 or nslist_v4: if subnets_v4 or nslist_v4:
debug2('setting up IPv4.') debug2('setting up IPv4.')
method.setup_firewall( method.setup_firewall(
port_v4, dnsport_v4, nslist_v4, port_v4, dnsport_v4, nslist_v4,
socket.AF_INET, subnets_v4, udp, socket.AF_INET, subnets_v4, udp,
user, group, tmark) user, tmark)
flush_systemd_dns_cache()
stdout.write('STARTED\n')
try: 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() stdout.flush()
except IOError as e: # the parent process probably died except IOError:
debug3('write to stdout failed: %s' % (e,)) # the parent process died for some reason; he's surely been loud
# enough, so no reason to report another error
return return
# Now we wait until EOF or any other kind of exception. We need # 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 # to stay running so that we don't need a *second* password
# authentication at shutdown time - that cleanup is important! # authentication at shutdown time - that cleanup is important!
while 1: while 1:
line = _read_next_string_line() line = stdin.readline(128)
if not line:
return
if line.startswith('HOST '): if line.startswith('HOST '):
(name, ip) = line[5:].split(',', 1) (name, ip) = line[5:].strip().split(',', 1)
hostmap[name] = ip hostmap[name] = ip
debug2('setting up /etc/hosts.') debug2('setting up /etc/hosts.')
rewrite_etc_hosts(hostmap, port_v6 or port_v4) rewrite_etc_hosts(hostmap, port_v6 or port_v4)
@ -382,47 +317,46 @@ def main(method_name, syslog):
finally: finally:
try: try:
debug1('undoing changes.') debug1('undoing changes.')
except Exception: except BaseException:
debug2('An error occurred, ignoring it.') debug2('An error occurred, ignoring it.')
try: try:
if subnets_v6 or nslist_v6: if subnets_v6 or nslist_v6:
debug2('undoing IPv6 changes.') debug2('undoing IPv6 changes.')
method.restore_firewall(port_v6, socket.AF_INET6, udp, user, group) method.restore_firewall(port_v6, socket.AF_INET6, udp, user)
except Exception: except BaseException:
try: try:
debug1("Error trying to undo IPv6 firewall.") debug1("Error trying to undo IPv6 firewall.")
debug1(traceback.format_exc()) debug1(traceback.format_exc())
except Exception: except BaseException:
debug2('An error occurred, ignoring it.') debug2('An error occurred, ignoring it.')
try: try:
if subnets_v4 or nslist_v4: if subnets_v4 or nslist_v4:
debug2('undoing IPv4 changes.') debug2('undoing IPv4 changes.')
method.restore_firewall(port_v4, socket.AF_INET, udp, user, group) method.restore_firewall(port_v4, socket.AF_INET, udp, user)
except Exception: except BaseException:
try: try:
debug1("Error trying to undo IPv4 firewall.") debug1("Error trying to undo IPv4 firewall.")
debug1(traceback.format_exc()) debug1(traceback.format_exc())
except Exception: except BaseException:
debug2('An error occurred, ignoring it.') debug2('An error occurred, ignoring it.')
try: try:
# debug2() message printed in restore_etc_hosts() function. # debug2() message printed in restore_etc_hosts() function.
restore_etc_hosts(hostmap, port_v6 or port_v4) restore_etc_hosts(hostmap, port_v6 or port_v4)
except Exception: except BaseException:
try: try:
debug1("Error trying to undo /etc/hosts changes.") debug1("Error trying to undo /etc/hosts changes.")
debug1(traceback.format_exc()) debug1(traceback.format_exc())
except Exception: except BaseException:
debug2('An error occurred, ignoring it.') debug2('An error occurred, ignoring it.')
if sys.platform == 'linux': try:
flush_systemd_dns_cache()
except BaseException:
try: try:
flush_systemd_dns_cache() debug1("Error trying to flush systemd dns cache.")
except Exception: debug1(traceback.format_exc())
try: except BaseException:
debug1("Error trying to flush systemd dns cache.") debug2("An error occurred, ignoring it.")
debug1(traceback.format_exc())
except Exception:
debug2("An error occurred, ignoring it.")

View File

@ -2,13 +2,6 @@ import sys
import socket import socket
import errno import errno
import os import os
import threading
import subprocess
import traceback
import re
if sys.platform != "win32":
import fcntl
logprefix = '' logprefix = ''
verbose = 0 verbose = 0
@ -18,17 +11,10 @@ def b(s):
return s.encode("ASCII") return s.encode("ASCII")
def get_verbose_level():
return verbose
def log(s): def log(s):
global logprefix global logprefix
try: try:
sys.stdout.flush() 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. # Put newline at end of string if line doesn't have one.
if not s.endswith("\n"): if not s.endswith("\n"):
s = s+"\n" s = s+"\n"
@ -36,10 +22,17 @@ def log(s):
prefix = logprefix prefix = logprefix
s = s.rstrip("\n") s = s.rstrip("\n")
for line in s.split("\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 = " " prefix = " "
sys.stderr.flush() 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 # this could happen if stderr gets forcibly disconnected, eg. because
# our tty closes. That sucks, but it's no reason to abort the program. # our tty closes. That sucks, but it's no reason to abort the program.
pass pass
@ -116,43 +109,18 @@ def resolvconf_nameservers(systemd_resolved):
return nsservers return nsservers
def windows_nameservers(): def resolvconf_random_nameserver(systemd_resolved):
out = subprocess.check_output(["powershell", "-NonInteractive", "-NoProfile", "-Command", "Get-DnsClientServerAddress"],
encoding="utf-8")
servers = set()
for line in out.splitlines():
if line.startswith("Loopback "):
continue
m = re.search(r'{.+}', line)
if not m:
continue
for s in m.group().strip('{}').split(','):
s = s.strip()
if s.startswith('fec0:0:0:ffff'):
continue
servers.add(s)
debug2("Found DNS servers: %s" % servers)
return [(socket.AF_INET6 if ':' in s else socket.AF_INET, s) for s in servers]
def get_random_nameserver():
"""Return a random nameserver selected from servers produced by """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": lines = resolvconf_nameservers(systemd_resolved)
if globals().get('_nameservers') is None: if lines:
ns_list = windows_nameservers() if len(lines) > 1:
globals()['_nameservers'] = ns_list
else:
ns_list = globals()['_nameservers']
else:
ns_list = resolvconf_nameservers(systemd_resolved=False)
if ns_list:
if len(ns_list) > 1:
# don't import this unless we really need it # don't import this unless we really need it
import random import random
random.shuffle(ns_list) random.shuffle(lines)
return ns_list[0] return lines[0]
else: else:
return (socket.AF_INET, '127.0.0.1') return (socket.AF_INET, '127.0.0.1')
@ -259,91 +227,3 @@ def which(file, mode=os.F_OK | os.X_OK):
else: else:
debug2("which() could not find '%s' in %s" % (file, path)) debug2("which() could not find '%s' in %s" % (file, path))
return rv 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? # Have we already failed to write CACHEFILE?
CACHE_WRITE_FAILED = False CACHE_WRITE_FAILED = False
SHOULD_WRITE_CACHE = False
hostnames = {} hostnames = {}
queue = {} queue = {}
try: try:
@ -57,7 +55,7 @@ def write_host_cache():
try: try:
os.unlink(tmpname) os.unlink(tmpname)
except Exception: except BaseException:
pass pass
@ -83,11 +81,6 @@ def read_host_cache():
ip = re.sub(r'[^0-9.]', '', ip).strip() ip = re.sub(r'[^0-9.]', '', ip).strip()
if name and ip: if name and ip:
found_host(name, 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): def found_host(name, ip):
@ -104,13 +97,12 @@ def found_host(name, ip):
if hostname != name: if hostname != name:
found_host(hostname, ip) found_host(hostname, ip)
global SHOULD_WRITE_CACHE
oldip = hostnames.get(name) oldip = hostnames.get(name)
if oldip != ip: if oldip != ip:
hostnames[name] = ip hostnames[name] = ip
debug1('Found: %s: %s' % (name, ip)) debug1('Found: %s: %s' % (name, ip))
sys.stdout.write('%s,%s\n' % (name, ip)) sys.stdout.write('%s,%s\n' % (name, ip))
SHOULD_WRITE_CACHE = True write_host_cache()
def _check_etc_hosts(): def _check_etc_hosts():

View File

@ -20,7 +20,7 @@ def ipt_chain_exists(family, table, name):
argv = [cmd, '-w', '-t', table, '-nL'] argv = [cmd, '-w', '-t', table, '-nL']
try: try:
output = ssubprocess.check_output(argv, env=get_env()) 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): if line.startswith('Chain %s ' % name):
return True return True
except ssubprocess.CalledProcessError as e: except ssubprocess.CalledProcessError as e:

View File

@ -1,13 +1,14 @@
import importlib import importlib
import socket import socket
import struct import struct
import sys
import errno import errno
import ipaddress import ipaddress
from sshuttle.helpers import Fatal, debug3 from sshuttle.helpers import Fatal, debug3
def original_dst(sock): def original_dst(sock):
ip = "0.0.0.0"
port = -1
try: try:
family = sock.family family = sock.family
SO_ORIGINAL_DST = 80 SO_ORIGINAL_DST = 80
@ -46,13 +47,11 @@ class BaseMethod(object):
@staticmethod @staticmethod
def get_supported_features(): def get_supported_features():
result = Features() result = Features()
result.loopback_proxy_port = True
result.ipv4 = True result.ipv4 = True
result.ipv6 = False result.ipv6 = False
result.udp = False result.udp = False
result.dns = True result.dns = True
result.user = False result.user = False
result.group = False
return result return result
@staticmethod @staticmethod
@ -73,8 +72,8 @@ class BaseMethod(object):
def send_udp(self, sock, srcip, dstip, data): def send_udp(self, sock, srcip, dstip, data):
if srcip is not None: if srcip is not None:
raise Fatal("Method %s send_udp does not support setting srcip to %r" Fatal("Method %s send_udp does not support setting srcip to %r"
% (self.name, srcip)) % (self.name, srcip))
sock.sendto(data, dstip) sock.sendto(data, dstip)
def setup_tcp_listener(self, tcp_listener): def setup_tcp_listener(self, tcp_listener):
@ -92,13 +91,10 @@ class BaseMethod(object):
(key, self.name)) (key, self.name))
def setup_firewall(self, port, dnsport, nslist, family, subnets, udp, def setup_firewall(self, port, dnsport, nslist, family, subnets, udp,
user, group, tmark): user, tmark):
raise NotImplementedError() raise NotImplementedError()
def restore_firewall(self, port, family, udp, user, group): def restore_firewall(self, port, family, udp, user):
raise NotImplementedError()
def wait_for_firewall_ready(self, sshuttle_pid):
raise NotImplementedError() raise NotImplementedError()
@staticmethod @staticmethod
@ -114,7 +110,7 @@ def get_method(method_name):
def get_auto_method(): def get_auto_method():
debug3("Selecting a method automatically...") debug3("Selecting a method automatically...")
# Try these methods, in order: # 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: for m in methods_to_try:
method = get_method(m) method = get_method(m)
if method.is_supported(): if method.is_supported():

View File

@ -52,7 +52,7 @@ def _fill_oldctls(prefix):
p = ssubprocess.Popen(argv, stdout=ssubprocess.PIPE, env=get_env()) p = ssubprocess.Popen(argv, stdout=ssubprocess.PIPE, env=get_env())
for line in p.stdout: for line in p.stdout:
line = line.decode() line = line.decode()
assert line[-1] == '\n' assert(line[-1] == '\n')
(k, v) = line[:-1].split(': ', 1) (k, v) = line[:-1].split(': ', 1)
_oldctls[k] = v.strip() _oldctls[k] = v.strip()
rv = p.wait() rv = p.wait()
@ -74,7 +74,7 @@ _changedctls = []
def sysctl_set(name, val, permanent=False): def sysctl_set(name, val, permanent=False):
PREFIX = 'net.inet.ip' PREFIX = 'net.inet.ip'
assert name.startswith(PREFIX + '.') assert(name.startswith(PREFIX + '.'))
val = str(val) val = str(val)
if not _oldctls: if not _oldctls:
_fill_oldctls(PREFIX) _fill_oldctls(PREFIX)
@ -156,7 +156,7 @@ class Method(BaseMethod):
# udp_listener.v6.setsockopt(SOL_IPV6, IPV6_RECVDSTADDR, 1) # udp_listener.v6.setsockopt(SOL_IPV6, IPV6_RECVDSTADDR, 1)
def setup_firewall(self, port, dnsport, nslist, family, subnets, udp, def setup_firewall(self, port, dnsport, nslist, family, subnets, udp,
user, group, tmark): user, tmark):
# IPv6 not supported # IPv6 not supported
if family not in [socket.AF_INET]: if family not in [socket.AF_INET]:
raise Exception( raise Exception(
@ -207,7 +207,7 @@ class Method(BaseMethod):
else: else:
ipfw('table', '126', 'add', '%s/%s' % (snet, swidth)) 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]: if family not in [socket.AF_INET]:
raise Exception( raise Exception(
'Address family "%s" unsupported by ipfw method' '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 # recently-started one will win (because we use "-I OUTPUT 1" instead of
# "-A OUTPUT"). # "-A OUTPUT").
def setup_firewall(self, port, dnsport, nslist, family, subnets, udp, 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: if family != socket.AF_INET and family != socket.AF_INET6:
raise Exception( raise Exception(
'Address family "%s" unsupported by nat method_name' 'Address family "%s" unsupported by nat method_name'
@ -31,18 +31,13 @@ class Method(BaseMethod):
chain = 'sshuttle-%s' % port chain = 'sshuttle-%s' % port
# basic cleanup/setup of chains # basic cleanup/setup of chains
self.restore_firewall(port, family, udp, user, group) self.restore_firewall(port, family, udp, user)
_ipt('-N', chain) _ipt('-N', chain)
_ipt('-F', chain) _ipt('-F', chain)
if user is not None or group is not None: if user is not None:
margs = ['-I', 'OUTPUT', '1', '-m', 'owner'] _ipm('-I', 'OUTPUT', '1', '-m', 'owner', '--uid-owner', str(user),
if user is not None: '-j', 'MARK', '--set-mark', str(port))
margs += ['--uid-owner', str(user)]
if group is not None:
margs += ['--gid-owner', str(group)]
margs += ['-j', 'MARK', '--set-mark', str(port)]
nonfatal(_ipm, *margs)
args = '-m', 'mark', '--mark', str(port), '-j', chain args = '-m', 'mark', '--mark', str(port), '-j', chain
else: else:
args = '-j', chain args = '-j', chain
@ -59,6 +54,11 @@ class Method(BaseMethod):
'--dport', '53', '--dport', '53',
'--to-ports', str(dnsport)) '--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. # create new subnet entries.
for _, swidth, sexclude, snet, fport, lport \ for _, swidth, sexclude, snet, fport, lport \
in sorted(subnets, key=subnet_weight, reverse=True): in sorted(subnets, key=subnet_weight, reverse=True):
@ -75,12 +75,7 @@ class Method(BaseMethod):
'--dest', '%s/%s' % (snet, swidth), '--dest', '%s/%s' % (snet, swidth),
*(tcp_ports + ('--to-ports', str(port)))) *(tcp_ports + ('--to-ports', str(port))))
# Don't route any remaining local traffic through sshuttle. def restore_firewall(self, port, family, udp, user):
_ipt('-A', chain, '-j', 'RETURN',
'-m', 'addrtype',
'--dst-type', 'LOCAL')
def restore_firewall(self, port, family, udp, user, group):
# only ipv4 supported with NAT # only ipv4 supported with NAT
if family != socket.AF_INET and family != socket.AF_INET6: if family != socket.AF_INET and family != socket.AF_INET6:
raise Exception( raise Exception(
@ -101,15 +96,9 @@ class Method(BaseMethod):
# basic cleanup/setup of chains # basic cleanup/setup of chains
if ipt_chain_exists(family, table, chain): if ipt_chain_exists(family, table, chain):
if user is not None or group is not None: if user is not None:
margs = ['-D', 'OUTPUT', '-m', 'owner'] nonfatal(_ipm, '-D', 'OUTPUT', '-m', 'owner', '--uid-owner',
if user is not None: str(user), '-j', 'MARK', '--set-mark', str(port))
margs += ['--uid-owner', str(user)]
if group is not None:
margs += ['--gid-owner', str(group)]
margs += ['-j', 'MARK', '--set-mark', str(port)]
nonfatal(_ipm, *margs)
args = '-m', 'mark', '--mark', str(port), '-j', chain args = '-m', 'mark', '--mark', str(port), '-j', chain
else: else:
args = '-j', chain args = '-j', chain
@ -122,7 +111,6 @@ class Method(BaseMethod):
result = super(Method, self).get_supported_features() result = super(Method, self).get_supported_features()
result.user = True result.user = True
result.ipv6 = True result.ipv6 = True
result.group = True
return result return result
def is_supported(self): 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 # recently-started one will win (because we use "-I OUTPUT 1" instead of
# "-A OUTPUT"). # "-A OUTPUT").
def setup_firewall(self, port, dnsport, nslist, family, subnets, udp, def setup_firewall(self, port, dnsport, nslist, family, subnets, udp,
user, group, tmark): user, tmark):
if udp: if udp:
raise Exception("UDP not supported by nft") raise Exception("UDP not supported by nft")
@ -87,7 +87,7 @@ class Method(BaseMethod):
ip_version, 'daddr %s/%s' % (snet, swidth), ip_version, 'daddr %s/%s' % (snet, swidth),
('redirect to :' + str(port))))) ('redirect to :' + str(port)))))
def restore_firewall(self, port, family, udp, user, group): def restore_firewall(self, port, family, udp, user):
if udp: if udp:
raise Exception("UDP not supported by nft method_name") raise Exception("UDP not supported by nft method_name")

View File

@ -266,7 +266,7 @@ class OpenBsd(Generic):
("proto_variant", c_uint8), ("proto_variant", c_uint8),
("direction", c_uint8)] ("direction", c_uint8)]
self.pfioc_rule = c_char * 3408 self.pfioc_rule = c_char * 3424
self.pfioc_natlook = pfioc_natlook self.pfioc_natlook = pfioc_natlook
super(OpenBsd, self).__init__() super(OpenBsd, self).__init__()
@ -448,7 +448,7 @@ class Method(BaseMethod):
return sock.getsockname() return sock.getsockname()
def setup_firewall(self, port, dnsport, nslist, family, subnets, udp, 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]: if family not in [socket.AF_INET, socket.AF_INET6]:
raise Exception( raise Exception(
'Address family "%s" unsupported by pf method_name' '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.add_rules(anchor, includes, port, dnsport, nslist, family)
pf.enable() 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]: if family not in [socket.AF_INET, socket.AF_INET6]:
raise Exception( raise Exception(
'Address family "%s" unsupported by pf method_name' '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) udp_listener.v6.setsockopt(SOL_IPV6, IPV6_RECVORIGDSTADDR, 1)
def setup_firewall(self, port, dnsport, nslist, family, subnets, udp, 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]: if family not in [socket.AF_INET, socket.AF_INET6]:
raise Exception( raise Exception(
'Address family "%s" unsupported by tproxy method' 'Address family "%s" unsupported by tproxy method'
@ -127,14 +127,14 @@ class Method(BaseMethod):
def _ipt_proto_ports(proto, fport, lport): def _ipt_proto_ports(proto, fport, lport):
return proto + ('--dport', '%d:%d' % (fport, lport)) \ return proto + ('--dport', '%d:%d' % (fport, lport)) \
if fport else proto if fport else proto
mark_chain = 'sshuttle-m-%s' % port mark_chain = 'sshuttle-m-%s' % port
tproxy_chain = 'sshuttle-t-%s' % port tproxy_chain = 'sshuttle-t-%s' % port
divert_chain = 'sshuttle-d-%s' % port divert_chain = 'sshuttle-d-%s' % port
# basic cleanup/setup of chains # 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('-N', mark_chain)
_ipt('-F', mark_chain) _ipt('-F', mark_chain)
@ -145,18 +145,8 @@ class Method(BaseMethod):
_ipt('-I', 'OUTPUT', '1', '-j', mark_chain) _ipt('-I', 'OUTPUT', '1', '-j', mark_chain)
_ipt('-I', 'PREROUTING', '1', '-j', tproxy_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 # 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 # Without this fix, if a large subnet is redirected through
# sshuttle (i.e., 0/0), then the user may be unable to receive # 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, _ipt('-A', tproxy_chain, '-m', 'socket', '-j', divert_chain,
'-m', 'udp', '-p', 'udp') '-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 \ for _, swidth, sexclude, snet, fport, lport \
in sorted(subnets, key=subnet_weight, reverse=True): in sorted(subnets, key=subnet_weight, reverse=True):
tcp_ports = ('-p', 'tcp') tcp_ports = ('-p', 'tcp')
@ -228,7 +228,7 @@ class Method(BaseMethod):
'-m', 'udp', '-m', 'udp',
*(udp_ports + ('--on-port', str(port)))) *(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]: if family not in [socket.AF_INET, socket.AF_INET6]:
raise Exception( raise Exception(
'Address family "%s" unsupported by tproxy method' '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 re
import socket import socket
import sys
from argparse import ArgumentParser, Action, ArgumentTypeError as Fatal from argparse import ArgumentParser, Action, ArgumentTypeError as Fatal
from sshuttle import __version__ from sshuttle import __version__
@ -38,9 +37,9 @@ def parse_subnetport_file(s):
def parse_subnetport(s): def parse_subnetport(s):
if s.count(':') > 1: if s.count(':') > 1:
rx = r'(?:\[?(?:\*\.)?([\w\:]+)(?:/(\d+))?]?)(?::(\d+)(?:-(\d+))?)?$' rx = r'(?:\[?([\w\:]+)(?:/(\d+))?]?)(?::(\d+)(?:-(\d+))?)?$'
else: else:
rx = r'((?:\*\.)?[\w\.\-]+)(?:/(\d+))?(?::(\d+)(?:-(\d+))?)?$' rx = r'([\w\.\-]+)(?:/(\d+))?(?::(\d+)(?:-(\d+))?)?$'
m = re.match(rx, s) m = re.match(rx, s)
if not m: if not m:
@ -137,15 +136,6 @@ def parse_list(lst):
return re.split(r'[\s,]+', lst.strip()) if lst else [] 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): class Concat(Action):
def __init__(self, option_strings, dest, nargs=None, **kwargs): def __init__(self, option_strings, dest, nargs=None, **kwargs):
if nargs is not None: 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( parser.add_argument(
"--method", "--method",
choices=method_choices, choices=["auto", "nat", "nft", "tproxy", "pf", "ipfw"],
metavar="TYPE", metavar="TYPE",
default="auto", default="auto",
help=""" help="""
@ -316,22 +301,6 @@ parser.add_argument(
the command to use to connect to the remote [%(default)s] 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( parser.add_argument(
"--seed-hosts", "--seed-hosts",
metavar="HOSTNAME[,HOSTNAME]", metavar="HOSTNAME[,HOSTNAME]",
@ -413,12 +382,6 @@ parser.add_argument(
apply all the rules only to this linux user 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( parser.add_argument(
"--firewall", "--firewall",
action="store_true", action="store_true",
@ -433,15 +396,18 @@ parser.add_argument(
(internal use only) (internal use only)
""" """
) )
parser.add_argument(
"--sudoers",
action="store_true",
help="""
Add sshuttle to the sudoers for this user
"""
)
parser.add_argument( parser.add_argument(
"--sudoers-no-modify", "--sudoers-no-modify",
action="store_true", action="store_true",
help=""" help="""
Prints a sudo configuration to STDOUT which allows a user to Prints the sudoers config to STDOUT and DOES NOT modify anything.
run sshuttle without a password. This option is INSECURE because,
with some cleverness, it also allows the user to run any command
as root without a password. The output also includes a suggested
method for you to install the configuration.
""" """
) )
parser.add_argument( parser.add_argument(
@ -449,7 +415,16 @@ parser.add_argument(
default="", default="",
help=""" help="""
Set the user name or group with %%group_name for passwordless operation. 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( parser.add_argument(
@ -469,20 +444,3 @@ parser.add_argument(
hexadecimal (default '0x01') 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 time
import sys import sys
import os import os
import io
import sshuttle.ssnet as ssnet import sshuttle.ssnet as ssnet
@ -14,7 +13,7 @@ import sshuttle.hostwatch as hostwatch
import subprocess as ssubprocess import subprocess as ssubprocess
from sshuttle.ssnet import Handler, Proxy, Mux, MuxWrapper from sshuttle.ssnet import Handler, Proxy, Mux, MuxWrapper
from sshuttle.helpers import b, log, debug1, debug2, debug3, Fatal, \ 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): def _ipmatch(ipstr):
@ -35,6 +34,7 @@ def _ipmatch(ipstr):
elif g[3] is None: elif g[3] is None:
ips += '.0' ips += '.0'
width = min(width, 24) width = min(width, 24)
ips = ips
return (struct.unpack('!I', socket.inet_aton(ips))[0], width) return (struct.unpack('!I', socket.inet_aton(ips))[0], width)
@ -79,20 +79,6 @@ def _route_iproute(line):
return ipw, int(mask) 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): def _list_routes(argv, extract_route):
# FIXME: IPv4 only # FIXME: IPv4 only
p = ssubprocess.Popen(argv, stdout=ssubprocess.PIPE, env=get_env()) p = ssubprocess.Popen(argv, stdout=ssubprocess.PIPE, env=get_env())
@ -115,17 +101,14 @@ def _list_routes(argv, extract_route):
def list_routes(): def list_routes():
if sys.platform == 'win32': if which('ip'):
routes = _list_routes(['route', 'PRINT', '-4'], _route_windows) routes = _list_routes(['ip', 'route'], _route_iproute)
elif which('netstat'):
routes = _list_routes(['netstat', '-rn'], _route_netstat)
else: else:
if which('ip'): log('WARNING: Neither "ip" nor "netstat" were found on the server. '
routes = _list_routes(['ip', 'route'], _route_iproute) '--auto-nets feature will not work.')
elif which('netstat'): routes = []
routes = _list_routes(['netstat', '-rn'], _route_netstat)
else:
log('WARNING: Neither "ip" nor "netstat" were found on the server. '
'--auto-nets feature will not work.')
routes = []
for (family, ip, width) in routes: for (family, ip, width) in routes:
if not ip.startswith('0.') and not ip.startswith('127.'): if not ip.startswith('0.') and not ip.startswith('127.'):
@ -199,7 +182,7 @@ class DnsProxy(Handler):
self.tries += 1 self.tries += 1
if self.to_nameserver is None: if self.to_nameserver is None:
_, peer = get_random_nameserver() _, peer = resolvconf_random_nameserver(False)
port = 53 port = 53
else: else:
peer = self.to_ns_peer peer = self.to_ns_peer
@ -299,16 +282,7 @@ def main(latency_control, latency_buffer_size, auto_hosts, to_nameserver,
sys.stdout.flush() sys.stdout.flush()
handlers = [] handlers = []
# get unbuffered stdin and stdout in binary mode. Equivalent to stdin.buffer/stdout.buffer (Only available in Python 3) mux = Mux(sys.stdin, sys.stdout)
r, w = io.FileIO(0, mode='r'), io.FileIO(1, mode='w')
if sys.platform == 'win32':
def _deferred_exit():
time.sleep(1) # give enough time to write logs to stderr
os._exit(23)
shim = SocketRWShim(r, w, on_end=_deferred_exit)
mux = Mux(*shim.makefiles())
else:
mux = Mux(r, w)
handlers.append(mux) handlers.append(mux)
debug1('auto-nets:' + str(auto_nets)) debug1('auto-nets:' + str(auto_nets))
@ -329,7 +303,7 @@ def main(latency_control, latency_buffer_size, auto_hosts, to_nameserver,
hw.leftover = b('') hw.leftover = b('')
def hostwatch_ready(sock): def hostwatch_ready(sock):
assert hw.pid assert(hw.pid)
content = hw.sock.recv(4096) content = hw.sock.recv(4096)
if content: if content:
lines = (hw.leftover + content).split(b('\n')) 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: while mux.ok:
if hw.pid: if hw.pid:
assert hw.pid > 0 assert(hw.pid > 0)
(rpid, rv) = os.waitpid(hw.pid, os.WNOHANG) (rpid, rv) = os.waitpid(hw.pid, os.WNOHANG)
if rpid: if rpid:
raise Fatal( raise Fatal(

View File

@ -12,7 +12,7 @@ import ipaddress
from urllib.parse import urlparse from urllib.parse import urlparse
import sshuttle.helpers as helpers 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): def get_module_source(name):
@ -56,7 +56,7 @@ def parse_hostport(rhostport):
# Fix #410 bad username error detect # Fix #410 bad username error detect
if ":" in username: if ":" in username:
# this will even allow for the username to be empty # this will even allow for the username to be empty
username, password = username.split(":", 1) username, password = username.split(":")
if ":" in host: if ":" in host:
# IPv6 address and/or got a port specified # IPv6 address and/or got a port specified
@ -84,7 +84,7 @@ def parse_hostport(rhostport):
return username, password, port, host 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) username, password, port, host = parse_hostport(rhostport)
if username: if username:
rhost = "{}@{}".format(username, host) rhost = "{}@{}".format(username, host)
@ -115,8 +115,8 @@ def connect(ssh_cmd, rhostport, python, stderr, add_cmd_delimiter, remote_shell,
pyscript = r""" pyscript = r"""
import sys, os; import sys, os;
verbosity=%d; verbosity=%d;
stdin = os.fdopen(0, 'rb'); sys.stdin = os.fdopen(0, "rb");
exec(compile(stdin.read(%d), 'assembler.py', 'exec')); exec(compile(sys.stdin.read(%d), "assembler.py", "exec"));
sys.exit(98); sys.exit(98);
""" % (helpers.verbose or 0, len(content)) """ % (helpers.verbose or 0, len(content))
pyscript = re.sub(r'\s+', ' ', pyscript.strip()) 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)] portl = ["-p", str(port)]
else: else:
portl = [] portl = []
if remote_shell == "cmd": if python:
pycmd = '"%s" -c "%s"' % (python or 'python', pyscript) pycmd = "'%s' -c '%s'" % (python, pyscript)
elif remote_shell == "powershell": else:
for c in ('\'', ' ', ';', '(', ')', ','): # By default, we run the following code in a shell.
pyscript = pyscript.replace(c, '`' + c) # However, with restricted shells and other unusual
pycmd = '%s -c %s' % (python or 'python', pyscript) # situations, there can be trouble. See the RESTRICTED
else: # posix shell expected # SHELL section in "man bash" for more information. The
if python: # code makes many assumptions:
pycmd = '"%s" -c "%s"' % (python, pyscript) #
else: # (1) That /bin/sh exists and that we can call it.
# By default, we run the following code in a shell. # Restricted shells often do *not* allow you to run
# However, with restricted shells and other unusual # programs specified with an absolute path like /bin/sh.
# situations, there can be trouble. See the RESTRICTED # Either way, if there is trouble with this, it should
# SHELL section in "man bash" for more information. The # return error code 127.
# code makes many assumptions: #
# # (2) python3 or python exists in the PATH and is
# (1) That /bin/sh exists and that we can call it. # executable. If they aren't, then exec won't work (see (4)
# Restricted shells often do *not* allow you to run # below).
# programs specified with an absolute path like /bin/sh. #
# Either way, if there is trouble with this, it should # (3) In /bin/sh, that we can redirect stderr in order to
# return error code 127. # hide the version that "python3 -V" might print (some
# # restricted shells don't allow redirection, see
# (2) python3 or python exists in the PATH and is # RESTRICTED SHELL section in 'man bash'). However, if we
# executable. If they aren't, then exec won't work (see (4) # are in a restricted shell, we'd likely have trouble with
# below). # assumption (1) above.
# #
# (3) In /bin/sh, that we can redirect stderr in order to # (4) The 'exec' command should work except if we failed
# hide the version that "python3 -V" might print (some # to exec python because it doesn't exist or isn't
# restricted shells don't allow redirection, see # executable OR if exec isn't allowed (some restricted
# RESTRICTED SHELL section in 'man bash'). However, if we # shells don't allow exec). If the exec succeeded, it will
# are in a restricted shell, we'd likely have trouble with # not return and not get to the "exit 97" command. If exec
# assumption (1) above. # does return, we exit with code 97.
# #
# (4) The 'exec' command should work except if we failed # Specifying the exact python program to run with --python
# to exec python because it doesn't exist or isn't # avoids many of the issues above. However, if
# executable OR if exec isn't allowed (some restricted # you have a restricted shell on remote, you may only be
# shells don't allow exec). If the exec succeeded, it will # able to run python if it is in your PATH (and you can't
# not return and not get to the "exit 97" command. If exec # run programs specified with an absolute path). In that
# does return, we exit with code 97. # case, sshuttle might not work at all since it is not
# # possible to run python on the remote machine---even if
# Specifying the exact python program to run with --python # it is present.
# avoids many of the issues above. However, if pycmd = ("P=python3; $P -V 2>%s || P=python; "
# you have a restricted shell on remote, you may only be "exec \"$P\" -c %s; exit 97") % \
# able to run python if it is in your PATH (and you can't (os.devnull, quote(pyscript))
# run programs specified with an absolute path). In that pycmd = ("/bin/sh -c {}".format(quote(pycmd)))
# case, sshuttle might not work at all since it is not
# possible to run python on the remote machine---even if
# it is present.
devnull = '/dev/null'
pycmd = ("P=python3; $P -V 2>%s || P=python; "
"exec \"$P\" -c %s; exit 97") % \
(devnull, quote(pyscript))
pycmd = ("/bin/sh -c {}".format(quote(pycmd)))
if password is not None: if password is not None:
os.environ['SSHPASS'] = str(password) os.environ['SSHPASS'] = str(password)
argv = (["sshpass", "-e"] + sshl + argv = (["sshpass", "-e"] + sshl +
portl + [rhost]) portl +
[rhost, '--', pycmd])
else: else:
argv = (sshl + portl + [rhost]) argv = (sshl +
portl +
if add_cmd_delimiter: [rhost, '--', pycmd])
argv += ['--', pycmd]
else:
argv += [pycmd]
# Our which() function searches for programs in get_path() # Our which() function searches for programs in get_path()
# directories (which include PATH). This step isn't strictly # 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())) raise Fatal("Failed to find '%s' in path %s" % (argv[0], get_path()))
argv[0] = abs_path argv[0] = abs_path
if sys.platform != 'win32': (s1, s2) = socket.socketpair()
(s1, s2) = socket.socketpair()
pstdin, pstdout = os.dup(s1.fileno()), os.dup(s1.fileno())
def preexec_fn(): def setup():
# runs in the child process # runs in the child process
s2.close() s2.close()
s1.close() s1a, s1b = os.dup(s1.fileno()), os.dup(s1.fileno())
s1.close()
def get_server_io(): debug2('executing: %r' % argv)
os.close(pstdin) p = ssubprocess.Popen(argv, stdin=s1a, stdout=s1b, preexec_fn=setup,
os.close(pstdout) close_fds=True, stderr=stderr)
return s2.makefile("rb", buffering=0), s2.makefile("wb", buffering=0) os.close(s1a)
else: os.close(s1b)
# In Windows CPython, BSD sockets are not supported as subprocess stdio s2.sendall(content)
# and select.select() used in ssnet.py won't work on Windows pipes. s2.sendall(content2)
# So we have to use both socketpair (for select.select) and pipes (for subprocess.Popen) together return p, s2
# 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

View File

@ -4,8 +4,9 @@ import socket
import errno import errno
import select import select
import os 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 MAX_CHANNEL = 65535
LATENCY_BUFFER_SIZE = 32768 LATENCY_BUFFER_SIZE = 32768
@ -77,8 +78,7 @@ def _fds(socks):
def _nb_clean(func, *args): def _nb_clean(func, *args):
try: try:
return func(*args) return func(*args)
except (OSError, socket.error): except OSError:
# Note: In python2 socket.error != OSError (In python3, they are same)
_, e = sys.exc_info()[:2] _, e = sys.exc_info()[:2]
if e.errno not in (errno.EWOULDBLOCK, errno.EAGAIN): if e.errno not in (errno.EWOULDBLOCK, errno.EAGAIN):
raise raise
@ -168,25 +168,19 @@ class SockWrapper:
debug3('%r: fixed connect result: %s' % (self, e)) debug3('%r: fixed connect result: %s' % (self, e))
if e.args[0] in [errno.EINPROGRESS, errno.EALREADY]: if e.args[0] in [errno.EINPROGRESS, errno.EALREADY]:
pass # not connected yet pass # not connected yet
elif sys.platform == 'win32' and e.args[0] == errno.WSAEWOULDBLOCK: # 10035
pass # not connected yet
elif e.args[0] == 0: elif e.args[0] == 0:
if sys.platform == 'win32': # connected successfully (weird Linux bug?)
# On Windows "real" error of EINVAL could be 0, when socket is in connecting state # Sometimes Linux seems to return EINVAL when it isn't
pass # invalid. This *may* be caused by a race condition
else: # between connect() and getsockopt(SO_ERROR) (ie. it
# connected successfully (weird Linux bug?) # finishes connecting in between the two, so there is no
# Sometimes Linux seems to return EINVAL when it isn't # longer an error). However, I'm not sure of that.
# invalid. This *may* be caused by a race condition #
# between connect() and getsockopt(SO_ERROR) (ie. it # I did get at least one report that the problem went away
# finishes connecting in between the two, so there is no # when we added this, however.
# longer an error). However, I'm not sure of that. self.connect_to = None
#
# 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: elif e.args[0] == errno.EISCONN:
# connected successfully (BSD + Windows) # connected successfully (BSD)
self.connect_to = None self.connect_to = None
elif e.args[0] in NET_ERRS + [errno.EACCES, errno.EPERM]: elif e.args[0] in NET_ERRS + [errno.EACCES, errno.EPERM]:
# a "normal" kind of error # a "normal" kind of error
@ -199,6 +193,7 @@ class SockWrapper:
if not self.shut_read: if not self.shut_read:
debug2('%r: done reading' % self) debug2('%r: done reading' % self)
self.shut_read = True self.shut_read = True
# self.rsock.shutdown(SHUT_RD) # doesn't do anything anyway
def nowrite(self): def nowrite(self):
if not self.shut_write: if not self.shut_write:
@ -219,7 +214,7 @@ class SockWrapper:
return 0 # still connecting return 0 # still connecting
self.wsock.setblocking(False) self.wsock.setblocking(False)
try: try:
return _nb_clean(self.wsock.send, buf) return _nb_clean(os.write, self.wsock.fileno(), buf)
except OSError: except OSError:
_, e = sys.exc_info()[:2] _, e = sys.exc_info()[:2]
if e.errno == errno.EPIPE: if e.errno == errno.EPIPE:
@ -232,7 +227,7 @@ class SockWrapper:
return 0 return 0
def write(self, buf): def write(self, buf):
assert buf assert(buf)
return self.uwrite(buf) return self.uwrite(buf)
def uread(self): def uread(self):
@ -242,7 +237,7 @@ class SockWrapper:
return return
self.rsock.setblocking(False) self.rsock.setblocking(False)
try: try:
return _nb_clean(self.rsock.recv, 65536) return _nb_clean(os.read, self.rsock.fileno(), 65536)
except OSError: except OSError:
_, e = sys.exc_info()[:2] _, e = sys.exc_info()[:2]
self.seterr('uread: %s' % e) self.seterr('uread: %s' % e)
@ -378,6 +373,11 @@ class Mux(Handler):
if not self.too_full: if not self.too_full:
self.send(0, CMD_PING, b('rttest')) self.send(0, CMD_PING, b('rttest'))
self.too_full = True 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): def send(self, channel, cmd, data):
assert isinstance(data, bytes) assert isinstance(data, bytes)
@ -388,13 +388,11 @@ class Mux(Handler):
debug2(' > channel=%d cmd=%s len=%d (fullness=%d)' debug2(' > channel=%d cmd=%s len=%d (fullness=%d)'
% (channel, cmd_to_name.get(cmd, hex(cmd)), % (channel, cmd_to_name.get(cmd, hex(cmd)),
len(data), self.fullness)) len(data), self.fullness))
# debug3('>>> data: %r' % data)
self.fullness += len(data) self.fullness += len(data)
def got_packet(self, channel, cmd, data): def got_packet(self, channel, cmd, data):
debug2('< channel=%d cmd=%s len=%d' debug2('< channel=%d cmd=%s len=%d'
% (channel, cmd_to_name.get(cmd, hex(cmd)), len(data))) % (channel, cmd_to_name.get(cmd, hex(cmd)), len(data)))
# debug3('<<< data: %r' % data)
if cmd == CMD_PING: if cmd == CMD_PING:
self.send(0, CMD_PONG, data) self.send(0, CMD_PONG, data)
elif cmd == CMD_PONG: elif cmd == CMD_PONG:
@ -404,15 +402,15 @@ class Mux(Handler):
elif cmd == CMD_EXIT: elif cmd == CMD_EXIT:
self.ok = False self.ok = False
elif cmd == CMD_TCP_CONNECT: elif cmd == CMD_TCP_CONNECT:
assert not self.channels.get(channel) assert(not self.channels.get(channel))
if self.new_channel: if self.new_channel:
self.new_channel(channel, data) self.new_channel(channel, data)
elif cmd == CMD_DNS_REQ: elif cmd == CMD_DNS_REQ:
assert not self.channels.get(channel) assert(not self.channels.get(channel))
if self.got_dns_req: if self.got_dns_req:
self.got_dns_req(channel, data) self.got_dns_req(channel, data)
elif cmd == CMD_UDP_OPEN: elif cmd == CMD_UDP_OPEN:
assert not self.channels.get(channel) assert(not self.channels.get(channel))
if self.got_udp_open: if self.got_udp_open:
self.got_udp_open(channel, data) self.got_udp_open(channel, data)
elif cmd == CMD_ROUTES: elif cmd == CMD_ROUTES:
@ -439,10 +437,15 @@ class Mux(Handler):
callback(cmd, data) callback(cmd, data)
def flush(self): 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]: if self.outbuf and self.outbuf[0]:
wrote = _nb_clean(self.wfile.write, self.outbuf[0]) wrote = _nb_clean(os.write, self.wfile.fileno(), self.outbuf[0])
# self.wfile.flush()
debug2('mux wrote: %r/%d' % (wrote, len(self.outbuf[0]))) debug2('mux wrote: %r/%d' % (wrote, len(self.outbuf[0])))
if wrote: if wrote:
self.outbuf[0] = self.outbuf[0][wrote:] self.outbuf[0] = self.outbuf[0][wrote:]
@ -450,12 +453,18 @@ class Mux(Handler):
self.outbuf[0:1] = [] self.outbuf[0:1] = []
def fill(self): 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: try:
# If LATENCY_BUFFER_SIZE is inappropriately large, we will # If LATENCY_BUFFER_SIZE is inappropriately large, we will
# get a MemoryError here. Read no more than 1MiB. # get a MemoryError here. Read no more than 1MiB.
read = _nb_clean(self.rfile.read, min(1048576, LATENCY_BUFFER_SIZE)) read = _nb_clean(os.read, self.rfile.fileno(),
debug2('mux read: %r' % len(read)) min(1048576, LATENCY_BUFFER_SIZE))
except OSError: except OSError:
_, e = sys.exc_info()[:2] _, e = sys.exc_info()[:2]
raise Fatal('other end: %r' % e) raise Fatal('other end: %r' % e)
@ -467,12 +476,14 @@ class Mux(Handler):
def handle(self): def handle(self):
self.fill() self.fill()
# log('inbuf is: (%d,%d) %r'
# % (self.want, len(self.inbuf), self.inbuf))
while 1: while 1:
if len(self.inbuf) >= (self.want or HDR_LEN): if len(self.inbuf) >= (self.want or HDR_LEN):
(s1, s2, channel, cmd, datalen) = \ (s1, s2, channel, cmd, datalen) = \
struct.unpack('!ccHHH', self.inbuf[:HDR_LEN]) struct.unpack('!ccHHH', self.inbuf[:HDR_LEN])
assert s1 == b('S') assert(s1 == b('S'))
assert s2 == b('S') assert(s2 == b('S'))
self.want = datalen + HDR_LEN self.want = datalen + HDR_LEN
if self.want and len(self.inbuf) >= self.want: if self.want and len(self.inbuf) >= self.want:
data = self.inbuf[HDR_LEN:self.want] data = self.inbuf[HDR_LEN:self.want]

View File

@ -10,7 +10,7 @@ def start_syslog():
global _p global _p
with open(os.devnull, 'w') as devnull: with open(os.devnull, 'w') as devnull:
_p = ssubprocess.Popen( _p = ssubprocess.Popen(
['logger', '-p', 'daemon.err', '-t', 'sshuttle'], ['logger', '-p', 'daemon.notice', '-t', 'sshuttle'],
stdin=ssubprocess.PIPE, stdin=ssubprocess.PIPE,
stdout=devnull, stdout=devnull,
stderr=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 sys
import getpass import getpass
from uuid import uuid4 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): 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]) return content
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
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() user_name = user_name or getpass.getuser()
content = build_config(user_name) 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 io
import os
from socket import AF_INET, AF_INET6 from socket import AF_INET, AF_INET6
from unittest.mock import Mock, patch, call from unittest.mock import Mock, patch, call
import pytest
import sshuttle.firewall import sshuttle.firewall
def setup_daemon(): def setup_daemon():
stdin = io.BytesIO(u"""ROUTES stdin = io.StringIO(u"""ROUTES
{inet},24,0,1.2.3.0,8000,9000 {inet},24,0,1.2.3.0,8000,9000
{inet},32,1,1.2.3.66,8080,8080 {inet},32,1,1.2.3.66,8080,8080
{inet6},64,0,2404:6800:4004:80c::,0,0 {inet6},64,0,2404:6800:4004:80c::,0,0
@ -19,9 +15,9 @@ NSLIST
{inet},1.2.3.33 {inet},1.2.3.33
{inet6},2404:6800:4004:80c::33 {inet6},2404:6800:4004:80c::33
PORTS 1024,1025,1026,1027 PORTS 1024,1025,1026,1027
GO 1 - - 0x01 12345 GO 1 - 0x01 12345
HOST 1.2.3.3,existing HOST 1.2.3.3,existing
""".format(inet=AF_INET, inet6=AF_INET6).encode('ASCII')) """.format(inet=AF_INET, inet6=AF_INET6))
stdout = Mock() stdout = Mock()
return stdin, stdout return stdin, stdout
@ -63,21 +59,6 @@ def test_rewrite_etc_hosts(tmpdir):
assert orig_hosts.computehash() == new_hosts.computehash() 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(): def test_subnet_weight():
subnets = [ subnets = [
(AF_INET, 16, 0, '192.168.0.0', 0, 0), (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 == [ assert stdout.mock_calls == [
call.write(b'READY test\n'), call.write('READY test\n'),
call.flush(), call.flush(),
call.write(b'STARTED\n'), call.write('STARTED\n'),
call.flush() call.flush()
] ]
assert mock_setup_daemon.mock_calls == [call()] 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, u'2404:6800:4004:80c::33')],
AF_INET6, AF_INET6,
[(AF_INET6, 64, False, u'2404:6800:4004:80c::', 0, 0), [(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, True,
None, None,
None,
'0x01'), '0x01'),
call().setup_firewall( call().setup_firewall(
1025, 1027, 1025, 1027,
[(AF_INET, u'1.2.3.33')], [(AF_INET, u'1.2.3.33')],
AF_INET, AF_INET,
[(AF_INET, 24, False, u'1.2.3.0', 8000, 9000), [(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, True,
None, None,
None,
'0x01'), '0x01'),
call().wait_for_firewall_ready(12345), call().restore_firewall(1024, AF_INET6, True, None),
call().restore_firewall(1024, AF_INET6, True, None, None), call().restore_firewall(1025, AF_INET, True, None),
call().restore_firewall(1025, AF_INET, True, None, None),
] ]

View File

@ -24,19 +24,19 @@ def test_log(mock_stderr, mock_stdout):
call.flush(), call.flush(),
] ]
assert mock_stderr.mock_calls == [ assert mock_stderr.mock_calls == [
call.write('prefix: message\n'), call.write('prefix: message\r\n'),
call.flush(), call.flush(),
call.write('prefix: abc\n'), call.write('prefix: abc\r\n'),
call.flush(), call.flush(),
call.write('prefix: message 1\n'), call.write('prefix: message 1\r\n'),
call.flush(), call.flush(),
call.write('prefix: message 2\n'), call.write('prefix: message 2\r\n'),
call.write(' line2\n'), call.write(' line2\r\n'),
call.write(' line3\n'), call.write(' line3\r\n'),
call.flush(), call.flush(),
call.write('prefix: message 3\n'), call.write('prefix: message 3\r\n'),
call.write(' line2\n'), call.write(' line2\r\n'),
call.write(' line3\n'), call.write(' line3\r\n'),
call.flush(), call.flush(),
] ]
@ -51,7 +51,7 @@ def test_debug1(mock_stderr, mock_stdout):
call.flush(), call.flush(),
] ]
assert mock_stderr.mock_calls == [ assert mock_stderr.mock_calls == [
call.write('prefix: message\n'), call.write('prefix: message\r\n'),
call.flush(), call.flush(),
] ]
@ -76,7 +76,7 @@ def test_debug2(mock_stderr, mock_stdout):
call.flush(), call.flush(),
] ]
assert mock_stderr.mock_calls == [ assert mock_stderr.mock_calls == [
call.write('prefix: message\n'), call.write('prefix: message\r\n'),
call.flush(), call.flush(),
] ]
@ -101,7 +101,7 @@ def test_debug3(mock_stderr, mock_stdout):
call.flush(), call.flush(),
] ]
assert mock_stderr.mock_calls == [ assert mock_stderr.mock_calls == [
call.write('prefix: message\n'), call.write('prefix: message\r\n'),
call.flush(), call.flush(),
] ]
@ -143,7 +143,7 @@ nameserver 2404:6800:4004:80c::4
@patch('sshuttle.helpers.open', create=True) @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""" mock_open.return_value = io.StringIO(u"""
# Generated by NetworkManager # Generated by NetworkManager
search pri search pri
@ -156,7 +156,7 @@ nameserver 2404:6800:4004:80c::2
nameserver 2404:6800:4004:80c::3 nameserver 2404:6800:4004:80c::3
nameserver 2404:6800:4004:80c::4 nameserver 2404:6800:4004:80c::4
""") """)
ns = sshuttle.helpers.get_random_nameserver() ns = sshuttle.helpers.resolvconf_random_nameserver(False)
assert ns in [ assert ns in [
(AF_INET, u'192.168.1.1'), (AF_INET, u'192.168.2.1'), (AF_INET, u'192.168.1.1'), (AF_INET, u'192.168.2.1'),
(AF_INET, u'192.168.3.1'), (AF_INET, u'192.168.4.1'), (AF_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(): def test_family_to_string():
assert sshuttle.helpers.family_to_string(AF_INET) == "AF_INET" assert sshuttle.helpers.family_to_string(AF_INET) == "AF_INET"
assert sshuttle.helpers.family_to_string(AF_INET6) == "AF_INET6" 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(): def test_firewall_command():
method = get_method('nat') method = get_method('nat')
assert not method.firewall_command("something") assert not method.firewall_command("somthing")
@patch('sshuttle.methods.nat.ipt') @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)], (AF_INET6, 128, True, u'2404:6800:4004:80c::101f', 80, 80)],
False, False,
None, None,
None,
'0x01') '0x01')
assert mock_ipt_chain_exists.mock_calls == [ 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', call(AF_INET6, 'nat', '-A', 'sshuttle-1024', '-j', 'REDIRECT',
'--dest', u'2404:6800:4004:80c::33', '-p', 'udp', '--dest', u'2404:6800:4004:80c::33', '-p', 'udp',
'--dport', '53', '--to-ports', '1026'), '--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', call(AF_INET6, 'nat', '-A', 'sshuttle-1024', '-j', 'RETURN',
'--dest', u'2404:6800:4004:80c::101f/128', '-p', 'tcp', '--dest', u'2404:6800:4004:80c::101f/128', '-p', 'tcp',
'--dport', '80:80'), '--dport', '80:80'),
call(AF_INET6, 'nat', '-A', 'sshuttle-1024', '-j', 'REDIRECT', call(AF_INET6, 'nat', '-A', 'sshuttle-1024', '-j', 'REDIRECT',
'--dest', u'2404:6800:4004:80c::/64', '-p', 'tcp', '--dest', u'2404:6800:4004:80c::/64', '-p', 'tcp',
'--to-ports', '1024'), '--to-ports', '1024')
call(AF_INET6, 'nat', '-A', 'sshuttle-1024', '-j', 'RETURN',
'-m', 'addrtype', '--dst-type', 'LOCAL')
] ]
mock_ipt_chain_exists.reset_mock() mock_ipt_chain_exists.reset_mock()
mock_ipt.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)], (AF_INET, 32, True, u'1.2.3.66', 8080, 8080)],
True, True,
None, None,
None,
'0x01') '0x01')
assert str(excinfo.value) == 'UDP not supported by nat method_name' assert str(excinfo.value) == 'UDP not supported by nat method_name'
assert mock_ipt_chain_exists.mock_calls == [] 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)], (AF_INET, 32, True, u'1.2.3.66', 8080, 8080)],
False, False,
None, None,
None,
'0x01') '0x01')
assert mock_ipt_chain_exists.mock_calls == [ assert mock_ipt_chain_exists.mock_calls == [
call(AF_INET, 'nat', 'sshuttle-1025') 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', call(AF_INET, 'nat', '-A', 'sshuttle-1025', '-j', 'REDIRECT',
'--dest', u'1.2.3.33', '-p', 'udp', '--dest', u'1.2.3.33', '-p', 'udp',
'--dport', '53', '--to-ports', '1027'), '--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', call(AF_INET, 'nat', '-A', 'sshuttle-1025', '-j', 'RETURN',
'--dest', u'1.2.3.66/32', '-p', 'tcp', '--dport', '8080:8080'), '--dest', u'1.2.3.66/32', '-p', 'tcp', '--dport', '8080:8080'),
call(AF_INET, 'nat', '-A', 'sshuttle-1025', '-j', 'REDIRECT', call(AF_INET, 'nat', '-A', 'sshuttle-1025', '-j', 'REDIRECT',
'--dest', u'1.2.3.0/24', '-p', 'tcp', '--dport', '8000:9000', '--dest', u'1.2.3.0/24', '-p', 'tcp', '--dport', '8000:9000',
'--to-ports', '1025'), '--to-ports', '1025')
call(AF_INET, 'nat', '-A', 'sshuttle-1025', '-j', 'RETURN',
'-m', 'addrtype', '--dst-type', 'LOCAL'),
] ]
mock_ipt_chain_exists.reset_mock() mock_ipt_chain_exists.reset_mock()
mock_ipt.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 == [ assert mock_ipt_chain_exists.mock_calls == [
call(AF_INET, 'nat', 'sshuttle-1025') 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_chain_exists.reset_mock()
mock_ipt.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 == [ assert mock_ipt_chain_exists.mock_calls == [
call(AF_INET6, 'nat', 'sshuttle-1025') call(AF_INET6, 'nat', 'sshuttle-1025')
] ]

View File

@ -92,7 +92,7 @@ def test_assert_features():
@patch('sshuttle.methods.pf.pf_get_dev') @patch('sshuttle.methods.pf.pf_get_dev')
def test_firewall_command_darwin(mock_pf_get_dev, mock_ioctl, mock_stdout): def test_firewall_command_darwin(mock_pf_get_dev, mock_ioctl, mock_stdout):
method = get_method('pf') 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" % ( command = "QUERY_PF_NAT %d,%d,%s,%d,%s,%d\n" % (
AF_INET, socket.IPPROTO_TCP, 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') @patch('sshuttle.methods.pf.pf_get_dev')
def test_firewall_command_freebsd(mock_pf_get_dev, mock_ioctl, mock_stdout): def test_firewall_command_freebsd(mock_pf_get_dev, mock_ioctl, mock_stdout):
method = get_method('pf') 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" % ( command = "QUERY_PF_NAT %d,%d,%s,%d,%s,%d\n" % (
AF_INET, socket.IPPROTO_TCP, 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') @patch('sshuttle.methods.pf.pf_get_dev')
def test_firewall_command_openbsd(mock_pf_get_dev, mock_ioctl, mock_stdout): def test_firewall_command_openbsd(mock_pf_get_dev, mock_ioctl, mock_stdout):
method = get_method('pf') 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" % ( command = "QUERY_PF_NAT %d,%d,%s,%d,%s,%d\n" % (
AF_INET, socket.IPPROTO_TCP, 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)], (AF_INET6, 128, True, u'2404:6800:4004:80c::101f', 8080, 8080)],
False, False,
None, None,
None,
'0x01') '0x01')
assert mock_ioctl.mock_calls == [ assert mock_ioctl.mock_calls == [
call(mock_pf_get_dev(), 0xC4704433, ANY), 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)], (AF_INET, 32, True, u'1.2.3.66', 80, 80)],
True, True,
None, None,
None,
'0x01') '0x01')
assert str(excinfo.value) == 'UDP not supported by pf method_name' assert str(excinfo.value) == 'UDP not supported by pf method_name'
assert mock_pf_get_dev.mock_calls == [] 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)], (AF_INET, 32, True, u'1.2.3.66', 80, 80)],
False, False,
None, None,
None,
'0x01') '0x01')
assert mock_ioctl.mock_calls == [ assert mock_ioctl.mock_calls == [
call(mock_pf_get_dev(), 0xC4704433, ANY), 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_ioctl.reset_mock()
mock_pfctl.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_ioctl.mock_calls == []
assert mock_pfctl.mock_calls == [ assert mock_pfctl.mock_calls == [
call('-a sshuttle-1025 -F all'), 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)], (AF_INET6, 128, True, u'2404:6800:4004:80c::101f', 8080, 8080)],
False, False,
None, None,
None,
'0x01') '0x01')
assert mock_pfctl.mock_calls == [ 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)], (AF_INET, 32, True, u'1.2.3.66', 80, 80)],
True, True,
None, None,
None,
'0x01') '0x01')
assert str(excinfo.value) == 'UDP not supported by pf method_name' assert str(excinfo.value) == 'UDP not supported by pf method_name'
assert mock_pf_get_dev.mock_calls == [] 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)], (AF_INET, 32, True, u'1.2.3.66', 80, 80)],
False, False,
None, None,
None,
'0x01') '0x01')
assert mock_ioctl.mock_calls == [ assert mock_ioctl.mock_calls == [
call(mock_pf_get_dev(), 0xC4704433, ANY), 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_ioctl.reset_mock()
mock_pfctl.reset_mock() mock_pfctl.reset_mock()
method.restore_firewall(1025, AF_INET, False, None, None) method.restore_firewall(1025, AF_INET, False, None)
method.restore_firewall(1024, AF_INET6, False, None, None) method.restore_firewall(1024, AF_INET6, False, None)
assert mock_ioctl.mock_calls == [] assert mock_ioctl.mock_calls == []
assert mock_pfctl.mock_calls == [ assert mock_pfctl.mock_calls == [
call('-a sshuttle-1025 -F all'), 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)], (AF_INET6, 128, True, u'2404:6800:4004:80c::101f', 8080, 8080)],
False, False,
None, None,
None,
'0x01') '0x01')
assert mock_ioctl.mock_calls == [ assert mock_ioctl.mock_calls == [
call(mock_pf_get_dev(), 0xcd50441a, ANY), call(mock_pf_get_dev(), 0xcd60441a, ANY),
call(mock_pf_get_dev(), 0xcd50441a, ANY), call(mock_pf_get_dev(), 0xcd60441a, ANY),
] ]
assert mock_pfctl.mock_calls == [ assert mock_pfctl.mock_calls == [
call('-s Interfaces -i lo -v'), 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)], (AF_INET, 32, True, u'1.2.3.66', 80, 80)],
True, True,
None, None,
None,
'0x01') '0x01')
assert str(excinfo.value) == 'UDP not supported by pf method_name' assert str(excinfo.value) == 'UDP not supported by pf method_name'
assert mock_pf_get_dev.mock_calls == [] 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)], (AF_INET, 32, True, u'1.2.3.66', 80, 80)],
False, False,
None, None,
None,
'0x01') '0x01')
assert mock_ioctl.mock_calls == [ assert mock_ioctl.mock_calls == [
call(mock_pf_get_dev(), 0xcd50441a, ANY), call(mock_pf_get_dev(), 0xcd60441a, ANY),
call(mock_pf_get_dev(), 0xcd50441a, ANY), call(mock_pf_get_dev(), 0xcd60441a, ANY),
] ]
assert mock_pfctl.mock_calls == [ assert mock_pfctl.mock_calls == [
call('-s Interfaces -i lo -v'), 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_ioctl.reset_mock()
mock_pfctl.reset_mock() mock_pfctl.reset_mock()
method.restore_firewall(1025, AF_INET, False, None, None) method.restore_firewall(1025, AF_INET, False, None)
method.restore_firewall(1024, AF_INET6, False, None, None) method.restore_firewall(1024, AF_INET6, False, None)
assert mock_ioctl.mock_calls == [] assert mock_ioctl.mock_calls == []
assert mock_pfctl.mock_calls == [ assert mock_pfctl.mock_calls == [
call('-a sshuttle-1025 -F all'), call('-a sshuttle-1025 -F all'),

View File

@ -78,7 +78,7 @@ def test_assert_features():
def test_firewall_command(): def test_firewall_command():
method = get_method('tproxy') method = get_method('tproxy')
assert not method.firewall_command("something") assert not method.firewall_command("somthing")
@patch('sshuttle.methods.tproxy.ipt') @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)], (AF_INET6, 128, True, u'2404:6800:4004:80c::101f', 8080, 8080)],
True, True,
None, None,
None,
'0x01') '0x01')
assert mock_ipt_chain_exists.mock_calls == [ assert mock_ipt_chain_exists.mock_calls == [
call(AF_INET6, 'mangle', 'sshuttle-m-1024'), 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', 'OUTPUT', '1', '-j', 'sshuttle-m-1024'),
call(AF_INET6, 'mangle', '-I', 'PREROUTING', '1', '-j', call(AF_INET6, 'mangle', '-I', 'PREROUTING', '1', '-j',
'sshuttle-t-1024'), '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', call(AF_INET6, 'mangle', '-A', 'sshuttle-t-1024', '-j', 'RETURN',
'-m', 'addrtype', '--dst-type', 'LOCAL'), '-m', 'addrtype', '--dst-type', 'LOCAL'),
call(AF_INET6, 'mangle', '-A', 'sshuttle-m-1024', '-j', 'RETURN', 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'), '-j', 'sshuttle-d-1024', '-m', 'tcp', '-p', 'tcp'),
call(AF_INET6, 'mangle', '-A', 'sshuttle-t-1024', '-m', 'socket', call(AF_INET6, 'mangle', '-A', 'sshuttle-t-1024', '-m', 'socket',
'-j', 'sshuttle-d-1024', '-m', 'udp', '-p', 'udp'), '-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', call(AF_INET6, 'mangle', '-A', 'sshuttle-m-1024', '-j', 'RETURN',
'--dest', u'2404:6800:4004:80c::101f/128', '--dest', u'2404:6800:4004:80c::101f/128',
'-m', 'tcp', '-p', 'tcp', '--dport', '8080:8080'), '-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_chain_exists.reset_mock()
mock_ipt.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 == [ assert mock_ipt_chain_exists.mock_calls == [
call(AF_INET6, 'mangle', 'sshuttle-m-1025'), call(AF_INET6, 'mangle', 'sshuttle-m-1025'),
call(AF_INET6, 'mangle', 'sshuttle-t-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)], (AF_INET, 32, True, u'1.2.3.66', 80, 80)],
True, True,
None, None,
None,
'0x01') '0x01')
assert mock_ipt_chain_exists.mock_calls == [ assert mock_ipt_chain_exists.mock_calls == [
call(AF_INET, 'mangle', 'sshuttle-m-1025'), 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', 'OUTPUT', '1', '-j', 'sshuttle-m-1025'),
call(AF_INET, 'mangle', '-I', 'PREROUTING', '1', '-j', call(AF_INET, 'mangle', '-I', 'PREROUTING', '1', '-j',
'sshuttle-t-1025'), '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', call(AF_INET, 'mangle', '-A', 'sshuttle-t-1025', '-j', 'RETURN',
'-m', 'addrtype', '--dst-type', 'LOCAL'), '-m', 'addrtype', '--dst-type', 'LOCAL'),
call(AF_INET, 'mangle', '-A', 'sshuttle-m-1025', '-j', 'RETURN', 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'), '-j', 'sshuttle-d-1025', '-m', 'tcp', '-p', 'tcp'),
call(AF_INET, 'mangle', '-A', 'sshuttle-t-1025', '-m', 'socket', call(AF_INET, 'mangle', '-A', 'sshuttle-t-1025', '-m', 'socket',
'-j', 'sshuttle-d-1025', '-m', 'udp', '-p', 'udp'), '-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', call(AF_INET, 'mangle', '-A', 'sshuttle-m-1025', '-j', 'RETURN',
'--dest', u'1.2.3.66/32', '-m', 'tcp', '-p', 'tcp', '--dest', u'1.2.3.66/32', '-m', 'tcp', '-p', 'tcp',
'--dport', '80:80'), '--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_chain_exists.reset_mock()
mock_ipt.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 == [ assert mock_ipt_chain_exists.mock_calls == [
call(AF_INET, 'mangle', 'sshuttle-m-1025'), call(AF_INET, 'mangle', 'sshuttle-m-1025'),
call(AF_INET, 'mangle', 'sshuttle-t-1025'), call(AF_INET, 'mangle', 'sshuttle-t-1025'),

View File

@ -1,6 +1,5 @@
import socket import socket
from argparse import ArgumentTypeError as Fatal from argparse import ArgumentTypeError as Fatal
from unittest.mock import patch
import pytest import pytest
@ -28,23 +27,6 @@ _ip6_reprs = {
_ip6_swidths = (48, 64, 96, 115, 128) _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(): def test_parse_subnetport_ip4():
for ip_repr, ip in _ip4_reprs.items(): for ip_repr, ip in _ip4_reprs.items():
assert sshuttle.options.parse_subnetport(ip_repr) \ 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(): def test_convert_arg_line_to_args_skips_comments():
parser = sshuttle.options.MyArgumentParser() parser = sshuttle.options.MyArgumentParser()
assert parser.convert_arg_line_to_args("# whatever something") == [] 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] [tox]
downloadcache = {toxworkdir}/cache/ downloadcache = {toxworkdir}/cache/
envlist = envlist =
py36,
py37,
py38, py38,
py39, py39,
py310,
[testenv] [testenv]
basepython = basepython =
py36: python3.6
py37: python3.7
py38: python3.8
py39: python3.9 py39: python3.9
py310: python3.10
py311: python3.11
py312: python3.12
commands = commands =
pip install -e . pip install -e .
# actual flake8 test # actual flake8 test

1425
uv.lock generated

File diff suppressed because it is too large Load Diff