websocket-tunnel 0.1.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.
- websocket_tunnel-0.1.0/.github/workflows/release.yml +51 -0
- websocket_tunnel-0.1.0/.gitignore +8 -0
- websocket_tunnel-0.1.0/LICENSE +21 -0
- websocket_tunnel-0.1.0/PKG-INFO +145 -0
- websocket_tunnel-0.1.0/README.md +135 -0
- websocket_tunnel-0.1.0/examples/client.toml +28 -0
- websocket_tunnel-0.1.0/examples/server.toml +27 -0
- websocket_tunnel-0.1.0/pyproject.toml +32 -0
- websocket_tunnel-0.1.0/src/websocket_tunnel/__init__.py +3 -0
- websocket_tunnel-0.1.0/src/websocket_tunnel/cli.py +81 -0
- websocket_tunnel-0.1.0/src/websocket_tunnel/client.py +115 -0
- websocket_tunnel-0.1.0/src/websocket_tunnel/config.py +173 -0
- websocket_tunnel-0.1.0/src/websocket_tunnel/protocol.py +78 -0
- websocket_tunnel-0.1.0/src/websocket_tunnel/server.py +81 -0
- websocket_tunnel-0.1.0/src/websocket_tunnel/session.py +665 -0
- websocket_tunnel-0.1.0/tests/test_config.py +101 -0
- websocket_tunnel-0.1.0/tests/test_e2e.py +476 -0
- websocket_tunnel-0.1.0/tests/test_protocol.py +43 -0
- websocket_tunnel-0.1.0/tests/test_session.py +45 -0
- websocket_tunnel-0.1.0/uv.lock +411 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
workflow_dispatch: # allow manual runs for testing the pipeline
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
# Trusted publishing (OIDC) needs the id-token permission. If you prefer an
|
|
12
|
+
# API token instead, remove this line, add the PYPI_TOKEN secret and pass it
|
|
13
|
+
# to the publish step via env: UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}.
|
|
14
|
+
id-token: write
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
publish:
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
steps:
|
|
20
|
+
- name: Checkout
|
|
21
|
+
uses: actions/checkout@v4
|
|
22
|
+
|
|
23
|
+
- name: Set up Python
|
|
24
|
+
uses: actions/setup-python@v5
|
|
25
|
+
with:
|
|
26
|
+
python-version: "3.12"
|
|
27
|
+
|
|
28
|
+
- name: Install uv
|
|
29
|
+
uses: astral-sh/setup-uv@v5
|
|
30
|
+
with:
|
|
31
|
+
enable-cache: true
|
|
32
|
+
|
|
33
|
+
- name: Verify tag matches package version
|
|
34
|
+
if: startsWith(github.ref, 'refs/tags/')
|
|
35
|
+
run: |
|
|
36
|
+
tag="${GITHUB_REF_NAME#v}"
|
|
37
|
+
version="$(uv run python -c 'from websocket_tunnel import __version__; print(__version__)')"
|
|
38
|
+
if [ "$tag" != "$version" ]; then
|
|
39
|
+
echo "::error::tag v${tag} does not match package version ${version}"
|
|
40
|
+
exit 1
|
|
41
|
+
fi
|
|
42
|
+
echo "releasing websocket-tunnel ${version}"
|
|
43
|
+
|
|
44
|
+
- name: Run tests
|
|
45
|
+
run: uv run pytest -q
|
|
46
|
+
|
|
47
|
+
- name: Build distributions
|
|
48
|
+
run: uv build
|
|
49
|
+
|
|
50
|
+
- name: Publish to PyPI
|
|
51
|
+
run: uv publish
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 8DE4732A
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: websocket-tunnel
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A frp-like intranet penetration tool with WebSocket transport (TCP passthrough).
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: websockets>=14
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# websocket-tunnel
|
|
12
|
+
|
|
13
|
+
一个类似 frp 的内网穿透工具:Python + uv 实现,传输层使用 WebSocket 多路复用,
|
|
14
|
+
支持任意 TCP 服务(HTTP/HTTPS/SSH/MySQL 等)的字节流透传。构建产物通过
|
|
15
|
+
`uv build` 生成,安装后提供 `wtunnel` CLI,分为 `server` 与 `client` 两种模式。
|
|
16
|
+
|
|
17
|
+
与 frp 的主要差异:**代理不区分服务端/客户端**。代理条目可以声明在任意节点的
|
|
18
|
+
配置中,监听端(listen)与后端(backend)分别用 `local` / `peer` 指定在哪一侧,
|
|
19
|
+
因此以下四种组合全部支持:
|
|
20
|
+
|
|
21
|
+
| listen 侧 | backend 侧 | 用途 |
|
|
22
|
+
| --- | --- | --- |
|
|
23
|
+
| server | client | 经典 frp:把内网服务暴露到公网服务器端口 |
|
|
24
|
+
| client | server | 反向:把服务端网络里的服务映射到客户端本地端口 |
|
|
25
|
+
| server | server | 服务端本机端口转发(不占用隧道) |
|
|
26
|
+
| client | client | 客户端本机端口转发(不占用隧道) |
|
|
27
|
+
|
|
28
|
+
## 安装
|
|
29
|
+
|
|
30
|
+
要求 Python >= 3.11 与 [uv](https://docs.astral.sh/uv/)。
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
uv sync # 开发环境(含测试依赖)
|
|
34
|
+
uv build # 构建 sdist + wheel
|
|
35
|
+
uv run wtunnel --version
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
也可以把 wheel 安装到任意环境:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
uv pip install dist/websocket_tunnel-0.1.0-py3-none-any.whl
|
|
42
|
+
wtunnel --version
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## 快速开始
|
|
46
|
+
|
|
47
|
+
### 1. 启动服务端
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
wtunnel server -c examples/server.toml
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### 2. 启动客户端
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
wtunnel client -c examples/client.toml
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
客户端默认把本机 `127.0.0.1:3000` 暴露到服务端的 `0.0.0.0:8080`,访问
|
|
60
|
+
`http://<server>:8080` 即可到达客户端内网的服务。
|
|
61
|
+
|
|
62
|
+
## CLI 参数
|
|
63
|
+
|
|
64
|
+
```text
|
|
65
|
+
wtunnel server -c server.toml [--listen HOST:PORT] [--token TOKEN]
|
|
66
|
+
[--tls-cert PATH --tls-key PATH] [-v]
|
|
67
|
+
wtunnel client -c client.toml [--server HOST:PORT] [--token TOKEN]
|
|
68
|
+
[--tls] [--tls-skip-verify] [-v]
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
CLI 参数覆盖配置文件中的同名项;`-v` 输出 DEBUG 日志。
|
|
72
|
+
|
|
73
|
+
## 配置参考
|
|
74
|
+
|
|
75
|
+
`server.toml`:
|
|
76
|
+
|
|
77
|
+
```toml
|
|
78
|
+
listen = "0.0.0.0:7000" # 控制端口(ws/wss)
|
|
79
|
+
token = "secret" # 可选,共享认证 token
|
|
80
|
+
tls = { cert = "server.crt", key = "server.key" } # 可选,启用 wss
|
|
81
|
+
|
|
82
|
+
[[proxies]]
|
|
83
|
+
name = "web"
|
|
84
|
+
listen = "0.0.0.0:8080"
|
|
85
|
+
listen_side = "local" # "local" | "peer"
|
|
86
|
+
backend = "127.0.0.1:3000"
|
|
87
|
+
backend_side = "peer"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`client.toml`:
|
|
91
|
+
|
|
92
|
+
```toml
|
|
93
|
+
server = "127.0.0.1:7000" # 服务端地址
|
|
94
|
+
token = "secret"
|
|
95
|
+
tls = false # true 时使用 wss
|
|
96
|
+
tls_skip_verify = false # 自签证书时设为 true
|
|
97
|
+
|
|
98
|
+
[[proxies]]
|
|
99
|
+
name = "web"
|
|
100
|
+
listen = "0.0.0.0:8080"
|
|
101
|
+
listen_side = "peer"
|
|
102
|
+
backend = "127.0.0.1:3000"
|
|
103
|
+
backend_side = "local"
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`listen` / `backend` 均为 `host:port`,IPv6 使用 `[::1]:8080` 形式。backend 可以
|
|
107
|
+
指向任意可达地址(含内网 IP 的外部服务端口),不限于 localhost。
|
|
108
|
+
|
|
109
|
+
## 传输与协议概要
|
|
110
|
+
|
|
111
|
+
- 每对节点一条 WebSocket 长连接(client 主动连接 server),控制消息与所有数据流
|
|
112
|
+
在其上多路复用;每条 TCP 流分配独立 stream id。
|
|
113
|
+
- 二进制帧:首字节为消息类型;控制消息为 JSON,数据帧为
|
|
114
|
+
`stream_id`(4 字节大端) + 数据块(默认 32 KiB)。
|
|
115
|
+
- 支持半关闭:任一方向 EOF 只关闭对应方向,保证 HTTP/1.0 与 keep-alive 响应完整。
|
|
116
|
+
- 握手时校验共享 token(失败即断开);服务端可选 wss,客户端可跳过证书校验。
|
|
117
|
+
- client 断线自动重连(指数退避 1s→30s),重连后双方重新注册代理并重新绑定监听。
|
|
118
|
+
|
|
119
|
+
## 开发与测试
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
uv run pytest # 单元 + 集成测试
|
|
123
|
+
uv build # 构建发布产物
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## 发布到 PyPI
|
|
127
|
+
|
|
128
|
+
打上 `v` 开头的 tag 并推送即触发 GitHub Actions 发布:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
git tag v0.1.0
|
|
132
|
+
git push origin v0.1.0
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
工作流(`.github/workflows/release.yml`)会校验 tag 与包版本一致、运行测试、
|
|
136
|
+
`uv build` 构建后通过 trusted publishing(OIDC)发布到 PyPI。首次使用前需要在
|
|
137
|
+
PyPI 项目页配置 Trusted Publisher:仓库 `8DE4732A/websocket-tunnel`、
|
|
138
|
+
工作流名 `Publish to PyPI`。若改用 API token,删除 workflow 中 `id-token: write`
|
|
139
|
+
权限,并配置 `PYPI_TOKEN` secret 传给 `uv publish`。
|
|
140
|
+
|
|
141
|
+
## 安全说明
|
|
142
|
+
|
|
143
|
+
共享 token 即信任边界:持有 token 的 client 可以请求服务端绑定任意监听端口。
|
|
144
|
+
公网部署请务必启用 wss(或搭配其他加密隧道)并妥善保管 token。v1 暂不提供
|
|
145
|
+
mTLS、证书固定与配置热加载。
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# websocket-tunnel
|
|
2
|
+
|
|
3
|
+
一个类似 frp 的内网穿透工具:Python + uv 实现,传输层使用 WebSocket 多路复用,
|
|
4
|
+
支持任意 TCP 服务(HTTP/HTTPS/SSH/MySQL 等)的字节流透传。构建产物通过
|
|
5
|
+
`uv build` 生成,安装后提供 `wtunnel` CLI,分为 `server` 与 `client` 两种模式。
|
|
6
|
+
|
|
7
|
+
与 frp 的主要差异:**代理不区分服务端/客户端**。代理条目可以声明在任意节点的
|
|
8
|
+
配置中,监听端(listen)与后端(backend)分别用 `local` / `peer` 指定在哪一侧,
|
|
9
|
+
因此以下四种组合全部支持:
|
|
10
|
+
|
|
11
|
+
| listen 侧 | backend 侧 | 用途 |
|
|
12
|
+
| --- | --- | --- |
|
|
13
|
+
| server | client | 经典 frp:把内网服务暴露到公网服务器端口 |
|
|
14
|
+
| client | server | 反向:把服务端网络里的服务映射到客户端本地端口 |
|
|
15
|
+
| server | server | 服务端本机端口转发(不占用隧道) |
|
|
16
|
+
| client | client | 客户端本机端口转发(不占用隧道) |
|
|
17
|
+
|
|
18
|
+
## 安装
|
|
19
|
+
|
|
20
|
+
要求 Python >= 3.11 与 [uv](https://docs.astral.sh/uv/)。
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
uv sync # 开发环境(含测试依赖)
|
|
24
|
+
uv build # 构建 sdist + wheel
|
|
25
|
+
uv run wtunnel --version
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
也可以把 wheel 安装到任意环境:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
uv pip install dist/websocket_tunnel-0.1.0-py3-none-any.whl
|
|
32
|
+
wtunnel --version
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## 快速开始
|
|
36
|
+
|
|
37
|
+
### 1. 启动服务端
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
wtunnel server -c examples/server.toml
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### 2. 启动客户端
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
wtunnel client -c examples/client.toml
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
客户端默认把本机 `127.0.0.1:3000` 暴露到服务端的 `0.0.0.0:8080`,访问
|
|
50
|
+
`http://<server>:8080` 即可到达客户端内网的服务。
|
|
51
|
+
|
|
52
|
+
## CLI 参数
|
|
53
|
+
|
|
54
|
+
```text
|
|
55
|
+
wtunnel server -c server.toml [--listen HOST:PORT] [--token TOKEN]
|
|
56
|
+
[--tls-cert PATH --tls-key PATH] [-v]
|
|
57
|
+
wtunnel client -c client.toml [--server HOST:PORT] [--token TOKEN]
|
|
58
|
+
[--tls] [--tls-skip-verify] [-v]
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
CLI 参数覆盖配置文件中的同名项;`-v` 输出 DEBUG 日志。
|
|
62
|
+
|
|
63
|
+
## 配置参考
|
|
64
|
+
|
|
65
|
+
`server.toml`:
|
|
66
|
+
|
|
67
|
+
```toml
|
|
68
|
+
listen = "0.0.0.0:7000" # 控制端口(ws/wss)
|
|
69
|
+
token = "secret" # 可选,共享认证 token
|
|
70
|
+
tls = { cert = "server.crt", key = "server.key" } # 可选,启用 wss
|
|
71
|
+
|
|
72
|
+
[[proxies]]
|
|
73
|
+
name = "web"
|
|
74
|
+
listen = "0.0.0.0:8080"
|
|
75
|
+
listen_side = "local" # "local" | "peer"
|
|
76
|
+
backend = "127.0.0.1:3000"
|
|
77
|
+
backend_side = "peer"
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`client.toml`:
|
|
81
|
+
|
|
82
|
+
```toml
|
|
83
|
+
server = "127.0.0.1:7000" # 服务端地址
|
|
84
|
+
token = "secret"
|
|
85
|
+
tls = false # true 时使用 wss
|
|
86
|
+
tls_skip_verify = false # 自签证书时设为 true
|
|
87
|
+
|
|
88
|
+
[[proxies]]
|
|
89
|
+
name = "web"
|
|
90
|
+
listen = "0.0.0.0:8080"
|
|
91
|
+
listen_side = "peer"
|
|
92
|
+
backend = "127.0.0.1:3000"
|
|
93
|
+
backend_side = "local"
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`listen` / `backend` 均为 `host:port`,IPv6 使用 `[::1]:8080` 形式。backend 可以
|
|
97
|
+
指向任意可达地址(含内网 IP 的外部服务端口),不限于 localhost。
|
|
98
|
+
|
|
99
|
+
## 传输与协议概要
|
|
100
|
+
|
|
101
|
+
- 每对节点一条 WebSocket 长连接(client 主动连接 server),控制消息与所有数据流
|
|
102
|
+
在其上多路复用;每条 TCP 流分配独立 stream id。
|
|
103
|
+
- 二进制帧:首字节为消息类型;控制消息为 JSON,数据帧为
|
|
104
|
+
`stream_id`(4 字节大端) + 数据块(默认 32 KiB)。
|
|
105
|
+
- 支持半关闭:任一方向 EOF 只关闭对应方向,保证 HTTP/1.0 与 keep-alive 响应完整。
|
|
106
|
+
- 握手时校验共享 token(失败即断开);服务端可选 wss,客户端可跳过证书校验。
|
|
107
|
+
- client 断线自动重连(指数退避 1s→30s),重连后双方重新注册代理并重新绑定监听。
|
|
108
|
+
|
|
109
|
+
## 开发与测试
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
uv run pytest # 单元 + 集成测试
|
|
113
|
+
uv build # 构建发布产物
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## 发布到 PyPI
|
|
117
|
+
|
|
118
|
+
打上 `v` 开头的 tag 并推送即触发 GitHub Actions 发布:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
git tag v0.1.0
|
|
122
|
+
git push origin v0.1.0
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
工作流(`.github/workflows/release.yml`)会校验 tag 与包版本一致、运行测试、
|
|
126
|
+
`uv build` 构建后通过 trusted publishing(OIDC)发布到 PyPI。首次使用前需要在
|
|
127
|
+
PyPI 项目页配置 Trusted Publisher:仓库 `8DE4732A/websocket-tunnel`、
|
|
128
|
+
工作流名 `Publish to PyPI`。若改用 API token,删除 workflow 中 `id-token: write`
|
|
129
|
+
权限,并配置 `PYPI_TOKEN` secret 传给 `uv publish`。
|
|
130
|
+
|
|
131
|
+
## 安全说明
|
|
132
|
+
|
|
133
|
+
共享 token 即信任边界:持有 token 的 client 可以请求服务端绑定任意监听端口。
|
|
134
|
+
公网部署请务必启用 wss(或搭配其他加密隧道)并妥善保管 token。v1 暂不提供
|
|
135
|
+
mTLS、证书固定与配置热加载。
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Tunnel client configuration.
|
|
2
|
+
server = "127.0.0.1:7000"
|
|
3
|
+
token = "change-me"
|
|
4
|
+
|
|
5
|
+
# Use wss transport and skip certificate verification (self-signed certs).
|
|
6
|
+
# tls = true
|
|
7
|
+
# tls_skip_verify = true
|
|
8
|
+
|
|
9
|
+
# Proxies are declared from the client's point of view: "local" = this node
|
|
10
|
+
# (the client), "peer" = the other node (the tunnel server).
|
|
11
|
+
|
|
12
|
+
# Classic frp scenario: expose a service on the client's machine at a port
|
|
13
|
+
# on the server (0.0.0.0:8080 here).
|
|
14
|
+
[[proxies]]
|
|
15
|
+
name = "web"
|
|
16
|
+
listen = "0.0.0.0:8080"
|
|
17
|
+
listen_side = "peer"
|
|
18
|
+
backend = "127.0.0.1:3000"
|
|
19
|
+
backend_side = "local"
|
|
20
|
+
|
|
21
|
+
# Reverse scenario: listen on the client's machine and forward to a service
|
|
22
|
+
# on the server's machine.
|
|
23
|
+
# [[proxies]]
|
|
24
|
+
# name = "db-on-server"
|
|
25
|
+
# listen = "127.0.0.1:3307"
|
|
26
|
+
# listen_side = "local"
|
|
27
|
+
# backend = "127.0.0.1:3306"
|
|
28
|
+
# backend_side = "peer"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Tunnel server configuration.
|
|
2
|
+
listen = "0.0.0.0:7000"
|
|
3
|
+
token = "change-me"
|
|
4
|
+
|
|
5
|
+
# Optional TLS (wss). Uncomment and provide cert/key files to enable.
|
|
6
|
+
# tls = { cert = "server.crt", key = "server.key" }
|
|
7
|
+
|
|
8
|
+
# The server may also declare proxies. Sides are relative to the declaring
|
|
9
|
+
# node: "local" = this node, "peer" = the other node (the connected client).
|
|
10
|
+
|
|
11
|
+
# Example: expose a service that runs on the SERVER's machine through the
|
|
12
|
+
# tunnel, listening on the CLIENT's machine at 127.0.0.1:3307.
|
|
13
|
+
[[proxies]]
|
|
14
|
+
name = "mysql-on-server"
|
|
15
|
+
listen = "127.0.0.1:3307"
|
|
16
|
+
listen_side = "peer"
|
|
17
|
+
backend = "127.0.0.1:3306"
|
|
18
|
+
backend_side = "local"
|
|
19
|
+
|
|
20
|
+
# Example: a classic frp-style forward declared by the server
|
|
21
|
+
# (listener on the server, backend on the client):
|
|
22
|
+
# [[proxies]]
|
|
23
|
+
# name = "web-on-client"
|
|
24
|
+
# listen = "0.0.0.0:8080"
|
|
25
|
+
# listen_side = "local"
|
|
26
|
+
# backend = "127.0.0.1:3000"
|
|
27
|
+
# backend_side = "peer"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "websocket-tunnel"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A frp-like intranet penetration tool with WebSocket transport (TCP passthrough)."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
dependencies = [
|
|
10
|
+
"websockets>=14",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[project.scripts]
|
|
14
|
+
wtunnel = "websocket_tunnel.cli:main"
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["hatchling"]
|
|
18
|
+
build-backend = "hatchling.build"
|
|
19
|
+
|
|
20
|
+
[tool.hatch.build.targets.wheel]
|
|
21
|
+
packages = ["src/websocket_tunnel"]
|
|
22
|
+
|
|
23
|
+
[dependency-groups]
|
|
24
|
+
dev = [
|
|
25
|
+
"pytest>=8",
|
|
26
|
+
"pytest-asyncio>=0.24",
|
|
27
|
+
"trustme>=1.1",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
asyncio_mode = "auto"
|
|
32
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Command line interface for wtunnel."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import logging
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .client import TunnelClient
|
|
13
|
+
from .config import ConfigError, load_client_config, load_server_config
|
|
14
|
+
from .server import TunnelServer
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
18
|
+
parser = argparse.ArgumentParser(
|
|
19
|
+
prog="wtunnel",
|
|
20
|
+
description="WebSocket-based intranet penetration tool (frp-like).",
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
23
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
24
|
+
|
|
25
|
+
server = subparsers.add_parser("server", help="run the tunnel server")
|
|
26
|
+
server.add_argument("-c", "--config", type=Path, required=True, help="path to server TOML config")
|
|
27
|
+
server.add_argument("--listen", metavar="HOST:PORT", help="override listen address")
|
|
28
|
+
server.add_argument("--token", help="override shared auth token")
|
|
29
|
+
server.add_argument("--tls-cert", metavar="PATH", help="override TLS certificate path")
|
|
30
|
+
server.add_argument("--tls-key", metavar="PATH", help="override TLS private key path")
|
|
31
|
+
server.add_argument("-v", "--verbose", action="count", default=0, help="increase log verbosity (repeatable)")
|
|
32
|
+
|
|
33
|
+
client = subparsers.add_parser("client", help="run the tunnel client")
|
|
34
|
+
client.add_argument("-c", "--config", type=Path, required=True, help="path to client TOML config")
|
|
35
|
+
client.add_argument("--server", metavar="HOST:PORT", help="override tunnel server address")
|
|
36
|
+
client.add_argument("--token", help="override shared auth token")
|
|
37
|
+
client.add_argument("--tls", action="store_true", default=None, help="override: use wss transport")
|
|
38
|
+
client.add_argument(
|
|
39
|
+
"--tls-skip-verify",
|
|
40
|
+
action="store_true",
|
|
41
|
+
default=None,
|
|
42
|
+
help="override: skip TLS certificate verification",
|
|
43
|
+
)
|
|
44
|
+
client.add_argument("-v", "--verbose", action="count", default=0, help="increase log verbosity (repeatable)")
|
|
45
|
+
return parser
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _setup_logging(verbosity: int) -> None:
|
|
49
|
+
level = logging.DEBUG if verbosity else logging.INFO
|
|
50
|
+
logging.basicConfig(
|
|
51
|
+
level=level,
|
|
52
|
+
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main(argv: list[str] | None = None) -> int:
|
|
57
|
+
args = _build_parser().parse_args(argv)
|
|
58
|
+
_setup_logging(args.verbose)
|
|
59
|
+
try:
|
|
60
|
+
if args.command == "server":
|
|
61
|
+
config = load_server_config(
|
|
62
|
+
args.config,
|
|
63
|
+
listen=args.listen,
|
|
64
|
+
token=args.token,
|
|
65
|
+
tls_cert=args.tls_cert,
|
|
66
|
+
tls_key=args.tls_key,
|
|
67
|
+
)
|
|
68
|
+
return asyncio.run(TunnelServer(config).run())
|
|
69
|
+
config = load_client_config(
|
|
70
|
+
args.config,
|
|
71
|
+
server=args.server,
|
|
72
|
+
token=args.token,
|
|
73
|
+
tls=args.tls,
|
|
74
|
+
tls_skip_verify=args.tls_skip_verify,
|
|
75
|
+
)
|
|
76
|
+
return asyncio.run(TunnelClient(config).run())
|
|
77
|
+
except ConfigError as exc:
|
|
78
|
+
print(f"wtunnel: configuration error: {exc}", file=sys.stderr)
|
|
79
|
+
return 2
|
|
80
|
+
except KeyboardInterrupt:
|
|
81
|
+
return 0
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Tunnel client: connects to a tunnel server and reconnects on failure."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import ssl
|
|
8
|
+
|
|
9
|
+
import websockets
|
|
10
|
+
from websockets.asyncio.client import connect
|
|
11
|
+
|
|
12
|
+
from .config import ClientConfig, split_host_port
|
|
13
|
+
from .protocol import MAX_CONTROL_SIZE
|
|
14
|
+
from .session import Session
|
|
15
|
+
|
|
16
|
+
CONNECT_TIMEOUT = 15.0
|
|
17
|
+
RECONNECT_BASE = 1.0
|
|
18
|
+
RECONNECT_MAX = 30.0
|
|
19
|
+
RECONNECT_AFTER_SESSION = 1.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _ws_url(host: str, port: int, secure: bool) -> str:
|
|
23
|
+
if ":" in host and not host.startswith("["):
|
|
24
|
+
host = f"[{host}]"
|
|
25
|
+
scheme = "wss" if secure else "ws"
|
|
26
|
+
return f"{scheme}://{host}:{port}/"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TunnelClient:
|
|
30
|
+
def __init__(self, config: ClientConfig) -> None:
|
|
31
|
+
self._config = config
|
|
32
|
+
self._log = logging.getLogger("wtunnel.client")
|
|
33
|
+
self._session: Session | None = None
|
|
34
|
+
self._stopping = False
|
|
35
|
+
self._stop_wait = asyncio.Event()
|
|
36
|
+
self.ready_event = asyncio.Event()
|
|
37
|
+
|
|
38
|
+
async def run(self) -> None:
|
|
39
|
+
backoff = RECONNECT_BASE
|
|
40
|
+
while not self._stopping:
|
|
41
|
+
try:
|
|
42
|
+
ws = await asyncio.wait_for(self._connect(), CONNECT_TIMEOUT)
|
|
43
|
+
except asyncio.CancelledError:
|
|
44
|
+
raise
|
|
45
|
+
except Exception as exc:
|
|
46
|
+
self._log.warning(
|
|
47
|
+
"connect to %s failed: %s; retrying in %.1fs", self._config.server, exc, backoff
|
|
48
|
+
)
|
|
49
|
+
await self._sleep_until_stop_or(backoff)
|
|
50
|
+
backoff = min(backoff * 2, RECONNECT_MAX)
|
|
51
|
+
continue
|
|
52
|
+
backoff = RECONNECT_BASE
|
|
53
|
+
self.ready_event = asyncio.Event()
|
|
54
|
+
session = Session(
|
|
55
|
+
ws,
|
|
56
|
+
role="client",
|
|
57
|
+
own_name="client",
|
|
58
|
+
own_proxies=self._config.proxies,
|
|
59
|
+
token=self._config.token,
|
|
60
|
+
ready_event=self.ready_event,
|
|
61
|
+
logger=self._log,
|
|
62
|
+
)
|
|
63
|
+
self._session = session
|
|
64
|
+
try:
|
|
65
|
+
await session.run()
|
|
66
|
+
except asyncio.CancelledError:
|
|
67
|
+
raise
|
|
68
|
+
except Exception as exc:
|
|
69
|
+
self._log.warning("session failed: %s", exc)
|
|
70
|
+
finally:
|
|
71
|
+
self._session = None
|
|
72
|
+
try:
|
|
73
|
+
await ws.close()
|
|
74
|
+
except Exception:
|
|
75
|
+
pass
|
|
76
|
+
if self._stopping:
|
|
77
|
+
break
|
|
78
|
+
self._log.info("session ended; reconnecting in %.1fs", RECONNECT_AFTER_SESSION)
|
|
79
|
+
await self._sleep_until_stop_or(RECONNECT_AFTER_SESSION)
|
|
80
|
+
|
|
81
|
+
async def _connect(self) -> websockets.asyncio.client.ClientConnection:
|
|
82
|
+
ssl_context = self._build_ssl()
|
|
83
|
+
host, port = split_host_port(self._config.server)
|
|
84
|
+
uri = _ws_url(host, port, ssl_context is not None)
|
|
85
|
+
return await connect(
|
|
86
|
+
uri,
|
|
87
|
+
ssl=ssl_context,
|
|
88
|
+
max_size=MAX_CONTROL_SIZE,
|
|
89
|
+
ping_interval=20,
|
|
90
|
+
ping_timeout=20,
|
|
91
|
+
close_timeout=1,
|
|
92
|
+
compression=None,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def _build_ssl(self) -> ssl.SSLContext | None:
|
|
96
|
+
if not self._config.tls:
|
|
97
|
+
return None
|
|
98
|
+
context = ssl.create_default_context()
|
|
99
|
+
if self._config.tls_skip_verify:
|
|
100
|
+
context.check_hostname = False
|
|
101
|
+
context.verify_mode = ssl.CERT_NONE
|
|
102
|
+
return context
|
|
103
|
+
|
|
104
|
+
async def _sleep_until_stop_or(self, seconds: float) -> None:
|
|
105
|
+
try:
|
|
106
|
+
await asyncio.wait_for(self._stop_wait.wait(), timeout=seconds)
|
|
107
|
+
except asyncio.TimeoutError:
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
async def stop(self) -> None:
|
|
111
|
+
self._stopping = True
|
|
112
|
+
self._stop_wait.set()
|
|
113
|
+
session = self._session
|
|
114
|
+
if session is not None:
|
|
115
|
+
await session.close()
|