pytap3 3.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pytap3-3.0.0/LICENSE.txt +18 -0
- pytap3-3.0.0/MANIFEST.in +3 -0
- pytap3-3.0.0/PKG-INFO +129 -0
- pytap3-3.0.0/README.rst +94 -0
- pytap3-3.0.0/pyproject.toml +28 -0
- pytap3-3.0.0/pytap3_build.py +76 -0
- pytap3-3.0.0/setup.cfg +4 -0
- pytap3-3.0.0/setup.py +44 -0
- pytap3-3.0.0/src/pytap3/__init__.py +499 -0
- pytap3-3.0.0/src/pytap3/py.typed +0 -0
- pytap3-3.0.0/src/pytap3.egg-info/PKG-INFO +129 -0
- pytap3-3.0.0/src/pytap3.egg-info/SOURCES.txt +15 -0
- pytap3-3.0.0/src/pytap3.egg-info/dependency_links.txt +1 -0
- pytap3-3.0.0/src/pytap3.egg-info/not-zip-safe +1 -0
- pytap3-3.0.0/src/pytap3.egg-info/top_level.txt +1 -0
- pytap3-3.0.0/tests/test_pytap3.py +115 -0
- pytap3-3.0.0/tests/test_pytap3_build.py +121 -0
pytap3-3.0.0/LICENSE.txt
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Copyright (c) 2026 Federico Santandrea
|
|
2
|
+
Copyright (c) 2019-2023 John Hagen
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
|
6
|
+
and associated documentation files (the "Software"), to deal in the Software without restriction,
|
|
7
|
+
including without limitation the rights to use, copy, modify, merge, publish, distribute,
|
|
8
|
+
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
|
|
9
|
+
furnished to do so, subject to the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all copies or
|
|
12
|
+
substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
|
|
15
|
+
BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
16
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
|
17
|
+
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
pytap3-3.0.0/MANIFEST.in
ADDED
pytap3-3.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pytap3
|
|
3
|
+
Version: 3.0.0
|
|
4
|
+
Summary: Cross-platform tunnel interface for Python
|
|
5
|
+
Home-page: https://github.com/sntfrc/pytap3
|
|
6
|
+
Author: Federico Santandrea
|
|
7
|
+
Author-email: federico@santandrea.net
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: pytap,tun,tap,networking
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
14
|
+
Classifier: Programming Language :: Python
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Requires-Python: >=3.8
|
|
24
|
+
License-File: LICENSE.txt
|
|
25
|
+
Dynamic: author
|
|
26
|
+
Dynamic: author-email
|
|
27
|
+
Dynamic: classifier
|
|
28
|
+
Dynamic: description
|
|
29
|
+
Dynamic: home-page
|
|
30
|
+
Dynamic: keywords
|
|
31
|
+
Dynamic: license
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
Dynamic: requires-python
|
|
34
|
+
Dynamic: summary
|
|
35
|
+
|
|
36
|
+
``pytap3`` - Cross-platform tunnel interface for Python
|
|
37
|
+
=======================================================
|
|
38
|
+
|
|
39
|
+
This is a cross-platform fork of the original
|
|
40
|
+
`johnthagen/pytap2 <https://github.com/johnthagen/pytap2>`_. It provides a
|
|
41
|
+
packet-oriented interface to TUN devices on Linux, macOS, and Windows.
|
|
42
|
+
|
|
43
|
+
Requirements
|
|
44
|
+
------------
|
|
45
|
+
|
|
46
|
+
Requirements depend on your operating system:
|
|
47
|
+
|
|
48
|
+
* **Linux:** Install ``net-tools`` to provide the ``ifconfig`` command.
|
|
49
|
+
|
|
50
|
+
On Debian/Ubuntu:
|
|
51
|
+
|
|
52
|
+
.. code:: shell-session
|
|
53
|
+
|
|
54
|
+
$ sudo apt install net-tools
|
|
55
|
+
|
|
56
|
+
On RHEL/Rocky Linux/Fedora:
|
|
57
|
+
|
|
58
|
+
.. code:: shell-session
|
|
59
|
+
|
|
60
|
+
$ sudo yum install net-tools
|
|
61
|
+
|
|
62
|
+
* **macOS:** No additional packages are required; ``ifconfig`` is included
|
|
63
|
+
with macOS.
|
|
64
|
+
|
|
65
|
+
* **Windows:** ``wintun.dll`` is required. When building/installing from source
|
|
66
|
+
on Windows, the package downloads the signed Wintun 0.14.1 archive from
|
|
67
|
+
`Wintun <https://www.wintun.net/>`_, verifies its SHA-256 checksum, and bundles
|
|
68
|
+
the DLL matching the Python platform. This requires network access during
|
|
69
|
+
installation. Wheels are platform-tagged; build Windows wheels on Windows to
|
|
70
|
+
include the DLL. Source distributions build locally on any supported OS.
|
|
71
|
+
|
|
72
|
+
Creating and configuring a TUN device may require administrator or root
|
|
73
|
+
privileges.
|
|
74
|
+
|
|
75
|
+
Installation
|
|
76
|
+
------------
|
|
77
|
+
|
|
78
|
+
You can install, upgrade, and uninstall ``pytap3`` with these commands:
|
|
79
|
+
|
|
80
|
+
.. code:: shell-session
|
|
81
|
+
|
|
82
|
+
$ pip install pytap3
|
|
83
|
+
$ pip install --upgrade pytap3
|
|
84
|
+
$ pip uninstall pytap3
|
|
85
|
+
|
|
86
|
+
Usage
|
|
87
|
+
-----
|
|
88
|
+
|
|
89
|
+
Using as a context manager automatically brings up the device and closes it at the
|
|
90
|
+
end of the ``with`` block.
|
|
91
|
+
|
|
92
|
+
.. code:: python
|
|
93
|
+
|
|
94
|
+
from pytap3 import TapDevice
|
|
95
|
+
|
|
96
|
+
with TapDevice() as device:
|
|
97
|
+
device.ifconfig(mtu=1300)
|
|
98
|
+
device.write(b'0000')
|
|
99
|
+
|
|
100
|
+
Or manually call ``up()`` and ``close()``.
|
|
101
|
+
|
|
102
|
+
.. code:: python
|
|
103
|
+
|
|
104
|
+
from pytap3 import TapDevice
|
|
105
|
+
|
|
106
|
+
device = TapDevice()
|
|
107
|
+
device.up()
|
|
108
|
+
device.ifconfig(mtu=1300)
|
|
109
|
+
device.write(b'0000')
|
|
110
|
+
device.close()
|
|
111
|
+
|
|
112
|
+
The ``fileno()`` method is defined, so that the device object can be passed directly
|
|
113
|
+
to `select() <https://docs.python.org/library/select.html#select.select>`_. This might
|
|
114
|
+
not work on Windows, as the underlying Wintun driver does not support this.
|
|
115
|
+
|
|
116
|
+
On macOS, configuring an IP address with ``ifconfig(ip=..., netmask=...)`` also
|
|
117
|
+
adds a route for that subnet through the ``utun`` interface. Routes that conflict
|
|
118
|
+
with an existing route may require administrator privileges or manual adjustment.
|
|
119
|
+
|
|
120
|
+
You can make pytap3 a drop-in replacement for pytap2 by just importing it like this:
|
|
121
|
+
``import pytap3 as pytap2``
|
|
122
|
+
|
|
123
|
+
Releases
|
|
124
|
+
--------
|
|
125
|
+
|
|
126
|
+
3.0.0 - 2024-06-05
|
|
127
|
+
^^^^^^^^^^^^^^^^^^
|
|
128
|
+
|
|
129
|
+
- Windows, Linux and macOS support!
|
pytap3-3.0.0/README.rst
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
``pytap3`` - Cross-platform tunnel interface for Python
|
|
2
|
+
=======================================================
|
|
3
|
+
|
|
4
|
+
This is a cross-platform fork of the original
|
|
5
|
+
`johnthagen/pytap2 <https://github.com/johnthagen/pytap2>`_. It provides a
|
|
6
|
+
packet-oriented interface to TUN devices on Linux, macOS, and Windows.
|
|
7
|
+
|
|
8
|
+
Requirements
|
|
9
|
+
------------
|
|
10
|
+
|
|
11
|
+
Requirements depend on your operating system:
|
|
12
|
+
|
|
13
|
+
* **Linux:** Install ``net-tools`` to provide the ``ifconfig`` command.
|
|
14
|
+
|
|
15
|
+
On Debian/Ubuntu:
|
|
16
|
+
|
|
17
|
+
.. code:: shell-session
|
|
18
|
+
|
|
19
|
+
$ sudo apt install net-tools
|
|
20
|
+
|
|
21
|
+
On RHEL/Rocky Linux/Fedora:
|
|
22
|
+
|
|
23
|
+
.. code:: shell-session
|
|
24
|
+
|
|
25
|
+
$ sudo yum install net-tools
|
|
26
|
+
|
|
27
|
+
* **macOS:** No additional packages are required; ``ifconfig`` is included
|
|
28
|
+
with macOS.
|
|
29
|
+
|
|
30
|
+
* **Windows:** ``wintun.dll`` is required. When building/installing from source
|
|
31
|
+
on Windows, the package downloads the signed Wintun 0.14.1 archive from
|
|
32
|
+
`Wintun <https://www.wintun.net/>`_, verifies its SHA-256 checksum, and bundles
|
|
33
|
+
the DLL matching the Python platform. This requires network access during
|
|
34
|
+
installation. Wheels are platform-tagged; build Windows wheels on Windows to
|
|
35
|
+
include the DLL. Source distributions build locally on any supported OS.
|
|
36
|
+
|
|
37
|
+
Creating and configuring a TUN device may require administrator or root
|
|
38
|
+
privileges.
|
|
39
|
+
|
|
40
|
+
Installation
|
|
41
|
+
------------
|
|
42
|
+
|
|
43
|
+
You can install, upgrade, and uninstall ``pytap3`` with these commands:
|
|
44
|
+
|
|
45
|
+
.. code:: shell-session
|
|
46
|
+
|
|
47
|
+
$ pip install pytap3
|
|
48
|
+
$ pip install --upgrade pytap3
|
|
49
|
+
$ pip uninstall pytap3
|
|
50
|
+
|
|
51
|
+
Usage
|
|
52
|
+
-----
|
|
53
|
+
|
|
54
|
+
Using as a context manager automatically brings up the device and closes it at the
|
|
55
|
+
end of the ``with`` block.
|
|
56
|
+
|
|
57
|
+
.. code:: python
|
|
58
|
+
|
|
59
|
+
from pytap3 import TapDevice
|
|
60
|
+
|
|
61
|
+
with TapDevice() as device:
|
|
62
|
+
device.ifconfig(mtu=1300)
|
|
63
|
+
device.write(b'0000')
|
|
64
|
+
|
|
65
|
+
Or manually call ``up()`` and ``close()``.
|
|
66
|
+
|
|
67
|
+
.. code:: python
|
|
68
|
+
|
|
69
|
+
from pytap3 import TapDevice
|
|
70
|
+
|
|
71
|
+
device = TapDevice()
|
|
72
|
+
device.up()
|
|
73
|
+
device.ifconfig(mtu=1300)
|
|
74
|
+
device.write(b'0000')
|
|
75
|
+
device.close()
|
|
76
|
+
|
|
77
|
+
The ``fileno()`` method is defined, so that the device object can be passed directly
|
|
78
|
+
to `select() <https://docs.python.org/library/select.html#select.select>`_. This might
|
|
79
|
+
not work on Windows, as the underlying Wintun driver does not support this.
|
|
80
|
+
|
|
81
|
+
On macOS, configuring an IP address with ``ifconfig(ip=..., netmask=...)`` also
|
|
82
|
+
adds a route for that subnet through the ``utun`` interface. Routes that conflict
|
|
83
|
+
with an existing route may require administrator privileges or manual adjustment.
|
|
84
|
+
|
|
85
|
+
You can make pytap3 a drop-in replacement for pytap2 by just importing it like this:
|
|
86
|
+
``import pytap3 as pytap2``
|
|
87
|
+
|
|
88
|
+
Releases
|
|
89
|
+
--------
|
|
90
|
+
|
|
91
|
+
3.0.0 - 2024-06-05
|
|
92
|
+
^^^^^^^^^^^^^^^^^^
|
|
93
|
+
|
|
94
|
+
- Windows, Linux and macOS support!
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=60.2", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[tool.mypy]
|
|
6
|
+
ignore_missing_imports = true
|
|
7
|
+
strict = true
|
|
8
|
+
|
|
9
|
+
[tool.black]
|
|
10
|
+
line-length = 99
|
|
11
|
+
target-version = ["py38", "py39", "py310"]
|
|
12
|
+
|
|
13
|
+
[tool.isort]
|
|
14
|
+
profile = "black"
|
|
15
|
+
line_length = 99
|
|
16
|
+
force_sort_within_sections = true
|
|
17
|
+
src_paths = ["src/pytap3"]
|
|
18
|
+
skip_gitignore = true
|
|
19
|
+
|
|
20
|
+
[tool.coverage.run]
|
|
21
|
+
branch = true
|
|
22
|
+
source = ["pytap3", "tests/"]
|
|
23
|
+
|
|
24
|
+
[tool.coverage.paths]
|
|
25
|
+
source = [
|
|
26
|
+
"src/pytap3",
|
|
27
|
+
".tox/*/lib/python*/site-packages/pytap3",
|
|
28
|
+
]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import io
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import sys
|
|
5
|
+
import sysconfig
|
|
6
|
+
from typing import Dict, List, Tuple, cast
|
|
7
|
+
import urllib.request
|
|
8
|
+
import zipfile
|
|
9
|
+
|
|
10
|
+
from setuptools.command.build_py import build_py
|
|
11
|
+
from wheel.bdist_wheel import bdist_wheel
|
|
12
|
+
|
|
13
|
+
WINTUN_URL = "https://www.wintun.net/builds/wintun-0.14.1.zip"
|
|
14
|
+
WINTUN_SHA256 = "07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51"
|
|
15
|
+
|
|
16
|
+
_PLATFORM_ARCHITECTURES: Dict[str, str] = {
|
|
17
|
+
"win32": "x86",
|
|
18
|
+
"win-amd64": "amd64",
|
|
19
|
+
"win-arm32": "arm",
|
|
20
|
+
"win-arm64": "arm64",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_wintun_architecture() -> str:
|
|
25
|
+
platform_name = sysconfig.get_platform().lower()
|
|
26
|
+
try:
|
|
27
|
+
return _PLATFORM_ARCHITECTURES[platform_name]
|
|
28
|
+
except KeyError as exc:
|
|
29
|
+
raise RuntimeError(
|
|
30
|
+
"Unsupported Windows platform for Wintun: {}".format(platform_name)
|
|
31
|
+
) from exc
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def download_wintun(destination: Path, architecture: str) -> None:
|
|
35
|
+
with urllib.request.urlopen(WINTUN_URL, timeout=30) as response:
|
|
36
|
+
archive_data = response.read()
|
|
37
|
+
digest = hashlib.sha256(archive_data).hexdigest()
|
|
38
|
+
if digest != WINTUN_SHA256:
|
|
39
|
+
raise ValueError(
|
|
40
|
+
"Wintun archive SHA-256 mismatch: expected {}, got {}".format(WINTUN_SHA256, digest)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
member_name = "wintun/bin/{}/wintun.dll".format(architecture)
|
|
44
|
+
with zipfile.ZipFile(io.BytesIO(archive_data)) as archive:
|
|
45
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
destination.write_bytes(archive.read(member_name))
|
|
47
|
+
(destination.parent / "wintun-LICENSE.txt").write_bytes(archive.read("wintun/LICENSE.txt"))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class WintunBuildPy(build_py): # type: ignore[misc]
|
|
51
|
+
def get_outputs(self, include_bytecode: int = 1) -> List[str]:
|
|
52
|
+
outputs = cast(List[str], super().get_outputs(include_bytecode))
|
|
53
|
+
if sys.platform == "win32":
|
|
54
|
+
package_directory = Path(self.build_lib) / "pytap3"
|
|
55
|
+
outputs.extend(
|
|
56
|
+
str(package_directory / filename)
|
|
57
|
+
for filename in ("wintun.dll", "wintun-LICENSE.txt")
|
|
58
|
+
)
|
|
59
|
+
return outputs
|
|
60
|
+
|
|
61
|
+
def run(self) -> None:
|
|
62
|
+
super().run()
|
|
63
|
+
if sys.platform == "win32":
|
|
64
|
+
architecture = get_wintun_architecture()
|
|
65
|
+
destination = Path(self.build_lib) / "pytap3" / "wintun.dll"
|
|
66
|
+
download_wintun(destination, architecture)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class PlatformWheel(bdist_wheel): # type: ignore[misc]
|
|
70
|
+
def finalize_options(self) -> None:
|
|
71
|
+
super().finalize_options()
|
|
72
|
+
self.root_is_pure = False
|
|
73
|
+
|
|
74
|
+
def get_tag(self) -> Tuple[str, str, str]:
|
|
75
|
+
_, _, platform_tag = cast(Tuple[str, str, str], super().get_tag())
|
|
76
|
+
return "py3", "none", platform_tag
|
pytap3-3.0.0/setup.cfg
ADDED
pytap3-3.0.0/setup.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import setuptools
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
9
|
+
|
|
10
|
+
from pytap3_build import PlatformWheel, WintunBuildPy # noqa: E402
|
|
11
|
+
|
|
12
|
+
setuptools.setup(
|
|
13
|
+
name="pytap3",
|
|
14
|
+
version="3.0.0",
|
|
15
|
+
description="Cross-platform tunnel interface for Python",
|
|
16
|
+
long_description=open("README.rst").read(),
|
|
17
|
+
keywords=["pytap", "tun", "tap", "networking"],
|
|
18
|
+
author="Federico Santandrea",
|
|
19
|
+
author_email="federico@santandrea.net",
|
|
20
|
+
url="https://github.com/sntfrc/pytap3",
|
|
21
|
+
packages=setuptools.find_packages("src"),
|
|
22
|
+
package_dir={"": "src"},
|
|
23
|
+
package_data={"pytap3": ["py.typed"]},
|
|
24
|
+
cmdclass={"bdist_wheel": PlatformWheel, "build_py": WintunBuildPy},
|
|
25
|
+
include_package_data=True,
|
|
26
|
+
python_requires=">=3.8",
|
|
27
|
+
zip_safe=False,
|
|
28
|
+
license="MIT",
|
|
29
|
+
classifiers=[
|
|
30
|
+
"Development Status :: 4 - Beta",
|
|
31
|
+
"Intended Audience :: Developers",
|
|
32
|
+
"License :: OSI Approved :: MIT License",
|
|
33
|
+
"Operating System :: POSIX :: Linux",
|
|
34
|
+
"Programming Language :: Python",
|
|
35
|
+
"Programming Language :: Python :: 3",
|
|
36
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
37
|
+
"Programming Language :: Python :: 3.8",
|
|
38
|
+
"Programming Language :: Python :: 3.9",
|
|
39
|
+
"Programming Language :: Python :: 3.10",
|
|
40
|
+
"Programming Language :: Python :: 3.11",
|
|
41
|
+
"Programming Language :: Python :: 3.12",
|
|
42
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
43
|
+
],
|
|
44
|
+
)
|
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
"""A small, packet-oriented wrapper for OS TUN interfaces.
|
|
2
|
+
|
|
3
|
+
Linux uses ``/dev/net/tun``, macOS uses ``utun`` kernel control sockets, and
|
|
4
|
+
Windows uses the Wintun userspace API. Creating/configuring an interface
|
|
5
|
+
normally requires administrator/root privileges.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ctypes
|
|
11
|
+
import ctypes.util
|
|
12
|
+
import errno
|
|
13
|
+
import ipaddress
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import platform
|
|
17
|
+
import socket
|
|
18
|
+
import struct
|
|
19
|
+
import subprocess
|
|
20
|
+
import uuid
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
if os.name != "nt":
|
|
24
|
+
import fcntl
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
IFF_TUN = 0x0001
|
|
28
|
+
IFF_TAP = 0x0002
|
|
29
|
+
IFF_NO_PI = 0x1000
|
|
30
|
+
_IFNAMSIZ = 16
|
|
31
|
+
_DEFAULT_MTU = 1500
|
|
32
|
+
_LOGGER = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TapDeviceError(OSError):
|
|
36
|
+
"""Raised when a TUN interface cannot be created or operated."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _run(*args: str) -> None:
|
|
40
|
+
"""Run an interface configuration command and preserve its error output."""
|
|
41
|
+
try:
|
|
42
|
+
subprocess.run(args, check=True, capture_output=True, text=True)
|
|
43
|
+
except FileNotFoundError as exc:
|
|
44
|
+
raise TapDeviceError(f"Required command not found: {args[0]}") from exc
|
|
45
|
+
except subprocess.CalledProcessError as exc:
|
|
46
|
+
detail = (exc.stderr or exc.stdout or "").strip()
|
|
47
|
+
raise TapDeviceError(detail or f"Command failed: {' '.join(args)}") from exc
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _LinuxBackend:
|
|
51
|
+
TUNSETIFF = 0x400454CA
|
|
52
|
+
|
|
53
|
+
def __init__(self, name: Optional[str], mode: int, no_pi: bool) -> None:
|
|
54
|
+
if mode not in (IFF_TUN, IFF_TAP):
|
|
55
|
+
raise ValueError("mode must be IFF_TUN or IFF_TAP")
|
|
56
|
+
self.fd = os.open("/dev/net/tun", os.O_RDWR)
|
|
57
|
+
flags = mode | (IFF_NO_PI if no_pi else 0)
|
|
58
|
+
request = struct.pack("16sH", (name or "").encode(), flags)
|
|
59
|
+
try:
|
|
60
|
+
result = fcntl.ioctl(self.fd, self.TUNSETIFF, request)
|
|
61
|
+
except Exception:
|
|
62
|
+
os.close(self.fd)
|
|
63
|
+
raise
|
|
64
|
+
self.name = result[:_IFNAMSIZ].split(b"\0", 1)[0].decode()
|
|
65
|
+
self.no_pi = no_pi
|
|
66
|
+
|
|
67
|
+
def read(self, size: int) -> bytes:
|
|
68
|
+
return os.read(self.fd, size)
|
|
69
|
+
|
|
70
|
+
def write(self, packet: bytes) -> int:
|
|
71
|
+
return os.write(self.fd, packet)
|
|
72
|
+
|
|
73
|
+
def fileno(self) -> int:
|
|
74
|
+
return self.fd
|
|
75
|
+
|
|
76
|
+
def close(self) -> None:
|
|
77
|
+
if self.fd is not None:
|
|
78
|
+
os.close(self.fd)
|
|
79
|
+
self.fd = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class _MacOSBackend:
|
|
83
|
+
# Darwin kernel-control socket structures (native byte order).
|
|
84
|
+
_CTL_INFO_SIZE = 96 # uint32 ctl_id + char ctl_name[96]
|
|
85
|
+
_SOCKADDR_CTL = struct.Struct("=BBHII5I")
|
|
86
|
+
_CTLIOCGINFO = 0xC0644E03
|
|
87
|
+
_UTUN_CONTROL_NAME = b"com.apple.net.utun_control"
|
|
88
|
+
_AF_SYSTEM = 32
|
|
89
|
+
_AF_SYS_CONTROL = 2
|
|
90
|
+
_SYSPROTO_CONTROL = 2
|
|
91
|
+
_UTUN_OPT_IFNAME = 2
|
|
92
|
+
|
|
93
|
+
def __init__(self, name: Optional[str], mode: int, no_pi: bool) -> None:
|
|
94
|
+
if mode != IFF_TUN:
|
|
95
|
+
raise ValueError("macOS utun supports layer-3 TUN devices only")
|
|
96
|
+
if name and not name.startswith("utun"):
|
|
97
|
+
name = "utun0"
|
|
98
|
+
requested_unit = None if not name else int(name[4:]) + 1
|
|
99
|
+
unit = 1 if requested_unit is None else requested_unit
|
|
100
|
+
retried = False
|
|
101
|
+
while True:
|
|
102
|
+
interface_name = f"utun{unit - 1}"
|
|
103
|
+
_LOGGER.debug("Creating macOS utun control socket for %s", interface_name)
|
|
104
|
+
try:
|
|
105
|
+
sock = socket.socket(
|
|
106
|
+
getattr(socket, "PF_SYSTEM", 32), socket.SOCK_DGRAM,
|
|
107
|
+
self._SYSPROTO_CONTROL
|
|
108
|
+
)
|
|
109
|
+
except OSError as exc:
|
|
110
|
+
raise TapDeviceError(
|
|
111
|
+
exc.errno,
|
|
112
|
+
f"Creating control socket for {interface_name} failed: "
|
|
113
|
+
f"{exc.strerror or exc}",
|
|
114
|
+
) from exc
|
|
115
|
+
try:
|
|
116
|
+
info = bytearray(self._CTL_INFO_SIZE + 4)
|
|
117
|
+
info[4:4 + len(self._UTUN_CONTROL_NAME)] = self._UTUN_CONTROL_NAME
|
|
118
|
+
fcntl.ioctl(sock.fileno(), self._CTLIOCGINFO, info, True)
|
|
119
|
+
ctl_id = struct.unpack_from("=I", info)[0]
|
|
120
|
+
except OSError as exc:
|
|
121
|
+
sock.close()
|
|
122
|
+
raise TapDeviceError(
|
|
123
|
+
exc.errno,
|
|
124
|
+
f"Looking up the utun kernel control for {interface_name} "
|
|
125
|
+
f"failed: {exc.strerror or exc}",
|
|
126
|
+
) from exc
|
|
127
|
+
except Exception:
|
|
128
|
+
sock.close()
|
|
129
|
+
raise
|
|
130
|
+
try:
|
|
131
|
+
_LOGGER.debug(
|
|
132
|
+
"Connecting macOS utun control socket to unit %d (%s)",
|
|
133
|
+
unit, interface_name,
|
|
134
|
+
)
|
|
135
|
+
sock.connect((ctl_id, unit))
|
|
136
|
+
except OSError as exc:
|
|
137
|
+
sock.close()
|
|
138
|
+
if exc.errno == errno.EBUSY:
|
|
139
|
+
_LOGGER.debug(
|
|
140
|
+
"%s is busy; trying the next macOS utun unit",
|
|
141
|
+
interface_name,
|
|
142
|
+
)
|
|
143
|
+
unit += 1
|
|
144
|
+
retried = True
|
|
145
|
+
continue
|
|
146
|
+
raise TapDeviceError(
|
|
147
|
+
exc.errno,
|
|
148
|
+
f"Connecting to macOS utun unit {unit} ({interface_name}) "
|
|
149
|
+
f"failed: {exc.strerror or exc}",
|
|
150
|
+
) from exc
|
|
151
|
+
except Exception:
|
|
152
|
+
sock.close()
|
|
153
|
+
raise
|
|
154
|
+
self.sock = sock
|
|
155
|
+
break
|
|
156
|
+
|
|
157
|
+
_LOGGER.debug(
|
|
158
|
+
"Reading assigned interface name for macOS utun unit %d", unit
|
|
159
|
+
)
|
|
160
|
+
try:
|
|
161
|
+
self.name = self.sock.getsockopt(
|
|
162
|
+
self._SYSPROTO_CONTROL, self._UTUN_OPT_IFNAME, 64
|
|
163
|
+
).split(b"\0", 1)[0].decode()
|
|
164
|
+
except OSError as exc:
|
|
165
|
+
self.sock.close()
|
|
166
|
+
raise TapDeviceError(
|
|
167
|
+
exc.errno,
|
|
168
|
+
f"Reading the assigned name for macOS utun unit {unit} failed: "
|
|
169
|
+
f"{exc.strerror or exc}",
|
|
170
|
+
) from exc
|
|
171
|
+
except Exception:
|
|
172
|
+
self.sock.close()
|
|
173
|
+
raise
|
|
174
|
+
if name and not retried and name != self.name:
|
|
175
|
+
self.sock.close()
|
|
176
|
+
raise TapDeviceError(
|
|
177
|
+
f"Requested {name}, but macOS assigned {self.name}; "
|
|
178
|
+
"utun names are assigned by the kernel"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def read(self, size: int) -> bytes:
|
|
182
|
+
packet = self.sock.recv(size + 4)
|
|
183
|
+
if len(packet) < 4:
|
|
184
|
+
return b""
|
|
185
|
+
# utun prepends a 32-bit address-family value to every packet.
|
|
186
|
+
return packet[4:]
|
|
187
|
+
|
|
188
|
+
def write(self, packet: bytes) -> int:
|
|
189
|
+
if not packet:
|
|
190
|
+
return self.sock.send(packet)
|
|
191
|
+
version = packet[0] >> 4
|
|
192
|
+
if version == 4:
|
|
193
|
+
family = socket.AF_INET
|
|
194
|
+
elif version == 6:
|
|
195
|
+
family = socket.AF_INET6
|
|
196
|
+
else:
|
|
197
|
+
raise ValueError("utun accepts only IPv4 or IPv6 packets")
|
|
198
|
+
return max(0, self.sock.send(struct.pack("!I", family) + packet) - 4)
|
|
199
|
+
|
|
200
|
+
def fileno(self) -> int:
|
|
201
|
+
return self.sock.fileno()
|
|
202
|
+
|
|
203
|
+
def close(self) -> None:
|
|
204
|
+
self.sock.close()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class _GUID(ctypes.Structure):
|
|
208
|
+
_fields_ = [
|
|
209
|
+
("Data1", ctypes.c_uint32),
|
|
210
|
+
("Data2", ctypes.c_uint16),
|
|
211
|
+
("Data3", ctypes.c_uint16),
|
|
212
|
+
("Data4", ctypes.c_ubyte * 8),
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
@classmethod
|
|
216
|
+
def from_uuid(cls, value: uuid.UUID) -> "_GUID":
|
|
217
|
+
raw = value.bytes_le
|
|
218
|
+
result = cls()
|
|
219
|
+
ctypes.memmove(ctypes.byref(result), raw, 16)
|
|
220
|
+
return result
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class _WindowsBackend:
|
|
224
|
+
ERROR_NO_MORE_ITEMS = 259
|
|
225
|
+
ERROR_BUFFER_OVERFLOW = 111
|
|
226
|
+
WAIT_OBJECT_0 = 0
|
|
227
|
+
INFINITE = 0xFFFFFFFF
|
|
228
|
+
|
|
229
|
+
def __init__(self, name: Optional[str], mode: int, no_pi: bool) -> None:
|
|
230
|
+
if mode != IFF_TUN:
|
|
231
|
+
raise ValueError("Wintun supports layer-3 TUN devices only")
|
|
232
|
+
if not hasattr(ctypes, "WinDLL"):
|
|
233
|
+
raise TapDeviceError("Wintun is available only on Windows")
|
|
234
|
+
self.name = name or "pytap3"
|
|
235
|
+
self._dll = self._load_dll()
|
|
236
|
+
self._bind_api()
|
|
237
|
+
adapter_type = "Wintun"
|
|
238
|
+
adapter_guid = _GUID.from_uuid(
|
|
239
|
+
uuid.uuid5(uuid.NAMESPACE_DNS, "pytap3:" + self.name)
|
|
240
|
+
)
|
|
241
|
+
self.adapter = self._create_adapter(
|
|
242
|
+
self.name, adapter_type, ctypes.byref(adapter_guid)
|
|
243
|
+
)
|
|
244
|
+
if not self.adapter:
|
|
245
|
+
raise ctypes.WinError(ctypes.get_last_error())
|
|
246
|
+
self.session = self._start_session(self.adapter, 0x400000)
|
|
247
|
+
if not self.session:
|
|
248
|
+
error = ctypes.get_last_error()
|
|
249
|
+
self._close_adapter(self.adapter)
|
|
250
|
+
self.adapter = None
|
|
251
|
+
raise ctypes.WinError(error)
|
|
252
|
+
self._wait_event = self._get_read_wait_event(self.session)
|
|
253
|
+
if not self._wait_event:
|
|
254
|
+
self.close()
|
|
255
|
+
raise ctypes.WinError(ctypes.get_last_error())
|
|
256
|
+
self._kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
257
|
+
self._kernel32.WaitForSingleObject.argtypes = [ctypes.c_void_p, ctypes.c_uint32]
|
|
258
|
+
self._kernel32.WaitForSingleObject.restype = ctypes.c_uint32
|
|
259
|
+
|
|
260
|
+
@staticmethod
|
|
261
|
+
def _load_dll():
|
|
262
|
+
candidates = []
|
|
263
|
+
bundled = os.path.join(os.path.dirname(__file__), "wintun.dll")
|
|
264
|
+
if os.path.isfile(bundled):
|
|
265
|
+
candidates.append(bundled)
|
|
266
|
+
found = ctypes.util.find_library("wintun")
|
|
267
|
+
if found:
|
|
268
|
+
candidates.append(found)
|
|
269
|
+
candidates.append("wintun.dll")
|
|
270
|
+
errors = []
|
|
271
|
+
for candidate in candidates:
|
|
272
|
+
try:
|
|
273
|
+
return ctypes.WinDLL(candidate, use_last_error=True)
|
|
274
|
+
except OSError as exc:
|
|
275
|
+
errors.append(str(exc))
|
|
276
|
+
raise TapDeviceError(
|
|
277
|
+
"Could not load wintun.dll. Place the architecture-matched, "
|
|
278
|
+
"signed Wintun DLL beside this module or on PATH. " + "; ".join(errors)
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def _bind_api(self) -> None:
|
|
282
|
+
pointer = ctypes.c_void_p
|
|
283
|
+
self._create_adapter = self._dll.WintunCreateAdapter
|
|
284
|
+
self._create_adapter.argtypes = [ctypes.c_wchar_p, ctypes.c_wchar_p,
|
|
285
|
+
ctypes.POINTER(_GUID)]
|
|
286
|
+
self._create_adapter.restype = pointer
|
|
287
|
+
self._start_session = self._dll.WintunStartSession
|
|
288
|
+
self._start_session.argtypes = [pointer, ctypes.c_uint32]
|
|
289
|
+
self._start_session.restype = pointer
|
|
290
|
+
self._end_session = self._dll.WintunEndSession
|
|
291
|
+
self._end_session.argtypes = [pointer]
|
|
292
|
+
self._end_session.restype = None
|
|
293
|
+
self._close_adapter = self._dll.WintunCloseAdapter
|
|
294
|
+
self._close_adapter.argtypes = [pointer]
|
|
295
|
+
self._close_adapter.restype = None
|
|
296
|
+
self._receive_packet = self._dll.WintunReceivePacket
|
|
297
|
+
self._receive_packet.argtypes = [pointer, ctypes.POINTER(ctypes.c_uint32)]
|
|
298
|
+
self._receive_packet.restype = pointer
|
|
299
|
+
self._release_packet = self._dll.WintunReleaseReceivePacket
|
|
300
|
+
self._release_packet.argtypes = [pointer, pointer]
|
|
301
|
+
self._release_packet.restype = None
|
|
302
|
+
self._allocate_packet = self._dll.WintunAllocateSendPacket
|
|
303
|
+
self._allocate_packet.argtypes = [pointer, ctypes.c_uint32]
|
|
304
|
+
self._allocate_packet.restype = pointer
|
|
305
|
+
self._send_packet = self._dll.WintunSendPacket
|
|
306
|
+
self._send_packet.argtypes = [pointer, pointer]
|
|
307
|
+
self._send_packet.restype = None
|
|
308
|
+
self._get_read_wait_event = self._dll.WintunGetReadWaitEvent
|
|
309
|
+
self._get_read_wait_event.argtypes = [pointer]
|
|
310
|
+
self._get_read_wait_event.restype = pointer
|
|
311
|
+
|
|
312
|
+
def read(self, size: int) -> bytes:
|
|
313
|
+
while True:
|
|
314
|
+
packet_size = ctypes.c_uint32()
|
|
315
|
+
packet = self._receive_packet(self.session, ctypes.byref(packet_size))
|
|
316
|
+
if packet:
|
|
317
|
+
try:
|
|
318
|
+
return ctypes.string_at(packet, min(size, packet_size.value))
|
|
319
|
+
finally:
|
|
320
|
+
self._release_packet(self.session, packet)
|
|
321
|
+
error = ctypes.get_last_error()
|
|
322
|
+
if error != self.ERROR_NO_MORE_ITEMS:
|
|
323
|
+
raise ctypes.WinError(error)
|
|
324
|
+
result = self._kernel32.WaitForSingleObject(self._wait_event, self.INFINITE)
|
|
325
|
+
if result != self.WAIT_OBJECT_0:
|
|
326
|
+
raise ctypes.WinError(ctypes.get_last_error())
|
|
327
|
+
|
|
328
|
+
def write(self, packet: bytes) -> int:
|
|
329
|
+
ptr = self._allocate_packet(self.session, len(packet))
|
|
330
|
+
if not ptr:
|
|
331
|
+
error = ctypes.get_last_error()
|
|
332
|
+
if error == self.ERROR_BUFFER_OVERFLOW:
|
|
333
|
+
return 0
|
|
334
|
+
raise ctypes.WinError(error)
|
|
335
|
+
if packet:
|
|
336
|
+
ctypes.memmove(ptr, packet, len(packet))
|
|
337
|
+
self._send_packet(self.session, ptr)
|
|
338
|
+
return len(packet)
|
|
339
|
+
|
|
340
|
+
def fileno(self) -> int:
|
|
341
|
+
# This is a Windows event HANDLE. It is waitable with Win32 wait APIs,
|
|
342
|
+
# but is not a POSIX descriptor and cannot be passed to select.select().
|
|
343
|
+
return int(self._wait_event)
|
|
344
|
+
|
|
345
|
+
def close(self) -> None:
|
|
346
|
+
if getattr(self, "session", None):
|
|
347
|
+
self._end_session(self.session)
|
|
348
|
+
self.session = None
|
|
349
|
+
if getattr(self, "adapter", None):
|
|
350
|
+
self._close_adapter(self.adapter)
|
|
351
|
+
self.adapter = None
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
class TapDevice:
|
|
355
|
+
"""Create and use a packet-oriented TUN device.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
name: Preferred interface name. macOS assigns ``utunN`` names itself
|
|
359
|
+
and tries a later unit if the requested unit is busy.
|
|
360
|
+
mode: ``IFF_TUN`` (default) or Linux-only ``IFF_TAP``.
|
|
361
|
+
no_pi: Linux-only; omit the two-byte packet-information header.
|
|
362
|
+
mtu: MTU to set when the interface is brought up.
|
|
363
|
+
|
|
364
|
+
``read`` and ``write`` exchange IP packets without platform headers.
|
|
365
|
+
"""
|
|
366
|
+
|
|
367
|
+
def __init__(self, name: Optional[str] = None, mode: int = IFF_TUN,
|
|
368
|
+
no_pi: bool = True, mtu: int = _DEFAULT_MTU) -> None:
|
|
369
|
+
self.mtu = mtu
|
|
370
|
+
self.mode = mode
|
|
371
|
+
self.no_pi = no_pi
|
|
372
|
+
self._platform = platform.system()
|
|
373
|
+
if self._platform == "Linux":
|
|
374
|
+
self._backend = _LinuxBackend(name, mode, no_pi)
|
|
375
|
+
elif self._platform == "Darwin":
|
|
376
|
+
self._backend = _MacOSBackend(name, mode, no_pi)
|
|
377
|
+
elif self._platform == "Windows":
|
|
378
|
+
self._backend = _WindowsBackend(name, mode, no_pi)
|
|
379
|
+
else:
|
|
380
|
+
raise TapDeviceError(f"Unsupported operating system: {self._platform}")
|
|
381
|
+
self.name = self._backend.name
|
|
382
|
+
self._closed = False
|
|
383
|
+
|
|
384
|
+
def fileno(self) -> int:
|
|
385
|
+
"""Return the POSIX file descriptor or Windows Wintun wait-event handle."""
|
|
386
|
+
return self._backend.fileno()
|
|
387
|
+
|
|
388
|
+
@property
|
|
389
|
+
def fd(self) -> int:
|
|
390
|
+
"""Compatibility alias for ``fileno()``."""
|
|
391
|
+
return self.fileno()
|
|
392
|
+
|
|
393
|
+
def read(self, size: Optional[int] = None) -> bytes:
|
|
394
|
+
"""Read one packet; defaults to one MTU of payload."""
|
|
395
|
+
if self._closed:
|
|
396
|
+
raise ValueError("I/O operation on closed TUN device")
|
|
397
|
+
if size is None:
|
|
398
|
+
size = self.mtu
|
|
399
|
+
if self._platform == "Linux" and not self.no_pi:
|
|
400
|
+
size += 4
|
|
401
|
+
return self._backend.read(size)
|
|
402
|
+
|
|
403
|
+
def write(self, packet: bytes) -> int:
|
|
404
|
+
"""Write one complete IP packet to the interface."""
|
|
405
|
+
if self._closed:
|
|
406
|
+
raise ValueError("I/O operation on closed TUN device")
|
|
407
|
+
if not isinstance(packet, (bytes, bytearray, memoryview)):
|
|
408
|
+
raise TypeError("packet must be bytes-like")
|
|
409
|
+
return self._backend.write(bytes(packet))
|
|
410
|
+
|
|
411
|
+
def up(self) -> None:
|
|
412
|
+
"""Bring the interface up and apply the configured MTU."""
|
|
413
|
+
if self._platform == "Windows":
|
|
414
|
+
_run("netsh", "interface", "set", "interface", f"name={self.name}",
|
|
415
|
+
"admin=enabled")
|
|
416
|
+
_run("netsh", "interface", "ipv4", "set", "subinterface",
|
|
417
|
+
self.name, f"mtu={self.mtu}", "store=active")
|
|
418
|
+
else:
|
|
419
|
+
_run("ifconfig", self.name, "mtu", str(self.mtu), "up")
|
|
420
|
+
|
|
421
|
+
@staticmethod
|
|
422
|
+
def _mac_peer(address: str, netmask: str) -> str:
|
|
423
|
+
ip = ipaddress.ip_address(address)
|
|
424
|
+
if ip.version == 4:
|
|
425
|
+
network = ipaddress.ip_network(f"{address}/{netmask}", strict=False)
|
|
426
|
+
else:
|
|
427
|
+
network = ipaddress.ip_network(f"{address}/{netmask}", strict=False)
|
|
428
|
+
for candidate in network.hosts():
|
|
429
|
+
if candidate != ip:
|
|
430
|
+
return str(candidate)
|
|
431
|
+
raise ValueError(f"No other usable address in {network} for utun peer")
|
|
432
|
+
|
|
433
|
+
def ifconfig(self, ip: Optional[str] = None, netmask: Optional[str] = None,
|
|
434
|
+
mtu: Optional[int] = None, peer: Optional[str] = None,
|
|
435
|
+
address: Optional[str] = None) -> None:
|
|
436
|
+
"""Configure interface addressing and/or MTU.
|
|
437
|
+
|
|
438
|
+
For macOS, ``peer`` defaults to the first usable address in the given
|
|
439
|
+
subnet other than ``ip``. This supplies utun's required point-to-point
|
|
440
|
+
destination address. It is a deterministic placeholder, not a probe of
|
|
441
|
+
address use on the network. The subnet route implied by ``netmask`` is
|
|
442
|
+
also installed through the utun interface.
|
|
443
|
+
"""
|
|
444
|
+
if address is not None:
|
|
445
|
+
if ip is not None and ip != address:
|
|
446
|
+
raise ValueError("pass either ip or address, not conflicting values")
|
|
447
|
+
ip = address
|
|
448
|
+
target_mtu = mtu if mtu is not None else self.mtu
|
|
449
|
+
if self._platform == "Linux":
|
|
450
|
+
args = ["ifconfig", self.name]
|
|
451
|
+
if ip:
|
|
452
|
+
args.append(ip)
|
|
453
|
+
if netmask:
|
|
454
|
+
args.extend(["netmask", netmask])
|
|
455
|
+
if target_mtu:
|
|
456
|
+
args.extend(["mtu", str(target_mtu)])
|
|
457
|
+
_run(*args)
|
|
458
|
+
elif self._platform == "Darwin":
|
|
459
|
+
if ip:
|
|
460
|
+
if not netmask:
|
|
461
|
+
raise ValueError("netmask is required when configuring an IP")
|
|
462
|
+
chosen_peer = peer or self._mac_peer(ip, netmask)
|
|
463
|
+
network = ipaddress.ip_network(f"{ip}/{netmask}", strict=False)
|
|
464
|
+
args = ["ifconfig", self.name, "inet", ip, chosen_peer,
|
|
465
|
+
"netmask", netmask]
|
|
466
|
+
else:
|
|
467
|
+
args = ["ifconfig", self.name]
|
|
468
|
+
if target_mtu:
|
|
469
|
+
args.extend(["mtu", str(target_mtu)])
|
|
470
|
+
_run(*args)
|
|
471
|
+
if ip:
|
|
472
|
+
_run("route", "-n", "add", "-net", str(network), ip)
|
|
473
|
+
else:
|
|
474
|
+
if ip:
|
|
475
|
+
if not netmask:
|
|
476
|
+
raise ValueError("netmask is required when configuring an IP")
|
|
477
|
+
_run("netsh", "interface", "ipv4", "set", "address",
|
|
478
|
+
f"name={self.name}", "source=static", f"address={ip}",
|
|
479
|
+
f"mask={netmask}")
|
|
480
|
+
if target_mtu:
|
|
481
|
+
_run("netsh", "interface", "ipv4", "set", "subinterface",
|
|
482
|
+
self.name, f"mtu={target_mtu}", "store=active")
|
|
483
|
+
if mtu is not None:
|
|
484
|
+
self.mtu = mtu
|
|
485
|
+
|
|
486
|
+
def close(self) -> None:
|
|
487
|
+
if not self._closed:
|
|
488
|
+
self._backend.close()
|
|
489
|
+
self._closed = True
|
|
490
|
+
|
|
491
|
+
def __enter__(self) -> "TapDevice":
|
|
492
|
+
self.up()
|
|
493
|
+
return self
|
|
494
|
+
|
|
495
|
+
def __exit__(self, exc_type, exc_value, traceback) -> None:
|
|
496
|
+
self.close()
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
__all__ = ["TapDevice", "TapDeviceError", "IFF_TUN", "IFF_TAP", "IFF_NO_PI"]
|
|
File without changes
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pytap3
|
|
3
|
+
Version: 3.0.0
|
|
4
|
+
Summary: Cross-platform tunnel interface for Python
|
|
5
|
+
Home-page: https://github.com/sntfrc/pytap3
|
|
6
|
+
Author: Federico Santandrea
|
|
7
|
+
Author-email: federico@santandrea.net
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: pytap,tun,tap,networking
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
14
|
+
Classifier: Programming Language :: Python
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Requires-Python: >=3.8
|
|
24
|
+
License-File: LICENSE.txt
|
|
25
|
+
Dynamic: author
|
|
26
|
+
Dynamic: author-email
|
|
27
|
+
Dynamic: classifier
|
|
28
|
+
Dynamic: description
|
|
29
|
+
Dynamic: home-page
|
|
30
|
+
Dynamic: keywords
|
|
31
|
+
Dynamic: license
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
Dynamic: requires-python
|
|
34
|
+
Dynamic: summary
|
|
35
|
+
|
|
36
|
+
``pytap3`` - Cross-platform tunnel interface for Python
|
|
37
|
+
=======================================================
|
|
38
|
+
|
|
39
|
+
This is a cross-platform fork of the original
|
|
40
|
+
`johnthagen/pytap2 <https://github.com/johnthagen/pytap2>`_. It provides a
|
|
41
|
+
packet-oriented interface to TUN devices on Linux, macOS, and Windows.
|
|
42
|
+
|
|
43
|
+
Requirements
|
|
44
|
+
------------
|
|
45
|
+
|
|
46
|
+
Requirements depend on your operating system:
|
|
47
|
+
|
|
48
|
+
* **Linux:** Install ``net-tools`` to provide the ``ifconfig`` command.
|
|
49
|
+
|
|
50
|
+
On Debian/Ubuntu:
|
|
51
|
+
|
|
52
|
+
.. code:: shell-session
|
|
53
|
+
|
|
54
|
+
$ sudo apt install net-tools
|
|
55
|
+
|
|
56
|
+
On RHEL/Rocky Linux/Fedora:
|
|
57
|
+
|
|
58
|
+
.. code:: shell-session
|
|
59
|
+
|
|
60
|
+
$ sudo yum install net-tools
|
|
61
|
+
|
|
62
|
+
* **macOS:** No additional packages are required; ``ifconfig`` is included
|
|
63
|
+
with macOS.
|
|
64
|
+
|
|
65
|
+
* **Windows:** ``wintun.dll`` is required. When building/installing from source
|
|
66
|
+
on Windows, the package downloads the signed Wintun 0.14.1 archive from
|
|
67
|
+
`Wintun <https://www.wintun.net/>`_, verifies its SHA-256 checksum, and bundles
|
|
68
|
+
the DLL matching the Python platform. This requires network access during
|
|
69
|
+
installation. Wheels are platform-tagged; build Windows wheels on Windows to
|
|
70
|
+
include the DLL. Source distributions build locally on any supported OS.
|
|
71
|
+
|
|
72
|
+
Creating and configuring a TUN device may require administrator or root
|
|
73
|
+
privileges.
|
|
74
|
+
|
|
75
|
+
Installation
|
|
76
|
+
------------
|
|
77
|
+
|
|
78
|
+
You can install, upgrade, and uninstall ``pytap3`` with these commands:
|
|
79
|
+
|
|
80
|
+
.. code:: shell-session
|
|
81
|
+
|
|
82
|
+
$ pip install pytap3
|
|
83
|
+
$ pip install --upgrade pytap3
|
|
84
|
+
$ pip uninstall pytap3
|
|
85
|
+
|
|
86
|
+
Usage
|
|
87
|
+
-----
|
|
88
|
+
|
|
89
|
+
Using as a context manager automatically brings up the device and closes it at the
|
|
90
|
+
end of the ``with`` block.
|
|
91
|
+
|
|
92
|
+
.. code:: python
|
|
93
|
+
|
|
94
|
+
from pytap3 import TapDevice
|
|
95
|
+
|
|
96
|
+
with TapDevice() as device:
|
|
97
|
+
device.ifconfig(mtu=1300)
|
|
98
|
+
device.write(b'0000')
|
|
99
|
+
|
|
100
|
+
Or manually call ``up()`` and ``close()``.
|
|
101
|
+
|
|
102
|
+
.. code:: python
|
|
103
|
+
|
|
104
|
+
from pytap3 import TapDevice
|
|
105
|
+
|
|
106
|
+
device = TapDevice()
|
|
107
|
+
device.up()
|
|
108
|
+
device.ifconfig(mtu=1300)
|
|
109
|
+
device.write(b'0000')
|
|
110
|
+
device.close()
|
|
111
|
+
|
|
112
|
+
The ``fileno()`` method is defined, so that the device object can be passed directly
|
|
113
|
+
to `select() <https://docs.python.org/library/select.html#select.select>`_. This might
|
|
114
|
+
not work on Windows, as the underlying Wintun driver does not support this.
|
|
115
|
+
|
|
116
|
+
On macOS, configuring an IP address with ``ifconfig(ip=..., netmask=...)`` also
|
|
117
|
+
adds a route for that subnet through the ``utun`` interface. Routes that conflict
|
|
118
|
+
with an existing route may require administrator privileges or manual adjustment.
|
|
119
|
+
|
|
120
|
+
You can make pytap3 a drop-in replacement for pytap2 by just importing it like this:
|
|
121
|
+
``import pytap3 as pytap2``
|
|
122
|
+
|
|
123
|
+
Releases
|
|
124
|
+
--------
|
|
125
|
+
|
|
126
|
+
3.0.0 - 2024-06-05
|
|
127
|
+
^^^^^^^^^^^^^^^^^^
|
|
128
|
+
|
|
129
|
+
- Windows, Linux and macOS support!
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
LICENSE.txt
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
README.rst
|
|
4
|
+
pyproject.toml
|
|
5
|
+
pytap3_build.py
|
|
6
|
+
setup.py
|
|
7
|
+
src/pytap3/__init__.py
|
|
8
|
+
src/pytap3/py.typed
|
|
9
|
+
src/pytap3.egg-info/PKG-INFO
|
|
10
|
+
src/pytap3.egg-info/SOURCES.txt
|
|
11
|
+
src/pytap3.egg-info/dependency_links.txt
|
|
12
|
+
src/pytap3.egg-info/not-zip-safe
|
|
13
|
+
src/pytap3.egg-info/top_level.txt
|
|
14
|
+
tests/test_pytap3.py
|
|
15
|
+
tests/test_pytap3_build.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pytap3
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import errno
|
|
2
|
+
import os
|
|
3
|
+
import socket
|
|
4
|
+
import struct
|
|
5
|
+
import unittest
|
|
6
|
+
from unittest.mock import Mock, call, patch
|
|
7
|
+
|
|
8
|
+
from pytap3 import IFF_TUN, TapDevice, _MacOSBackend
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestCase(unittest.TestCase):
|
|
12
|
+
def test(self) -> None:
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@unittest.skipIf(os.name == "nt", "the macOS backend requires fcntl")
|
|
17
|
+
class MacOSBackendTestCase(unittest.TestCase):
|
|
18
|
+
def test_write_encodes_address_family_in_network_byte_order(self) -> None:
|
|
19
|
+
backend = _MacOSBackend.__new__(_MacOSBackend)
|
|
20
|
+
backend.sock = Mock()
|
|
21
|
+
packet = b"\x45\x00"
|
|
22
|
+
frame = struct.pack("!I", socket.AF_INET) + packet
|
|
23
|
+
backend.sock.send.return_value = len(frame)
|
|
24
|
+
|
|
25
|
+
self.assertEqual(backend.write(packet), len(packet))
|
|
26
|
+
backend.sock.send.assert_called_once_with(frame)
|
|
27
|
+
|
|
28
|
+
@patch("pytap3.fcntl.ioctl")
|
|
29
|
+
@patch("pytap3.socket.socket")
|
|
30
|
+
def test_automatic_name_uses_next_available_unit(
|
|
31
|
+
self, socket_factory: Mock, _ioctl: Mock
|
|
32
|
+
) -> None:
|
|
33
|
+
busy_socket = Mock()
|
|
34
|
+
busy_socket.connect.side_effect = OSError(errno.EBUSY, "resource busy")
|
|
35
|
+
available_socket = Mock()
|
|
36
|
+
available_socket.getsockopt.return_value = b"utun1\0"
|
|
37
|
+
socket_factory.side_effect = [busy_socket, available_socket]
|
|
38
|
+
|
|
39
|
+
backend = _MacOSBackend(None, IFF_TUN, True)
|
|
40
|
+
|
|
41
|
+
self.assertEqual(backend.name, "utun1")
|
|
42
|
+
busy_socket.connect.assert_called_once_with((0, 1))
|
|
43
|
+
available_socket.connect.assert_called_once_with((0, 2))
|
|
44
|
+
busy_socket.close.assert_called_once_with()
|
|
45
|
+
self.assertIs(backend.sock, available_socket)
|
|
46
|
+
|
|
47
|
+
@patch("pytap3.fcntl.ioctl")
|
|
48
|
+
@patch("pytap3.socket.socket")
|
|
49
|
+
def test_requested_name_uses_exact_unit(
|
|
50
|
+
self, socket_factory: Mock, _ioctl: Mock
|
|
51
|
+
) -> None:
|
|
52
|
+
requested_socket = Mock()
|
|
53
|
+
requested_socket.getsockopt.return_value = b"utun4\0"
|
|
54
|
+
socket_factory.return_value = requested_socket
|
|
55
|
+
|
|
56
|
+
backend = _MacOSBackend("utun4", IFF_TUN, True)
|
|
57
|
+
|
|
58
|
+
self.assertEqual(backend.name, "utun4")
|
|
59
|
+
requested_socket.connect.assert_called_once_with((0, 5))
|
|
60
|
+
self.assertIs(backend.sock, requested_socket)
|
|
61
|
+
|
|
62
|
+
@patch("pytap3.fcntl.ioctl")
|
|
63
|
+
@patch("pytap3.socket.socket")
|
|
64
|
+
def test_requested_busy_unit_retries_and_uses_assigned_name(
|
|
65
|
+
self, socket_factory: Mock, _ioctl: Mock
|
|
66
|
+
) -> None:
|
|
67
|
+
busy_socket = Mock()
|
|
68
|
+
busy_socket.connect.side_effect = OSError(errno.EBUSY, "Resource busy")
|
|
69
|
+
available_socket = Mock()
|
|
70
|
+
available_socket.getsockopt.return_value = b"utun5\0"
|
|
71
|
+
socket_factory.side_effect = [busy_socket, available_socket]
|
|
72
|
+
|
|
73
|
+
backend = _MacOSBackend("utun4", IFF_TUN, True)
|
|
74
|
+
|
|
75
|
+
self.assertEqual(backend.name, "utun5")
|
|
76
|
+
busy_socket.connect.assert_called_once_with((0, 5))
|
|
77
|
+
available_socket.connect.assert_called_once_with((0, 6))
|
|
78
|
+
busy_socket.close.assert_called_once_with()
|
|
79
|
+
self.assertIs(backend.sock, available_socket)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class MacOSRouteTestCase(unittest.TestCase):
|
|
83
|
+
@patch("pytap3._run")
|
|
84
|
+
def test_ifconfig_adds_route_for_configured_subnet(self, run: Mock) -> None:
|
|
85
|
+
device = TapDevice.__new__(TapDevice)
|
|
86
|
+
device._platform = "Darwin"
|
|
87
|
+
device.name = "utun3"
|
|
88
|
+
device.mtu = 1500
|
|
89
|
+
|
|
90
|
+
device.ifconfig(ip="10.0.13.7", netmask="255.255.240.0")
|
|
91
|
+
|
|
92
|
+
self.assertEqual(
|
|
93
|
+
run.call_args_list,
|
|
94
|
+
[
|
|
95
|
+
call(
|
|
96
|
+
"ifconfig", "utun3", "inet", "10.0.13.7", "10.0.0.1",
|
|
97
|
+
"netmask", "255.255.240.0", "mtu", "1500",
|
|
98
|
+
),
|
|
99
|
+
call(
|
|
100
|
+
"route", "-n", "add", "-net", "10.0.0.0/20",
|
|
101
|
+
"-interface", "utun3",
|
|
102
|
+
),
|
|
103
|
+
],
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
@patch("pytap3._run")
|
|
107
|
+
def test_ifconfig_without_address_does_not_add_route(self, run: Mock) -> None:
|
|
108
|
+
device = TapDevice.__new__(TapDevice)
|
|
109
|
+
device._platform = "Darwin"
|
|
110
|
+
device.name = "utun3"
|
|
111
|
+
device.mtu = 1500
|
|
112
|
+
|
|
113
|
+
device.ifconfig(mtu=1400)
|
|
114
|
+
|
|
115
|
+
run.assert_called_once_with("ifconfig", "utun3", "mtu", "1400")
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import io
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
from unittest.mock import patch
|
|
7
|
+
import zipfile
|
|
8
|
+
|
|
9
|
+
import pytap3_build
|
|
10
|
+
from setuptools import Distribution
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _wintun_archive() -> bytes:
|
|
14
|
+
buffer = io.BytesIO()
|
|
15
|
+
with zipfile.ZipFile(buffer, "w") as archive:
|
|
16
|
+
archive.writestr("wintun/LICENSE.txt", "Wintun license")
|
|
17
|
+
for architecture in ("x86", "amd64", "arm", "arm64"):
|
|
18
|
+
archive.writestr("wintun/bin/{}/wintun.dll".format(architecture), architecture)
|
|
19
|
+
return buffer.getvalue()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class WintunBuildTestCase(unittest.TestCase):
|
|
23
|
+
def test_architecture_matches_windows_python_platform(self) -> None:
|
|
24
|
+
for platform_name, expected in (
|
|
25
|
+
("win32", "x86"),
|
|
26
|
+
("win-amd64", "amd64"),
|
|
27
|
+
("win-arm32", "arm"),
|
|
28
|
+
("win-arm64", "arm64"),
|
|
29
|
+
):
|
|
30
|
+
with self.subTest(platform=platform_name):
|
|
31
|
+
with patch(
|
|
32
|
+
"pytap3_build.sysconfig.get_platform",
|
|
33
|
+
return_value=platform_name,
|
|
34
|
+
):
|
|
35
|
+
self.assertEqual(pytap3_build.get_wintun_architecture(), expected)
|
|
36
|
+
|
|
37
|
+
def test_unsupported_platform_is_reported(self) -> None:
|
|
38
|
+
with patch("pytap3_build.sysconfig.get_platform", return_value="win-unknown"):
|
|
39
|
+
with self.assertRaisesRegex(RuntimeError, "win-unknown"):
|
|
40
|
+
pytap3_build.get_wintun_architecture()
|
|
41
|
+
|
|
42
|
+
def test_download_extracts_matching_dll_and_license(self) -> None:
|
|
43
|
+
archive_data = _wintun_archive()
|
|
44
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
45
|
+
destination = Path(directory) / "pytap3" / "wintun.dll"
|
|
46
|
+
with patch(
|
|
47
|
+
"pytap3_build.urllib.request.urlopen",
|
|
48
|
+
return_value=io.BytesIO(archive_data),
|
|
49
|
+
) as urlopen:
|
|
50
|
+
with patch.object(
|
|
51
|
+
pytap3_build,
|
|
52
|
+
"WINTUN_SHA256",
|
|
53
|
+
hashlib.sha256(archive_data).hexdigest(),
|
|
54
|
+
):
|
|
55
|
+
pytap3_build.download_wintun(destination, "arm64")
|
|
56
|
+
|
|
57
|
+
urlopen.assert_called_once_with(pytap3_build.WINTUN_URL, timeout=30)
|
|
58
|
+
self.assertEqual(destination.read_bytes(), b"arm64")
|
|
59
|
+
self.assertEqual(
|
|
60
|
+
(destination.parent / "wintun-LICENSE.txt").read_bytes(),
|
|
61
|
+
b"Wintun license",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def test_download_rejects_unexpected_archive(self) -> None:
|
|
65
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
66
|
+
destination = Path(directory) / "pytap3" / "wintun.dll"
|
|
67
|
+
with patch(
|
|
68
|
+
"pytap3_build.urllib.request.urlopen",
|
|
69
|
+
return_value=io.BytesIO(b"not the official archive"),
|
|
70
|
+
):
|
|
71
|
+
with self.assertRaisesRegex(ValueError, "SHA-256 mismatch"):
|
|
72
|
+
pytap3_build.download_wintun(destination, "amd64")
|
|
73
|
+
self.assertFalse(destination.exists())
|
|
74
|
+
|
|
75
|
+
def test_windows_build_includes_downloaded_files_as_outputs(self) -> None:
|
|
76
|
+
command = pytap3_build.WintunBuildPy(Distribution({"packages": []}))
|
|
77
|
+
command.ensure_finalized()
|
|
78
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
79
|
+
command.build_lib = directory
|
|
80
|
+
with patch("pytap3_build.sys.platform", "win32"):
|
|
81
|
+
with patch("pytap3_build.build_py.run"):
|
|
82
|
+
with patch("pytap3_build.get_wintun_architecture", return_value="amd64"):
|
|
83
|
+
with patch("pytap3_build.download_wintun") as download:
|
|
84
|
+
command.run()
|
|
85
|
+
download.assert_called_once_with(
|
|
86
|
+
Path(directory) / "pytap3" / "wintun.dll", "amd64"
|
|
87
|
+
)
|
|
88
|
+
outputs = command.get_outputs()
|
|
89
|
+
self.assertIn(
|
|
90
|
+
str(Path(directory) / "pytap3" / "wintun.dll"),
|
|
91
|
+
outputs,
|
|
92
|
+
)
|
|
93
|
+
self.assertIn(
|
|
94
|
+
str(Path(directory) / "pytap3" / "wintun-LICENSE.txt"),
|
|
95
|
+
outputs,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def test_non_windows_build_does_not_download_wintun(self) -> None:
|
|
99
|
+
command = pytap3_build.WintunBuildPy(Distribution({"packages": []}))
|
|
100
|
+
command.ensure_finalized()
|
|
101
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
102
|
+
command.build_lib = directory
|
|
103
|
+
for platform_name in ("linux", "darwin"):
|
|
104
|
+
with self.subTest(platform=platform_name):
|
|
105
|
+
with patch("pytap3_build.sys.platform", platform_name):
|
|
106
|
+
with patch("pytap3_build.build_py.run"):
|
|
107
|
+
with patch("pytap3_build.download_wintun") as download:
|
|
108
|
+
command.run()
|
|
109
|
+
download.assert_not_called()
|
|
110
|
+
|
|
111
|
+
def test_wheel_uses_platform_tag_without_python_abi_tag(self) -> None:
|
|
112
|
+
command = pytap3_build.PlatformWheel(Distribution())
|
|
113
|
+
with patch("pytap3_build.bdist_wheel.finalize_options"):
|
|
114
|
+
command.finalize_options()
|
|
115
|
+
self.assertFalse(command.root_is_pure)
|
|
116
|
+
|
|
117
|
+
with patch(
|
|
118
|
+
"pytap3_build.bdist_wheel.get_tag",
|
|
119
|
+
return_value=("cp315", "cp315", "win_amd64"),
|
|
120
|
+
):
|
|
121
|
+
self.assertEqual(command.get_tag(), ("py3", "none", "win_amd64"))
|