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.
@@ -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,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .pytest_cache/
6
+ .coverage
7
+ dist/
8
+ build/
@@ -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,3 @@
1
+ """websocket-tunnel: a frp-like intranet penetration tool over WebSocket."""
2
+
3
+ __version__ = "0.1.0"
@@ -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()