Compare commits

...

127 Commits

Author SHA1 Message Date
99c4abce81 chore(master): release 1.3.0 2025-02-24 07:54:01 +11:00
a2d405a6a7 docs: update installation instructions
* Update pip installation instructions to work
  without setup.py.

* Remove duplication of installation instructions
  in two places.
2025-02-23 20:38:07 +11:00
7fa927ef8c fix: support ':' sign in password 2025-02-22 08:23:36 +11:00
a1dd6859b0 build(deps-dev): bump flake8 from 7.1.1 to 7.1.2
Bumps [flake8](https://github.com/pycqa/flake8) from 7.1.1 to 7.1.2.
- [Commits](https://github.com/pycqa/flake8/compare/7.1.1...7.1.2)

---
updated-dependencies:
- dependency-name: flake8
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-18 08:11:24 +11:00
8a123d9762 feat: switch to a network namespace on Linux
* Add support to run inside Linux namespace

**Motivation:**
In a specific use case, we use sshuttle to provide access to private
networks from multiple sites to a specific host. The sites may contain
networks that overlap each other, so each site is accessed inside a
different namespace that provides process-level network isolation and
prevents network overlap.

**Objective:**
This commit just adds a convenient way of spawning multiple sshuttle
instances inside different namespaces from a single process, by passing
the namespace's name though the variable --namespace. The result is the
same as calling `ip netns exec $NAMESPACE sshuttle ...`

* Add the argument --namespace-pid

The argument '--namespace-pid' allows sshuttle to attach to the same net
namespace used by a running process.

* PEP-8 compliance

* Add comment

* Make --namespace and --namespace-pid mutually exclusive.

* Prevent UnicodeDecodeError parsing iptables rule with comments

If one or more iptables rule contains a comment with a non-unicode character, an UnicodeDecodeError would be raised.
2025-02-09 08:48:55 +11:00
cbe3d1e402 fix: prevent UnicodeDecodeError parsing iptables rule with comments
If one or more iptables rule contains a comment with a non-unicode character, an UnicodeDecodeError would be raised.
2025-02-09 08:45:25 +11:00
340ccc705e docs: replace nix-env with nix-shell 2025-02-08 10:02:31 +11:00
1f5e6cea70 fix: remove temp build hack 2025-02-08 09:59:46 +11:00
fd6b6bb71f build: redo publish to pypi 2025-02-08 09:58:09 +11:00
5b08caaeb1 build: hack force publish pypi 2025-02-08 09:25:06 +11:00
40f6c1d4f2 build: don't skip pypi release 2025-02-08 09:23:33 +11:00
c09e2985f2 chore(master): release 1.2.0 2025-02-08 09:21:02 +11:00
7725f93d94 build: release to prod pypi 2025-02-08 09:18:45 +11:00
75faa9b9e8 build: remove setup.py 2025-02-08 09:16:15 +11:00
d910b64be7 feat: Add release-please to build workflow 2025-02-08 08:34:58 +11:00
3f0f88eb09 build(deps): bump abatilo/actions-poetry from 3 to 4
Bumps [abatilo/actions-poetry](https://github.com/abatilo/actions-poetry) from 3 to 4.
- [Release notes](https://github.com/abatilo/actions-poetry/releases)
- [Changelog](https://github.com/abatilo/actions-poetry/blob/master/.releaserc)
- [Commits](https://github.com/abatilo/actions-poetry/compare/v3...v4)

---
updated-dependencies:
- dependency-name: abatilo/actions-poetry
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-07 07:24:08 +11:00
f7f9a4dbc6 build(deps): bump actions/cache from 3 to 4
Bumps [actions/cache](https://github.com/actions/cache) from 3 to 4.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-07 07:23:55 +11:00
bf294643e2 fix: use Python >= 3.10 for docs 2025-02-06 19:11:50 +11:00
693ee40c48 fix: ensure poetry works for Python 3.9 2025-02-06 18:57:16 +11:00
a0d94367f6 Back out "Bump sphinx from 7.1.2 to 8.1.3"
This backs out commit ac4313deca.
2025-02-06 18:55:48 +11:00
ac4313deca Bump sphinx from 7.1.2 to 8.1.3
Bumps [sphinx](https://github.com/sphinx-doc/sphinx) from 7.1.2 to 8.1.3.
- [Release notes](https://github.com/sphinx-doc/sphinx/releases)
- [Changelog](https://github.com/sphinx-doc/sphinx/blob/master/CHANGES.rst)
- [Commits](https://github.com/sphinx-doc/sphinx/compare/v7.1.2...v8.1.3)

---
updated-dependencies:
- dependency-name: sphinx
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-06 17:42:29 +11:00
9bcedf1904 fix: replace requirements.txt files with poetry (4) 2025-02-06 16:48:22 +11:00
62da70510e fix: replace requirements.txt files with poetry (3) 2025-02-06 16:05:55 +11:00
d08f78a2d9 fix: replace requirements.txt files with poetry (2) 2025-02-06 16:03:58 +11:00
85dc3199a3 fix: replace requirements.txt files with poetry 2025-02-06 15:57:36 +11:00
6f12698209 Bump pytest-cov from 5.0.0 to 6.0.0
Bumps [pytest-cov](https://github.com/pytest-dev/pytest-cov) from 5.0.0 to 6.0.0.
- [Changelog](https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest-cov/compare/v5.0.0...v6.0.0)

---
updated-dependencies:
- dependency-name: pytest-cov
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-06 09:17:17 +11:00
4b6f7c6a65 fix: fix broken workflow_dispatch CI rule 2025-02-06 09:11:21 +11:00
a3396a443d fix: Add support for Python 3.11 and Python 3.11 2025-02-06 09:09:50 +11:00
339b5221bc fix: Remove more references to legacy Python versions 2025-02-06 09:08:45 +11:00
1084c0f245 fix: drop Python 3.8 support
Python 3.8 support has been dropped upstream.
2025-02-06 09:02:11 +11:00
cda60a5233 fix: update nix flake to fix problems 2025-02-06 08:52:31 +11:00
b346e976eb Bump twine from 6.0.1 to 6.1.0
Bumps [twine](https://github.com/pypa/twine) from 6.0.1 to 6.1.0.
- [Release notes](https://github.com/pypa/twine/releases)
- [Changelog](https://github.com/pypa/twine/blob/main/docs/changelog.rst)
- [Commits](https://github.com/pypa/twine/compare/6.0.1...6.1.0)

---
updated-dependencies:
- dependency-name: twine
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-23 07:54:18 +11:00
7c2b3cd30e Bump pytest from 8.3.3 to 8.3.4
Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.3.3 to 8.3.4.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.3.3...8.3.4)

---
updated-dependencies:
- dependency-name: pytest
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-04 07:16:31 +11:00
012fbcb587 Bump twine from 5.1.1 to 6.0.1
Bumps [twine](https://github.com/pypa/twine) from 5.1.1 to 6.0.1.
- [Release notes](https://github.com/pypa/twine/releases)
- [Changelog](https://github.com/pypa/twine/blob/main/docs/changelog.rst)
- [Commits](https://github.com/pypa/twine/compare/v5.1.1...6.0.1)

---
updated-dependencies:
- dependency-name: twine
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-03 15:17:45 +11:00
4a1fe0fefe Bump pytest from 8.3.3 to 8.3.4
Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.3.3 to 8.3.4.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.3.3...8.3.4)

---
updated-dependencies:
- dependency-name: pytest
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-03 15:17:33 +11:00
6abda35fce Bump pytest-cov from 5.0.0 to 6.0.0
Bumps [pytest-cov](https://github.com/pytest-dev/pytest-cov) from 5.0.0 to 6.0.0.
- [Changelog](https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest-cov/compare/v5.0.0...v6.0.0)

---
updated-dependencies:
- dependency-name: pytest-cov
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-31 07:48:56 +11:00
2f3171670c Bump pytest from 8.3.2 to 8.3.3
Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.3.2 to 8.3.3.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.3.2...8.3.3)

---
updated-dependencies:
- dependency-name: pytest
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-13 08:39:35 +10:00
304aaa5e46 Bump cryptography from 42.0.3 to 43.0.1
Bumps [cryptography](https://github.com/pyca/cryptography) from 42.0.3 to 43.0.1.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/42.0.3...43.0.1)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-12 08:17:09 +10:00
f05d6531f2 Bump pytest from 8.3.2 to 8.3.3
Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.3.2 to 8.3.3.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.3.2...8.3.3)

---
updated-dependencies:
- dependency-name: pytest
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-12 08:16:55 +10:00
e6074ed52d Revert "Suppress error P is not recognized as an internal or external command,operable program or batch file."
This reverts commit 6272a0212c.
2024-08-20 16:32:50 +10:00
ac36a8a20e Revert "add next error log"
This reverts commit dc2287ccf8.
2024-08-20 16:32:50 +10:00
09c3324978 Revert "restore single quote then it looks working"
This reverts commit d1dbed04a0.
2024-08-20 16:32:50 +10:00
81532b29a9 Revert "remove unnecessary log file"
This reverts commit eaf55ed296.
2024-08-20 16:32:50 +10:00
o2
eaf55ed296 remove unnecessary log file 2024-08-16 08:46:04 +10:00
o2
d1dbed04a0 restore single quote then it looks working 2024-08-16 08:46:04 +10:00
o2
dc2287ccf8 add next error log 2024-08-16 08:46:04 +10:00
o2
6272a0212c Suppress error P is not recognized as an internal or external command,operable program or batch file. 2024-08-16 08:46:04 +10:00
8364fd96e8 remove unused imports 2024-08-08 10:35:47 +10:00
8da94c39ea transfer work from PR #837 2024-08-08 10:35:47 +10:00
60ee5b910b Bump flake8 from 7.1.0 to 7.1.1
Bumps [flake8](https://github.com/pycqa/flake8) from 7.1.0 to 7.1.1.
- [Commits](https://github.com/pycqa/flake8/compare/7.1.0...7.1.1)

---
updated-dependencies:
- dependency-name: flake8
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-07 09:10:42 +10:00
b9e7a80715 Bump furo from 2024.7.18 to 2024.8.6
Bumps [furo](https://github.com/pradyunsg/furo) from 2024.7.18 to 2024.8.6.
- [Release notes](https://github.com/pradyunsg/furo/releases)
- [Changelog](https://github.com/pradyunsg/furo/blob/main/docs/changelog.md)
- [Commits](https://github.com/pradyunsg/furo/compare/2024.07.18...2024.08.06)

---
updated-dependencies:
- dependency-name: furo
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-07 09:10:25 +10:00
bac2a6b0c7 windows: add --remote-shell option to select cmd/powershell 2024-08-06 08:38:24 +10:00
dff6950c4c windows: update docs 2024-08-06 08:38:24 +10:00
df9625bbfd windows: ignore netstat output encoding errors 2024-08-06 08:38:24 +10:00
554b8e3ae5 windows: improve ssnet/try_connect() logic 2024-08-06 08:38:24 +10:00
b826ae6b91 windows: support automatic nameserver detection for --dns option 2024-08-06 08:38:24 +10:00
51287dc4db support server on Windows 2024-08-06 08:38:24 +10:00
ace8642950 add SocketRWShim helper 2024-08-06 08:38:24 +10:00
c4255a23f0 update exec-sshuttle script 2024-08-06 08:38:24 +10:00
6b8e402367 make sure that existing python2 compatibility is not broken by this feature 2024-08-06 08:38:24 +10:00
7a92183f59 windows: better connection tracker 2024-08-06 08:38:24 +10:00
81a598a4cc suppport --auto-hosts in Windows 2024-08-06 08:38:24 +10:00
89a94ff150 support port ranges and exclude subnets 2024-08-06 08:38:24 +10:00
72060abbef code cleanup and small refactoring 2024-08-06 08:38:24 +10:00
de8a19ce69 rename hacks to scripts 2024-08-06 08:38:24 +10:00
32fceefa76 !fix: windows installed script execution 2024-08-06 08:38:24 +10:00
b0799f8752 Fix benchmarking script to use correct node parameter 2024-08-06 08:38:24 +10:00
cd2d69ac08 Bump version to 1.2.0 2024-08-06 08:38:24 +10:00
1885974f52 refactor for future ipv6 support 2024-08-06 08:38:24 +10:00
dadfba488b better windivert filters 2024-08-06 08:38:24 +10:00
8fa15c3ca8 support windivert > 2.0 2024-08-06 08:38:24 +10:00
e19fc01324 !improved windrivert throughput 2024-08-06 08:38:24 +10:00
371258991f Update exec-sshuttle script and related files 2024-08-06 08:38:24 +10:00
db9ec36fac better test-bed scripts 2024-08-06 08:38:24 +10:00
d4d0fa945d fix: bad file descriptor error in windows, fix pytest errors 2024-08-06 08:38:24 +10:00
4a84ad3be6 fix windows CRLF issue on stdin/stdout 2024-08-06 08:38:24 +10:00
900acc3ac7 refactoring to make it better structured 2024-08-06 08:38:24 +10:00
49f46cd528 Add containers based testbed setup 2024-08-06 08:38:24 +10:00
7b8f140870 ensure non loopback address for windivert method 2024-08-06 08:38:24 +10:00
9c5517fd25 use custom RWPair instead of io.BufferedRWPair 2024-08-06 08:38:24 +10:00
3f34e27a2c try not use socket share 2024-08-06 08:38:24 +10:00
2f88fc93cf add some comments 2024-08-06 08:38:24 +10:00
0c4c061123 fix failing tests 2024-08-06 08:38:24 +10:00
482e0cbd00 pass flake8 linting 2024-08-06 08:38:24 +10:00
7da3b024dd fix is_admin_user() helper 2024-08-06 08:38:24 +10:00
b09cc4595b add pydivert as windows specific dependency 2024-08-06 08:38:24 +10:00
c01794f232 windivert: garbage collect timed put connections from tracker 2024-08-06 08:38:24 +10:00
338486930f windivert: add ipv6 support and better thread handling 2024-08-06 08:38:24 +10:00
bd2f960743 more improvements windows support 2024-08-06 08:38:24 +10:00
2c74476124 windivert - basic working connection tracker 2024-08-06 08:38:24 +10:00
5a64c81b5b experimental windows method 2024-08-06 08:38:24 +10:00
2408563f3b Bump flake8 from 7.1.0 to 7.1.1
Bumps [flake8](https://github.com/pycqa/flake8) from 7.1.0 to 7.1.1.
- [Commits](https://github.com/pycqa/flake8/compare/7.1.0...7.1.1)

---
updated-dependencies:
- dependency-name: flake8
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-06 08:36:18 +10:00
834ac02a5d Bump pytest from 8.3.1 to 8.3.2
Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.3.1 to 8.3.2.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.3.1...8.3.2)

---
updated-dependencies:
- dependency-name: pytest
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-27 08:35:10 +10:00
bd3164db22 Bump pytest from 8.3.1 to 8.3.2
Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.3.1 to 8.3.2.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.3.1...8.3.2)

---
updated-dependencies:
- dependency-name: pytest
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-26 08:44:19 +10:00
037ee9025e Bump pytest from 8.2.2 to 8.3.1
Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.2.2 to 8.3.1.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.2.2...8.3.1)

---
updated-dependencies:
- dependency-name: pytest
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-25 07:44:51 +10:00
bf2db72393 Bump pytest from 8.2.2 to 8.3.1
Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.2.2 to 8.3.1.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.2.2...8.3.1)

---
updated-dependencies:
- dependency-name: pytest
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-24 19:52:22 +10:00
f10535edf4 Bump furo from 2024.5.6 to 2024.7.18
Bumps [furo](https://github.com/pradyunsg/furo) from 2024.5.6 to 2024.7.18.
- [Release notes](https://github.com/pradyunsg/furo/releases)
- [Changelog](https://github.com/pradyunsg/furo/blob/main/docs/changelog.md)
- [Commits](https://github.com/pradyunsg/furo/compare/2024.05.06...2024.07.18)

---
updated-dependencies:
- dependency-name: furo
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-24 19:45:38 +10:00
fd63611b5a Fix pf_rule size in OpenBSD. 2024-07-12 09:52:57 +10:00
9c3107bed7 Unbreak OpenBSD runtime.
sizeof(struct pfioc_rule) changed in recent OpenBSD releases.
This fixes the ioctl call to DIOCCHANGERULE.
2024-07-12 09:52:57 +10:00
fdcc840b7b Bump zipp from 3.17.0 to 3.19.1
Bumps [zipp](https://github.com/jaraco/zipp) from 3.17.0 to 3.19.1.
- [Release notes](https://github.com/jaraco/zipp/releases)
- [Changelog](https://github.com/jaraco/zipp/blob/main/NEWS.rst)
- [Commits](https://github.com/jaraco/zipp/compare/v3.17.0...v3.19.1)

---
updated-dependencies:
- dependency-name: zipp
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-10 15:25:02 +10:00
348f0eb653 Add support for non-compliant ssh wrappers
ssh wrappers like teleport's tsh do not correctly interpret the
double dash as an argument delimiter and will not work properly
with sshuttle. This PR adds a new command line switch to handle
these cases by not adding the delimiter.

Fixes #599
2024-07-07 13:28:26 +10:00
6cdae8c3e5 Bump certifi from 2024.2.2 to 2024.7.4
Bumps [certifi](https://github.com/certifi/python-certifi) from 2024.2.2 to 2024.7.4.
- [Commits](https://github.com/certifi/python-certifi/compare/2024.02.02...2024.07.04)

---
updated-dependencies:
- dependency-name: certifi
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-07 13:27:24 +10:00
bdf2797b74 Bump twine from 5.1.0 to 5.1.1
Bumps [twine](https://github.com/pypa/twine) from 5.1.0 to 5.1.1.
- [Release notes](https://github.com/pypa/twine/releases)
- [Changelog](https://github.com/pypa/twine/blob/main/docs/changelog.rst)
- [Commits](https://github.com/pypa/twine/compare/5.1.0...v5.1.1)

---
updated-dependencies:
- dependency-name: twine
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-28 09:25:28 +10:00
cc38cc2def Bump flake8 from 7.0.0 to 7.1.0
Bumps [flake8](https://github.com/pycqa/flake8) from 7.0.0 to 7.1.0.
- [Commits](https://github.com/pycqa/flake8/compare/7.0.0...7.1.0)

---
updated-dependencies:
- dependency-name: flake8
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-19 09:50:04 +10:00
4ccf528664 Bump urllib3 from 2.2.1 to 2.2.2
Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.2.1 to 2.2.2.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/2.2.1...2.2.2)

---
updated-dependencies:
- dependency-name: urllib3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-18 09:34:15 +10:00
83c136d6e6 Bump flake8 from 7.0.0 to 7.1.0
Bumps [flake8](https://github.com/pycqa/flake8) from 7.0.0 to 7.1.0.
- [Commits](https://github.com/pycqa/flake8/compare/7.0.0...7.1.0)

---
updated-dependencies:
- dependency-name: flake8
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-18 09:33:53 +10:00
efbc4d066f Bump pytest from 8.2.1 to 8.2.2
Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.2.1 to 8.2.2.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.2.1...8.2.2)

---
updated-dependencies:
- dependency-name: pytest
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-07 08:49:18 +10:00
a0f466a07c Bump pytest from 8.2.1 to 8.2.2
Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.2.1 to 8.2.2.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.2.1...8.2.2)

---
updated-dependencies:
- dependency-name: pytest
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-06 10:42:41 +10:00
d660d8159b ---
updated-dependencies:
- dependency-name: requests
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-22 20:43:42 +10:00
8d5e23477e ---
updated-dependencies:
- dependency-name: pytest
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-22 20:43:28 +10:00
a91e0c0470 Bump pytest from 8.2.0 to 8.2.1
Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.2.0 to 8.2.1.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.2.0...8.2.1)

---
updated-dependencies:
- dependency-name: pytest
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-21 16:00:12 +10:00
c0938bc9a5 Bump twine from 5.0.0 to 5.1.0
Bumps [twine](https://github.com/pypa/twine) from 5.0.0 to 5.1.0.
- [Release notes](https://github.com/pypa/twine/releases)
- [Changelog](https://github.com/pypa/twine/blob/main/docs/changelog.rst)
- [Commits](https://github.com/pypa/twine/compare/5.0.0...5.1.0)

---
updated-dependencies:
- dependency-name: twine
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-18 09:35:51 +10:00
975d208d60 Bump furo from 2024.4.27 to 2024.5.6
Bumps [furo](https://github.com/pradyunsg/furo) from 2024.4.27 to 2024.5.6.
- [Release notes](https://github.com/pradyunsg/furo/releases)
- [Changelog](https://github.com/pradyunsg/furo/blob/main/docs/changelog.md)
- [Commits](https://github.com/pradyunsg/furo/compare/2024.04.27...2024.05.06)

---
updated-dependencies:
- dependency-name: furo
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-08 09:03:54 +10:00
39a7b1b47f Bump pytest from 8.1.1 to 8.2.0
Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.1.1 to 8.2.0.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.1.1...8.2.0)

---
updated-dependencies:
- dependency-name: pytest
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-01 11:55:12 +10:00
4ba7612d90 Bump furo from 2024.1.29 to 2024.4.27
Bumps [furo](https://github.com/pradyunsg/furo) from 2024.1.29 to 2024.4.27.
- [Release notes](https://github.com/pradyunsg/furo/releases)
- [Changelog](https://github.com/pradyunsg/furo/blob/main/docs/changelog.md)
- [Commits](https://github.com/pradyunsg/furo/compare/2024.01.29...2024.04.27)

---
updated-dependencies:
- dependency-name: furo
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-30 08:47:03 +10:00
ea0559eaea Bump pytest from 8.1.1 to 8.2.0
Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.1.1 to 8.2.0.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.1.1...8.2.0)

---
updated-dependencies:
- dependency-name: pytest
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-30 08:46:52 +10:00
6bd3bd738a Bump idna from 3.6 to 3.7
Bumps [idna](https://github.com/kjd/idna) from 3.6 to 3.7.
- [Release notes](https://github.com/kjd/idna/releases)
- [Changelog](https://github.com/kjd/idna/blob/master/HISTORY.rst)
- [Commits](https://github.com/kjd/idna/compare/v3.6...v3.7)

---
updated-dependencies:
- dependency-name: idna
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-12 14:53:36 +10:00
116b1e22b1 Revert "Bump cryptography from 42.0.3 to 42.0.4"
This reverts commit 87bd34e094.

Fixes #939

This was to be fixed in
https://github.com/nix-community/poetry2nix/pull/1538, but merging that
is taking longer then I might have hoped.
2024-04-08 09:03:21 +10:00
694a9c8a5b Bump pytest-cov from 4.1.0 to 5.0.0
Bumps [pytest-cov](https://github.com/pytest-dev/pytest-cov) from 4.1.0 to 5.0.0.
- [Changelog](https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest-cov/compare/v4.1.0...v5.0.0)

---
updated-dependencies:
- dependency-name: pytest-cov
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-27 09:05:30 +11:00
264e4d94b8 Bump pytest-cov from 4.1.0 to 5.0.0
Bumps [pytest-cov](https://github.com/pytest-dev/pytest-cov) from 4.1.0 to 5.0.0.
- [Changelog](https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest-cov/compare/v4.1.0...v5.0.0)

---
updated-dependencies:
- dependency-name: pytest-cov
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-26 11:52:00 +11:00
afbdf8b606 Bump pytest from 8.0.2 to 8.1.1
Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.0.2 to 8.1.1.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.0.2...8.1.1)

---
updated-dependencies:
- dependency-name: pytest
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-13 09:26:29 +11:00
9a4df1fdcf Bump pytest from 8.0.2 to 8.1.1
Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.0.2 to 8.1.1.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.0.2...8.1.1)

---
updated-dependencies:
- dependency-name: pytest
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-12 09:49:32 +11:00
3d875b8ca8 Bump pytest from 8.0.2 to 8.1.0
Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.0.2 to 8.1.0.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.0.2...8.1.0)

---
updated-dependencies:
- dependency-name: pytest
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-05 09:18:21 +11:00
313ada3ff7 Bump pytest from 8.0.1 to 8.0.2
Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.0.1 to 8.0.2.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.0.1...8.0.2)

---
updated-dependencies:
- dependency-name: pytest
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-28 09:11:53 +11:00
934618b603 Bump pytest from 8.0.1 to 8.0.2
Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.0.1 to 8.0.2.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.0.1...8.0.2)

---
updated-dependencies:
- dependency-name: pytest
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-27 08:52:55 +11:00
87bd34e094 Bump cryptography from 42.0.3 to 42.0.4
Bumps [cryptography](https://github.com/pyca/cryptography) from 42.0.3 to 42.0.4.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/42.0.3...42.0.4)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-22 15:22:26 +11:00
83debdfb21 Bump pytest from 8.0.0 to 8.0.1
Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.0.0 to 8.0.1.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.0.0...8.0.1)

---
updated-dependencies:
- dependency-name: pytest
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-20 08:56:07 +11:00
48 changed files with 2869 additions and 823 deletions

View File

@ -8,8 +8,7 @@ on:
branches: [ master ] branches: [ master ]
pull_request: pull_request:
branches: [ master ] branches: [ master ]
workflow_dispatch: workflow_dispatch: {}
branches: [ master ]
jobs: jobs:
build: build:
@ -17,21 +16,31 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: ["3.8", "3.9", "3.10"] python-version: ["3.9", "3.10", "3.11", "3.12"]
poetry-version: ["main"]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- name: Install dependencies - name: Run image
uses: abatilo/actions-poetry@v4
with:
poetry-version: ${{ matrix.poetry-version }}
- name: Setup a local virtual environment (if no poetry.toml file)
run: | run: |
python -m pip install --upgrade pip poetry config virtualenvs.create true --local
pip install -r requirements-tests.txt poetry config virtualenvs.in-project true --local
- uses: actions/cache@v4
name: Define a cache for the virtual environment based on the dependencies lock file
with:
path: ./.venv
key: venv-${{ hashFiles('poetry.lock') }}
- name: Install the project dependencies
run: poetry install
- name: Lint with flake8 - name: Lint with flake8
run: | run: |
flake8 sshuttle tests --count --show-source --statistics poetry run flake8 sshuttle tests --count --show-source --statistics
- name: Test with pytest - name: Run the automated tests
run: | run: poetry run pytest -v
PYTHONPATH=$PWD pytest

55
.github/workflows/release-please.yml vendored Normal file
View File

@ -0,0 +1,55 @@
on:
push:
branches:
- master
name: release-please
jobs:
release-please:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: googleapis/release-please-action@v4
with:
token: ${{ secrets.MY_RELEASE_PLEASE_TOKEN }}
release-type: python
upload-pypi:
name: Upload to pypi
needs: [release-please]
if: ${{ needs.release-please.outputs.release_created == 'true' }}
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/sshuttle
permissions:
id-token: write
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: 3.12
- name: Run image
uses: abatilo/actions-poetry@v4
with:
poetry-version: main
- name: Setup a local virtual environment (if no poetry.toml file)
run: |
poetry config virtualenvs.create true --local
poetry config virtualenvs.in-project true --local
- uses: actions/cache@v4
name: Define a cache for the virtual environment based on the dependencies lock file
with:
path: ./.venv
key: venv-${{ hashFiles('poetry.lock') }}
- name: Install the project dependencies
run: poetry install
- name: Package project
run: poetry build
- name: Publish package distributions to PyPI
uses: pypa/gh-action-pypi-publish@release/v1

View File

@ -3,13 +3,12 @@ version: 2
build: build:
os: ubuntu-20.04 os: ubuntu-20.04
tools: tools:
python: "3.9" python: "3.10"
jobs:
post_create_environment:
- pip install poetry
post_install:
- VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install --with docs
sphinx: sphinx:
configuration: docs/conf.py configuration: docs/conf.py
python:
install:
- requirements: requirements.txt
- method: setuptools
path: .

44
CHANGELOG.md Normal file
View File

@ -0,0 +1,44 @@
# Changelog
## [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 &gt;= 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, or MacOS. - Your client machine (or router) is Linux, FreeBSD, MacOS or Windows.
- You have access to a remote network via ssh. - You have access to a remote network via ssh.
@ -30,84 +30,9 @@ common case:
Obtaining sshuttle Obtaining sshuttle
------------------ ------------------
- Ubuntu 16.04 or later:: Please see the documentation_.
apt-get install sshuttle
- Debian stretch or later::
apt-get install sshuttle
- Arch Linux::
pacman -S sshuttle
- Fedora::
dnf install sshuttle
- openSUSE::
zypper in sshuttle
- Gentoo::
emerge -av net-proxy/sshuttle
- NixOS::
nix-env -iA nixos.sshuttle
- From PyPI::
sudo pip install sshuttle
- Clone::
git clone https://github.com/sshuttle/sshuttle.git
cd sshuttle
sudo ./setup.py install
- FreeBSD::
# ports
cd /usr/ports/net/py-sshuttle && make install clean
# pkg
pkg install py39-sshuttle
- OpenBSD::
pkg_add sshuttle
- macOS, via MacPorts::
sudo port selfupdate
sudo port install sshuttle
It is also possible to install into a virtualenv as a non-root user.
- From PyPI::
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
------------- -------------

View File

@ -1,23 +1,84 @@
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::
pip install sshuttle sudo 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
./setup.py install sudo ./setup.py install
- FreeBSD::
Optionally after installation # ports
----------------------------- cd /usr/ports/net/py-sshuttle && make install clean
# pkg
pkg install py39-sshuttle
- Install sudoers configuration. For details, see the "Sudoers File" section in :doc:`usage` - OpenBSD::
pkg_add sshuttle
- macOS, via MacPorts::
sudo port selfupdate
sudo port install sshuttle
It is also possible to install into a virtualenv as a non-root user.
- From PyPI::
python3 -m venv /tmp/sshuttle
. /tmp/sshuttle/bin/activate
pip install sshuttle
- Clone::
git clone https://github.com/sshuttle/sshuttle.git
cd sshuttle
python3 -m venv /tmp/sshuttle
. /tmp/sshuttle/bin/activate
python -m pip install .
- Homebrew::
brew install sshuttle
- Nix::
nix-shell -p sshuttle
- Windows::
pip install sshuttle

View File

@ -181,6 +181,18 @@ 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

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.8 or greater. - Python 3.9 or greater.
Linux with NAT method Linux with NAT method
@ -65,14 +65,13 @@ Requires:
Windows Windows
~~~~~~~ ~~~~~~~
Not officially supported, however can be made to work with Vagrant. Requires Experimental built-in support available. See :doc:`windows` for more information.
cmd.exe with Administrator access. See :doc:`windows` for more information.
Server side Requirements Server side Requirements
------------------------ ------------------------
- Python 3.8 or greater. - Python 3.9 or greater.
Additional Suggested Software Additional Suggested Software

View File

@ -1,7 +1,16 @@
Microsoft Windows Microsoft Windows
================= =================
Currently there is no built in support for running sshuttle directly on
Microsoft Windows. Experimental native support::
Experimental built-in support for Windows is available through `windivert` method.
You have to install https://pypi.org/project/pydivert package. You need Administrator privileges to use windivert method
Notes
- sshuttle should be executed from admin shell (Automatic firewall process admin elevation is not available)
- TCP/IPv4 supported (IPv6/UDP/DNS are not available)
Use Linux VM on Windows::
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

43
flake.lock generated
View File

@ -5,11 +5,11 @@
"systems": "systems" "systems": "systems"
}, },
"locked": { "locked": {
"lastModified": 1705309234, "lastModified": 1731533236,
"narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=", "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide", "owner": "numtide",
"repo": "flake-utils", "repo": "flake-utils",
"rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26", "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -23,11 +23,11 @@
"systems": "systems_2" "systems": "systems_2"
}, },
"locked": { "locked": {
"lastModified": 1694529238, "lastModified": 1726560853,
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", "narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=",
"owner": "numtide", "owner": "numtide",
"repo": "flake-utils", "repo": "flake-utils",
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384", "rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -44,11 +44,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1698974481, "lastModified": 1729742964,
"narHash": "sha256-yPncV9Ohdz1zPZxYHQf47S8S0VrnhV7nNhCawY46hDA=", "narHash": "sha256-B4mzTcQ0FZHdpeWcpDYPERtyjJd/NIuaQ9+BV1h+MpA=",
"owner": "nix-community", "owner": "nix-community",
"repo": "nix-github-actions", "repo": "nix-github-actions",
"rev": "4bb5e752616262457bc7ca5882192a564c0472d2", "rev": "e04df33f62cdcf93d73e9a04142464753a16db67",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -59,16 +59,16 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1708161998, "lastModified": 1738702386,
"narHash": "sha256-6KnemmUorCvlcAvGziFosAVkrlWZGIc6UNT9GUYr0jQ=", "narHash": "sha256-nJj8f78AYAxl/zqLiFGXn5Im1qjFKU8yBPKoWEeZN5M=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "84d981bae8b5e783b3b548de505b22880559515f", "rev": "030ba1976b7c0e1a67d9716b17308ccdab5b381e",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "NixOS", "owner": "NixOS",
"ref": "nixos-23.11", "ref": "nixos-24.11",
"repo": "nixpkgs", "repo": "nixpkgs",
"type": "github" "type": "github"
} }
@ -84,11 +84,11 @@
"treefmt-nix": "treefmt-nix" "treefmt-nix": "treefmt-nix"
}, },
"locked": { "locked": {
"lastModified": 1708175019, "lastModified": 1738741221,
"narHash": "sha256-B7wY2pNrLc3X9uYRo1LUmVzI6oH6fX8oi+96GdUpayU=", "narHash": "sha256-UiTOA89yQV5YNlO1ZAp4IqJUGWOnTyBC83netvt8rQE=",
"owner": "nix-community", "owner": "nix-community",
"repo": "poetry2nix", "repo": "poetry2nix",
"rev": "403d923ea8e2e6cedce3a0f04a9394c4244cb806", "rev": "be1fe795035d3d36359ca9135b26dcc5321b31fb",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -144,8 +144,9 @@
"type": "github" "type": "github"
}, },
"original": { "original": {
"id": "systems", "owner": "nix-systems",
"type": "indirect" "repo": "default",
"type": "github"
} }
}, },
"treefmt-nix": { "treefmt-nix": {
@ -156,11 +157,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1699786194, "lastModified": 1730120726,
"narHash": "sha256-3h3EH1FXQkIeAuzaWB+nK0XK54uSD46pp+dMD3gAcB4=", "narHash": "sha256-LqHYIxMrl/1p3/kvm2ir925tZ8DkI0KA10djk8wecSk=",
"owner": "numtide", "owner": "numtide",
"repo": "treefmt-nix", "repo": "treefmt-nix",
"rev": "e82f32aa7f06bbbd56d7b12186d555223dc399d1", "rev": "9ef337e492a5555d8e17a51c911ff1f02635be15",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@ -1,24 +1,32 @@
{ {
description = 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.";
"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"; inputs.flake-utils.url = "github:numtide/flake-utils";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-23.11"; inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
inputs.poetry2nix = { inputs.poetry2nix = {
url = "github:nix-community/poetry2nix"; url = "github:nix-community/poetry2nix";
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
}; };
outputs = { self, nixpkgs, flake-utils, poetry2nix }: outputs =
flake-utils.lib.eachDefaultSystem (system: {
self,
nixpkgs,
flake-utils,
poetry2nix,
}:
flake-utils.lib.eachDefaultSystem (
system:
let let
p2n = import poetry2nix { inherit pkgs; }; p2n = import poetry2nix { inherit pkgs; };
overrides = p2n.defaultPoetryOverrides.extend (self: super: { overrides = p2n.defaultPoetryOverrides.extend (
self: super: {
nh3 = super.nh3.override { preferWheel = true; }; nh3 = super.nh3.override { preferWheel = true; };
bump2version = super.bump2version.overridePythonAttrs (old: { bump2version = super.bump2version.overridePythonAttrs (old: {
buildInputs = (old.buildInputs or [ ]) ++ [ super.setuptools ]; buildInputs = (old.buildInputs or [ ]) ++ [ super.setuptools ];
}); });
}); }
);
poetry_env = p2n.mkPoetryEnv { poetry_env = p2n.mkPoetryEnv {
python = pkgs.python3; python = pkgs.python3;
@ -31,12 +39,18 @@
inherit overrides; inherit overrides;
}; };
pkgs = nixpkgs.legacyPackages.${system}; pkgs = nixpkgs.legacyPackages.${system};
in { in
{
packages = { packages = {
sshuttle = poetry_app; sshuttle = poetry_app;
default = self.packages.${system}.sshuttle; default = self.packages.${system}.sshuttle;
}; };
devShells.default = devShells.default = pkgs.mkShell {
pkgs.mkShell { packages = [ pkgs.poetry poetry_env ]; }; packages = [
}); pkgs.poetry
poetry_env
];
};
}
);
} }

1242
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,21 +1,33 @@
[tool.poetry] [tool.poetry]
name = "sshuttle" name = "sshuttle"
version = "1.1.2" version = "1.3.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." 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."
authors = ["Brian May <brian@linuxpenguins.xyz>"] authors = ["Brian May <brian@linuxpenguins.xyz>"]
license = "LGPL-2.1" license = "LGPL-2.1"
readme = "README.rst" 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",
]
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.10" python = "^3.9"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
pytest = "^8.0.1" pytest = "^8.0.1"
pytest-cov = "^4.1.0" pytest-cov = ">=4.1,<7.0"
flake8 = "^7.0.0" flake8 = "^7.0.0"
pyflakes = "^3.2.0" pyflakes = "^3.2.0"
bump2version = "^1.0.1" bump2version = "^1.0.1"
twine = "^5.0.0" twine = ">=5,<7"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]
@ -23,3 +35,10 @@ build-backend = "poetry.core.masonry.api"
[tool.poetry.scripts] [tool.poetry.scripts]
sshuttle = "sshuttle.cmdline:main" sshuttle = "sshuttle.cmdline:main"
[tool.poetry.group.docs]
optional = true
[tool.poetry.group.docs.dependencies]
sphinx = { version = "8.1.3", python = ">=3.10,<4.0" }
furo = "2024.8.6"

View File

@ -1,5 +0,0 @@
-r requirements.txt
pytest==8.0.0
pytest-cov==4.1.0
flake8==7.0.0
bump2version==1.0.1

View File

@ -1,2 +0,0 @@
Sphinx==7.1.2
furo==2024.1.29

39
scripts/Containerfile Normal file
View File

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

21
scripts/README.md Normal file
View File

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

34
scripts/compose.yml Normal file
View File

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

65
scripts/container.setup.sh Executable file
View File

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

159
scripts/exec-sshuttle Executable file
View File

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

86
scripts/exec-tool Executable file
View File

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

40
scripts/run-benchmark Executable file
View File

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

9
scripts/run-checks Executable file
View File

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

42
scripts/test-bed Executable file
View File

@ -0,0 +1,42 @@
#!/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,5 +1,5 @@
[bumpversion] [bumpversion]
current_version = 1.1.2 current_version = 1.3.0
[bumpversion:file:setup.py] [bumpversion:file:setup.py]

View File

@ -1,61 +0,0 @@
#!/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
setup(
name="sshuttle",
version='1.1.2',
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.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Topic :: System :: Networking",
],
entry_points={
'console_scripts': [
'sshuttle = sshuttle.cmdline:main',
],
},
python_requires='>=3.8',
install_requires=[
],
tests_require=[
'pytest',
'pytest-cov',
'pytest-runner',
'flake8',
],
keywords="ssh vpn",
)

View File

@ -1,4 +1,10 @@
"""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
sys.exit(main()) from sshuttle.helpers import debug3
debug3("Start: (pid=%s, ppid=%s) %r" % (os.getpid(), os.getppid(), sys.argv))
exit_code = main()
debug3("Exit: (pid=%s, ppid=%s, code=%s) cmd %r" % (os.getpid(), os.getppid(), exit_code, sys.argv))
sys.exit(exit_code)

View File

@ -3,24 +3,27 @@ import zlib
import types import types
import platform import platform
verbosity = verbosity # noqa: F821 must be a previously defined global stdin = stdin # type: typing.BinaryIO # noqa: F821 must be a previously defined global
verbosity = verbosity # type: int # noqa: F821 must be a previously defined global
if verbosity > 0: 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 = sys.stdin.readline().strip() name = stdin.readline().strip()
if name: if name:
# python2 compat: in python2 sys.stdin.readline().strip() -> str # python2 compat: in python2 stdin.readline().strip() -> str
# in python3 sys.stdin.readline().strip() -> bytes # in python3 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(sys.stdin.readline()) nbytes = int(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)\n'
% (name, nbytes)) % (name, nbytes))
content = z.decompress(sys.stdin.read(nbytes)) content = z.decompress(stdin.read(nbytes))
module = types.ModuleType(name) module = types.ModuleType(name)
parents = name.rsplit(".", 1) parents = name.rsplit(".", 1)
@ -44,6 +47,7 @@ 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,6 +5,7 @@ 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
@ -14,7 +15,7 @@ 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 resolvconf_nameservers, which, is_admin_user, RWPair
from sshuttle.methods import get_method, Features from sshuttle.methods import get_method, Features
from sshuttle import __version__ from sshuttle import __version__
try: try:
@ -209,7 +210,10 @@ class FirewallClient:
def __init__(self, method_name, sudo_pythonpath): def __init__(self, method_name, sudo_pythonpath):
self.auto_nets = [] self.auto_nets = []
argvbase = ([sys.executable, sys.argv[0]] + argv0 = sys.argv[0]
# argv0 is either be a normal Python file or an executable.
# After installed as a package, sshuttle command points to an .exe in Windows and Python shebang script elsewhere.
argvbase = (([sys.executable, sys.argv[0]] if argv0.endswith('.py') else [argv0]) +
['-v'] * (helpers.verbose or 0) + ['-v'] * (helpers.verbose or 0) +
['--method', method_name] + ['--method', method_name] +
['--firewall']) ['--firewall'])
@ -219,9 +223,18 @@ 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 os.getuid() == 0: # No need to elevate privileges if is_admin_user(): # 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: ']
@ -254,8 +267,7 @@ class FirewallClient:
# 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 \ if (doas_path and not sudo_path) or platform.platform().startswith('OpenBSD'):
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]
@ -265,21 +277,58 @@ 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:
if sys.platform != 'win32':
# we can't use stdin/stdout=subprocess.PIPE here, as we # we can't use stdin/stdout=subprocess.PIPE here, as we
# normally would, because stupid Linux 'su' requires that # normally would, because stupid Linux 'su' requires that
# stdin be attached to a tty. Instead, attach a # stdin be attached to a tty. Instead, attach a
# *bidirectional* socket to its stdout, and use that for # *bidirectional* socket to its stdout, and use that for
# talking in both directions. # talking in both directions.
(s1, s2) = socket.socketpair() (s1, s2) = socket.socketpair()
pstdout = s1
pstdin = s1
penv = None
def setup(): def preexec_fn():
# run in the child process # run in the child process
s2.close() 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=s1, stdin=s1, self.p = ssubprocess.Popen(argv, stdout=pstdout, stdin=pstdin, env=penv,
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
@ -287,11 +336,14 @@ 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.argv = argv
s1.close() self.pfile = get_pfile()
self.pfile = s2.makefile('rwb')
try:
line = self.pfile.readline() line = self.pfile.readline()
except IOError:
# happens when firewall subprocess exists
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:
@ -334,7 +386,7 @@ class FirewallClient:
break break
if not success: if not success:
raise Fatal("All attempts to elevate privileges failed.") raise Fatal("All attempts to run firewall client process with elevated privileges were 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,
@ -539,7 +591,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): to_nameserver, add_cmd_delimiter, remote_shell):
helpers.logprefix = 'c : ' helpers.logprefix = 'c : '
debug1('Starting client with Python version %s' debug1('Starting client with Python version %s'
@ -551,9 +603,11 @@ def _main(tcp_listener, udp_listener, fw, ssh_cmd, remotename,
debug1('Connecting to server...') debug1('Connecting to server...')
try: try:
(serverproc, serversock) = ssh.connect( (serverproc, rfile, wfile) = 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,
@ -561,24 +615,25 @@ 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(serversock.makefile("rb"), serversock.makefile("wb")) mux = Mux(rfile, wfile)
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 = serversock.recv(1) v = rfile.read(1)
v = 'x' v = 'x'
while v and v != b'\0': while v and v != b'\0':
v = serversock.recv(1) v = rfile.read(1)
initstring = serversock.recv(len(expected)) initstring = rfile.read(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
@ -755,7 +810,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, tmark): user, group, sudo_pythonpath, add_cmd_delimiter, remote_shell, 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 "
@ -820,7 +875,8 @@ 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', 0) listenip_v4 = ('127.0.0.1' if avail.loopback_proxy_port else '0.0.0.0', 0)
debug1("Using default IPv4 listen address " + listenip_v4[0])
# listenip_v6 is... # listenip_v6 is...
# None when IPv6 is disabled. # None when IPv6 is disabled.
@ -830,8 +886,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:
debug1("IPv6 enabled: Using default IPv6 listen address ::1") listenip_v6 = ('::1' if avail.loopback_proxy_port else '::', 0)
listenip_v6 = ('::1', 0) debug1("IPv6 enabled: Using default IPv6 listen address " + listenip_v6[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)
@ -1103,7 +1159,7 @@ def main(listenip_v6, listenip_v4,
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) daemon, to_nameserver, add_cmd_delimiter, remote_shell)
finally: finally:
try: try:
if daemon: if daemon:

View File

@ -11,6 +11,7 @@ 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():
@ -37,6 +38,16 @@ 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')
@ -115,6 +126,8 @@ def main():
opt.user, opt.user,
opt.group, 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

@ -7,12 +7,17 @@ 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 log, debug1, debug2, Fatal from sshuttle.helpers import is_admin_user, log, debug1, debug2, debug3, Fatal
from sshuttle.methods import get_auto_method, get_method from sshuttle.methods import get_auto_method, get_method
if sys.platform == 'win32':
HOSTSFILE = r"C:\Windows\System32\drivers\etc\hosts"
else:
HOSTSFILE = '/etc/hosts' HOSTSFILE = '/etc/hosts'
sshuttle_pid = None sshuttle_pid = None
@ -46,6 +51,7 @@ 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)
@ -82,14 +88,17 @@ 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 SIGINT to sshuttle process %d\n" % sshuttle_pid) debug1("Relaying interupt signal to sshuttle process %d" % sshuttle_pid)
os.kill(sshuttle_pid, signal.SIGINT) if sys.platform == 'win32':
sig = signal.CTRL_C_EVENT
else:
sig = signal.SIGINT
os.kill(sshuttle_pid, sig)
# Isolate function that needs to be replaced for tests def _setup_daemon_for_unix_like():
def setup_daemon(): if not is_admin_user():
if os.getuid() != 0: raise Fatal('You must have root privileges (or enable su/sudo) to set the firewall')
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.
@ -110,7 +119,34 @@ def setup_daemon():
# 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, sys.stdout return sys.stdin.buffer, sys.stdout.buffer
def _setup_daemon_for_windows():
if not is_admin_user():
raise Fatal('You must be administrator to set the firewall')
signal.signal(signal.SIGTERM, firewall_exit)
signal.signal(signal.SIGINT, firewall_exit)
com_chan = os.environ.get('SSHUTTLE_FW_COM_CHANNEL')
if com_chan == 'stdio':
debug3('Using inherited stdio for communicating with sshuttle client process')
else:
debug3('Using shared socket for communicating with sshuttle client process')
socket_share_data = base64.b64decode(com_chan)
sock = socket.fromshare(socket_share_data) # type: socket.socket
sys.stdin = io.TextIOWrapper(sock.makefile('rb', buffering=0))
sys.stdout = io.TextIOWrapper(sock.makefile('wb', buffering=0), write_through=True)
sock.close()
return sys.stdin.buffer, sys.stdout.buffer
# Isolate function that needs to be replaced for tests
if sys.platform == 'win32':
setup_daemon = _setup_daemon_for_windows
else:
setup_daemon = _setup_daemon_for_unix_like
# Note that we're sorting in a very particular order: # Note that we're sorting in a very particular order:
@ -184,28 +220,42 @@ 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) stdout.write(('READY %s\n' % method.name).encode('ASCII'))
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).
line = stdin.readline(128) try:
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\n': if line != 'ROUTES':
raise Fatal('expected ROUTES but got %r' % line) raise Fatal('expected ROUTES but got %r' % line)
while 1: while 1:
line = stdin.readline(128) line = _read_next_string_line()
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\n"): elif line.startswith("NSLIST"):
break break
try: try:
(family, width, exclude, ip, fport, lport) = \ (family, width, exclude, ip, fport, lport) = line.split(',', 5)
line.strip().split(',', 5)
except Exception: except Exception:
raise Fatal('expected route or NSLIST but got %r' % line) raise Fatal('expected route or NSLIST but got %r' % line)
subnets.append(( subnets.append((
@ -218,16 +268,16 @@ def main(method_name, syslog):
debug2('Got subnets: %r' % subnets) debug2('Got subnets: %r' % subnets)
nslist = [] nslist = []
if line != 'NSLIST\n': if line != 'NSLIST':
raise Fatal('expected NSLIST but got %r' % line) raise Fatal('expected NSLIST but got %r' % line)
while 1: while 1:
line = stdin.readline(128) line = _read_next_string_line()
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.strip().split(',', 1) (family, ip) = line.split(',', 1)
except Exception: except Exception:
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))
@ -257,15 +307,13 @@ def main(method_name, syslog):
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 = stdin.readline(128) line = _read_next_string_line()
if not line: if not line or not line.startswith("GO "):
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.strip().split(" ", 4) udp, user, group, tmark, sshuttle_pid = args.split(" ", 4)
udp = bool(int(udp)) udp = bool(int(udp))
sshuttle_pid = int(sshuttle_pid) sshuttle_pid = int(sshuttle_pid)
if user == '-': if user == '-':
@ -297,23 +345,32 @@ def main(method_name, syslog):
socket.AF_INET, subnets_v4, udp, socket.AF_INET, subnets_v4, udp,
user, group, tmark) user, group, tmark)
try:
# For some methods (eg: windivert) firewall setup will be differed / will run asynchronously.
# Such method implements wait_for_firewall_ready() to wait until firewall is up and running.
method.wait_for_firewall_ready(sshuttle_pid)
except NotImplementedError:
pass
if sys.platform == 'linux':
flush_systemd_dns_cache() flush_systemd_dns_cache()
stdout.write('STARTED\n')
try: try:
stdout.write(b'STARTED\n')
stdout.flush() stdout.flush()
except IOError: except IOError as e: # the parent process probably died
# the parent process died for some reason; he's surely been loud debug3('write to stdout failed: %s' % (e,))
# 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 = stdin.readline(128) line = _read_next_string_line()
if not line:
return
if line.startswith('HOST '): if line.startswith('HOST '):
(name, ip) = line[5:].strip().split(',', 1) (name, ip) = line[5:].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)
@ -360,6 +417,7 @@ def main(method_name, syslog):
except Exception: except Exception:
debug2('An error occurred, ignoring it.') debug2('An error occurred, ignoring it.')
if sys.platform == 'linux':
try: try:
flush_systemd_dns_cache() flush_systemd_dns_cache()
except Exception: except Exception:

View File

@ -2,6 +2,13 @@ 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
@ -11,10 +18,17 @@ 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"
@ -25,7 +39,7 @@ def log(s):
sys.stderr.write(prefix + line + "\n") sys.stderr.write(prefix + line + "\n")
prefix = " " prefix = " "
sys.stderr.flush() sys.stderr.flush()
except IOError: except (IOError, ValueError): # ValueError ~ I/O operation on closed file
# 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
@ -102,18 +116,43 @@ def resolvconf_nameservers(systemd_resolved):
return nsservers return nsservers
def resolvconf_random_nameserver(systemd_resolved): def windows_nameservers():
out = subprocess.check_output(["powershell", "-NonInteractive", "-NoProfile", "-Command", "Get-DnsClientServerAddress"],
encoding="utf-8")
servers = set()
for line in out.splitlines():
if line.startswith("Loopback "):
continue
m = re.search(r'{.+}', line)
if not m:
continue
for s in m.group().strip('{}').split(','):
s = s.strip()
if s.startswith('fec0:0:0:ffff'):
continue
servers.add(s)
debug2("Found DNS servers: %s" % servers)
return [(socket.AF_INET6 if ':' in s else socket.AF_INET, s) for s in servers]
def get_random_nameserver():
"""Return a random nameserver selected from servers produced by """Return a random nameserver selected from servers produced by
resolvconf_nameservers(). See documentation for resolvconf_nameservers()/windows_nameservers()
resolvconf_nameservers() for a description of the parameter.
""" """
lines = resolvconf_nameservers(systemd_resolved) if sys.platform == "win32":
if lines: if globals().get('_nameservers') is None:
if len(lines) > 1: ns_list = windows_nameservers()
globals()['_nameservers'] = ns_list
else:
ns_list = globals()['_nameservers']
else:
ns_list = resolvconf_nameservers(systemd_resolved=False)
if ns_list:
if len(ns_list) > 1:
# don't import this unless we really need it # don't import this unless we really need it
import random import random
random.shuffle(lines) random.shuffle(ns_list)
return lines[0] return ns_list[0]
else: else:
return (socket.AF_INET, '127.0.0.1') return (socket.AF_INET, '127.0.0.1')
@ -220,3 +259,91 @@ 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,6 +18,8 @@ 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:
@ -81,6 +83,11 @@ 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):
@ -97,12 +104,13 @@ 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))
write_host_cache() SHOULD_WRITE_CACHE = True
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').split('\n'): for line in output.decode('ASCII', errors='replace').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,6 +1,7 @@
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
@ -45,6 +46,7 @@ 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
@ -96,6 +98,9 @@ class BaseMethod(object):
def restore_firewall(self, port, family, udp, user, group): def restore_firewall(self, port, family, udp, user, group):
raise NotImplementedError() raise NotImplementedError()
def wait_for_firewall_ready(self, sshuttle_pid):
raise NotImplementedError()
@staticmethod @staticmethod
def firewall_command(line): def firewall_command(line):
return False return False
@ -109,7 +114,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"] methods_to_try = ["nat", "nft", "pf", "ipfw"] if sys.platform != "win32" else ["windivert"]
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

@ -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 * 3424 self.pfioc_rule = c_char * 3408
self.pfioc_natlook = pfioc_natlook self.pfioc_natlook = pfioc_natlook
super(OpenBsd, self).__init__() super(OpenBsd, self).__init__()

View File

@ -0,0 +1,533 @@
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()

40
sshuttle/namespace.py Normal file
View File

@ -0,0 +1,40 @@
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,5 +1,6 @@
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__
@ -136,6 +137,15 @@ 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:
@ -234,9 +244,14 @@ parser.add_argument(
""" """
) )
if sys.platform == 'win32':
method_choices = ["auto", "windivert"]
else:
method_choices = ["auto", "nat", "tproxy", "pf", "ipfw"]
parser.add_argument( parser.add_argument(
"--method", "--method",
choices=["auto", "nat", "nft", "tproxy", "pf", "ipfw"], choices=method_choices,
metavar="TYPE", metavar="TYPE",
default="auto", default="auto",
help=""" help="""
@ -301,6 +316,22 @@ 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]",
@ -438,3 +469,20 @@ 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,6 +5,7 @@ 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
@ -13,7 +14,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, \
resolvconf_random_nameserver, which, get_env get_random_nameserver, which, get_env, SocketRWShim
def _ipmatch(ipstr): def _ipmatch(ipstr):
@ -78,6 +79,20 @@ 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())
@ -100,6 +115,9 @@ def _list_routes(argv, extract_route):
def list_routes(): def list_routes():
if sys.platform == 'win32':
routes = _list_routes(['route', 'PRINT', '-4'], _route_windows)
else:
if which('ip'): if which('ip'):
routes = _list_routes(['ip', 'route'], _route_iproute) routes = _list_routes(['ip', 'route'], _route_iproute)
elif which('netstat'): elif which('netstat'):
@ -181,7 +199,7 @@ class DnsProxy(Handler):
self.tries += 1 self.tries += 1
if self.to_nameserver is None: if self.to_nameserver is None:
_, peer = resolvconf_random_nameserver(False) _, peer = get_random_nameserver()
port = 53 port = 53
else: else:
peer = self.to_ns_peer peer = self.to_ns_peer
@ -281,7 +299,16 @@ def main(latency_control, latency_buffer_size, auto_hosts, to_nameserver,
sys.stdout.flush() sys.stdout.flush()
handlers = [] handlers = []
mux = Mux(sys.stdin, sys.stdout) # get unbuffered stdin and stdout in binary mode. Equivalent to stdin.buffer/stdout.buffer (Only available in Python 3)
r, w = io.FileIO(0, mode='r'), io.FileIO(1, mode='w')
if sys.platform == 'win32':
def _deferred_exit():
time.sleep(1) # give enough time to write logs to stderr
os._exit(23)
shim = SocketRWShim(r, w, on_end=_deferred_exit)
mux = Mux(*shim.makefiles())
else:
mux = Mux(r, w)
handlers.append(mux) handlers.append(mux)
debug1('auto-nets:' + str(auto_nets)) debug1('auto-nets:' + str(auto_nets))

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, Fatal from sshuttle.helpers import debug2, which, get_path, SocketRWShim, 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(":") username, password = username.split(":", 1)
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, options): def connect(ssh_cmd, rhostport, python, stderr, add_cmd_delimiter, remote_shell, 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, options):
pyscript = r""" pyscript = r"""
import sys, os; import sys, os;
verbosity=%d; verbosity=%d;
sys.stdin = os.fdopen(0, "rb"); stdin = os.fdopen(0, 'rb');
exec(compile(sys.stdin.read(%d), "assembler.py", "exec")); exec(compile(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,8 +134,15 @@ def connect(ssh_cmd, rhostport, python, stderr, options):
portl = ["-p", str(port)] portl = ["-p", str(port)]
else: else:
portl = [] portl = []
if remote_shell == "cmd":
pycmd = '"%s" -c "%s"' % (python or 'python', pyscript)
elif remote_shell == "powershell":
for c in ('\'', ' ', ';', '(', ')', ','):
pyscript = pyscript.replace(c, '`' + c)
pycmd = '%s -c %s' % (python or 'python', pyscript)
else: # posix shell expected
if python: if python:
pycmd = "'%s' -c '%s'" % (python, pyscript) pycmd = '"%s" -c "%s"' % (python, pyscript)
else: else:
# By default, we run the following code in a shell. # By default, we run the following code in a shell.
# However, with restricted shells and other unusual # However, with restricted shells and other unusual
@ -175,21 +182,24 @@ def connect(ssh_cmd, rhostport, python, stderr, options):
# case, sshuttle might not work at all since it is not # case, sshuttle might not work at all since it is not
# possible to run python on the remote machine---even if # possible to run python on the remote machine---even if
# it is present. # it is present.
devnull = '/dev/null'
pycmd = ("P=python3; $P -V 2>%s || P=python; " pycmd = ("P=python3; $P -V 2>%s || P=python; "
"exec \"$P\" -c %s; exit 97") % \ "exec \"$P\" -c %s; exit 97") % \
(os.devnull, quote(pyscript)) (devnull, quote(pyscript))
pycmd = ("/bin/sh -c {}".format(quote(pycmd))) 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 + portl + [rhost])
[rhost, '--', pycmd])
else: else:
argv = (sshl + argv = (sshl + portl + [rhost])
portl +
[rhost, '--', pycmd]) if add_cmd_delimiter:
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
@ -201,19 +211,45 @@ def connect(ssh_cmd, rhostport, python, stderr, options):
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 setup(): def preexec_fn():
# runs in the child process # runs in the child process
s2.close() s2.close()
s1a, s1b = os.dup(s1.fileno()), os.dup(s1.fileno())
s1.close() s1.close()
debug2('executing: %r' % argv) def get_server_io():
p = ssubprocess.Popen(argv, stdin=s1a, stdout=s1b, preexec_fn=setup, os.close(pstdin)
close_fds=True, stderr=stderr) os.close(pstdout)
os.close(s1a) return s2.makefile("rb", buffering=0), s2.makefile("wb", buffering=0)
os.close(s1b) else:
s2.sendall(content) # In Windows CPython, BSD sockets are not supported as subprocess stdio
s2.sendall(content2) # and select.select() used in ssnet.py won't work on Windows pipes.
return p, s2 # So we have to use both socketpair (for select.select) and pipes (for subprocess.Popen) together
# along with reader/writer threads to stream data between them
# NOTE: Their could be a better way. Need to investigate further on this.
# Either to use sockets as stdio for subprocess. Or to use pipes but with a select() alternative
# https://stackoverflow.com/questions/4993119/redirect-io-of-process-to-windows-socket
pstdin = ssubprocess.PIPE
pstdout = ssubprocess.PIPE
preexec_fn = None
def get_server_io():
shim = SocketRWShim(p.stdout, p.stdin, on_end=lambda: p.terminate())
return shim.makefiles()
# See: stackoverflow.com/questions/48671215/howto-workaround-of-close-fds-true-and-redirect-stdout-stderr-on-windows
close_fds = False if sys.platform == 'win32' else True
debug2("executing: %r" % argv)
p = ssubprocess.Popen(argv, stdin=pstdin, stdout=pstdout, preexec_fn=preexec_fn,
close_fds=close_fds, stderr=stderr, bufsize=0)
rfile, wfile = get_server_io()
wfile.write(content)
wfile.write(content2)
return p, rfile, wfile

View File

@ -4,9 +4,8 @@ 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 from sshuttle.helpers import b, log, debug1, debug2, debug3, Fatal, set_non_blocking_io
MAX_CHANNEL = 65535 MAX_CHANNEL = 65535
LATENCY_BUFFER_SIZE = 32768 LATENCY_BUFFER_SIZE = 32768
@ -78,7 +77,8 @@ def _fds(socks):
def _nb_clean(func, *args): def _nb_clean(func, *args):
try: try:
return func(*args) return func(*args)
except OSError: except (OSError, socket.error):
# 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,7 +168,13 @@ 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':
# On Windows "real" error of EINVAL could be 0, when socket is in connecting state
pass
else:
# connected successfully (weird Linux bug?) # connected successfully (weird Linux bug?)
# Sometimes Linux seems to return EINVAL when it isn't # Sometimes Linux seems to return EINVAL when it isn't
# invalid. This *may* be caused by a race condition # invalid. This *may* be caused by a race condition
@ -180,7 +186,7 @@ class SockWrapper:
# when we added this, however. # when we added this, however.
self.connect_to = None self.connect_to = None
elif e.args[0] == errno.EISCONN: elif e.args[0] == errno.EISCONN:
# connected successfully (BSD) # connected successfully (BSD + Windows)
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
@ -213,7 +219,7 @@ class SockWrapper:
return 0 # still connecting return 0 # still connecting
self.wsock.setblocking(False) self.wsock.setblocking(False)
try: try:
return _nb_clean(os.write, self.wsock.fileno(), buf) return _nb_clean(self.wsock.send, 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:
@ -236,7 +242,7 @@ class SockWrapper:
return return
self.rsock.setblocking(False) self.rsock.setblocking(False)
try: try:
return _nb_clean(os.read, self.rsock.fileno(), 65536) return _nb_clean(self.rsock.recv, 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)
@ -382,11 +388,13 @@ 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:
@ -431,15 +439,10 @@ class Mux(Handler):
callback(cmd, data) callback(cmd, data)
def flush(self): def flush(self):
try: set_non_blocking_io(self.wfile.fileno())
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
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(os.write, self.wfile.fileno(), self.outbuf[0]) wrote = _nb_clean(self.wfile.write, self.outbuf[0])
# self.wfile.flush()
debug2('mux wrote: %r/%d' % (wrote, len(self.outbuf[0]))) 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:]
@ -447,18 +450,12 @@ class Mux(Handler):
self.outbuf[0:1] = [] self.outbuf[0:1] = []
def fill(self): def fill(self):
try: set_non_blocking_io(self.rfile.fileno())
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
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(os.read, self.rfile.fileno(), read = _nb_clean(self.rfile.read, min(1048576, LATENCY_BUFFER_SIZE))
min(1048576, LATENCY_BUFFER_SIZE)) debug2('mux read: %r' % len(read))
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)

View File

@ -1 +1 @@
__version__ = version = '1.1.2' __version__ = version = '1.2.0'

View File

@ -10,7 +10,7 @@ import sshuttle.firewall
def setup_daemon(): def setup_daemon():
stdin = io.StringIO(u"""ROUTES stdin = io.BytesIO(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
@ -21,7 +21,7 @@ NSLIST
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)) """.format(inet=AF_INET, inet6=AF_INET6).encode('ASCII'))
stdout = Mock() stdout = Mock()
return stdin, stdout return stdin, stdout
@ -127,9 +127,9 @@ def test_main(mock_get_method, mock_setup_daemon, mock_rewrite_etc_hosts):
] ]
assert stdout.mock_calls == [ assert stdout.mock_calls == [
call.write('READY test\n'), call.write(b'READY test\n'),
call.flush(), call.flush(),
call.write('STARTED\n'), call.write(b'STARTED\n'),
call.flush() call.flush()
] ]
assert mock_setup_daemon.mock_calls == [call()] assert mock_setup_daemon.mock_calls == [call()]
@ -157,6 +157,7 @@ def test_main(mock_get_method, mock_setup_daemon, mock_rewrite_etc_hosts):
None, None,
None, None,
'0x01'), '0x01'),
call().wait_for_firewall_ready(12345),
call().restore_firewall(1024, AF_INET6, True, None, None), call().restore_firewall(1024, AF_INET6, True, None, None),
call().restore_firewall(1025, AF_INET, True, None, None), call().restore_firewall(1025, AF_INET, True, None, None),
] ]

View File

@ -143,7 +143,7 @@ nameserver 2404:6800:4004:80c::4
@patch('sshuttle.helpers.open', create=True) @patch('sshuttle.helpers.open', create=True)
def test_resolvconf_random_nameserver(mock_open): def test_get_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.resolvconf_random_nameserver(False) ns = sshuttle.helpers.get_random_nameserver()
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'),

View File

@ -418,8 +418,8 @@ def test_setup_firewall_openbsd(mock_pf_get_dev, mock_ioctl, mock_pfctl):
'0x01') '0x01')
assert mock_ioctl.mock_calls == [ assert mock_ioctl.mock_calls == [
call(mock_pf_get_dev(), 0xcd60441a, ANY), call(mock_pf_get_dev(), 0xcd50441a, ANY),
call(mock_pf_get_dev(), 0xcd60441a, ANY), call(mock_pf_get_dev(), 0xcd50441a, ANY),
] ]
assert mock_pfctl.mock_calls == [ assert mock_pfctl.mock_calls == [
call('-s Interfaces -i lo -v'), call('-s Interfaces -i lo -v'),
@ -470,8 +470,8 @@ def test_setup_firewall_openbsd(mock_pf_get_dev, mock_ioctl, mock_pfctl):
None, None,
'0x01') '0x01')
assert mock_ioctl.mock_calls == [ assert mock_ioctl.mock_calls == [
call(mock_pf_get_dev(), 0xcd60441a, ANY), call(mock_pf_get_dev(), 0xcd50441a, ANY),
call(mock_pf_get_dev(), 0xcd60441a, ANY), call(mock_pf_get_dev(), 0xcd50441a, ANY),
] ]
assert mock_pfctl.mock_calls == [ assert mock_pfctl.mock_calls == [
call('-s Interfaces -i lo -v'), call('-s Interfaces -i lo -v'),

View File

@ -176,3 +176,33 @@ def test_parse_subnetport_host_with_port(mock_getaddrinfo):
(socket.AF_INET6, '2404:6800:4004:821::2001', 128, 80, 90), (socket.AF_INET6, '2404:6800:4004:821::2001', 128, 80, 90),
(socket.AF_INET, '142.251.42.129', 32, 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

@ -7,9 +7,10 @@ envlist =
[testenv] [testenv]
basepython = basepython =
py38: python3.8
py39: python3.9 py39: python3.9
py310: python3.10 py310: python3.10
py311: python3.11
py312: python3.12
commands = commands =
pip install -e . pip install -e .
# actual flake8 test # actual flake8 test