websocket-tunnel 0.1.0__py3-none-any.whl
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/__init__.py +3 -0
- websocket_tunnel/cli.py +81 -0
- websocket_tunnel/client.py +115 -0
- websocket_tunnel/config.py +173 -0
- websocket_tunnel/protocol.py +78 -0
- websocket_tunnel/server.py +81 -0
- websocket_tunnel/session.py +665 -0
- websocket_tunnel-0.1.0.dist-info/METADATA +145 -0
- websocket_tunnel-0.1.0.dist-info/RECORD +12 -0
- websocket_tunnel-0.1.0.dist-info/WHEEL +4 -0
- websocket_tunnel-0.1.0.dist-info/entry_points.txt +2 -0
- websocket_tunnel-0.1.0.dist-info/licenses/LICENSE +21 -0
websocket_tunnel/cli.py
ADDED
|
@@ -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()
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""TOML configuration parsing and validation for both tunnel nodes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import tomllib
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
_HOST_PORT_RE = re.compile(r"^(\[[^\[\]]+\]|[^:]+):([0-9]+)$")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConfigError(Exception):
|
|
15
|
+
"""Raised when a configuration file or value is invalid."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class ProxyConfig:
|
|
20
|
+
name: str
|
|
21
|
+
listen: str
|
|
22
|
+
listen_side: str # "local" | "peer"
|
|
23
|
+
backend: str
|
|
24
|
+
backend_side: str # "local" | "peer"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class ServerConfig:
|
|
29
|
+
listen: str = "0.0.0.0:7000"
|
|
30
|
+
token: str | None = None
|
|
31
|
+
tls_cert: str | None = None
|
|
32
|
+
tls_key: str | None = None
|
|
33
|
+
proxies: tuple[ProxyConfig, ...] = ()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ClientConfig:
|
|
38
|
+
server: str = "127.0.0.1:7000"
|
|
39
|
+
token: str | None = None
|
|
40
|
+
tls: bool = False
|
|
41
|
+
tls_skip_verify: bool = False
|
|
42
|
+
proxies: tuple[ProxyConfig, ...] = ()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def split_host_port(value: str) -> tuple[str, int]:
|
|
46
|
+
"""Split ``host:port`` (IPv6 literals must use ``[::1]:port`` form)."""
|
|
47
|
+
if not isinstance(value, str):
|
|
48
|
+
raise ConfigError(f"expected 'host:port', got {value!r}")
|
|
49
|
+
match = _HOST_PORT_RE.match(value.strip())
|
|
50
|
+
if match is None:
|
|
51
|
+
raise ConfigError(f"invalid 'host:port' address: {value!r}")
|
|
52
|
+
host = match.group(1)
|
|
53
|
+
if host.startswith("[") and host.endswith("]"):
|
|
54
|
+
host = host[1:-1]
|
|
55
|
+
port = int(match.group(2))
|
|
56
|
+
if not 1 <= port <= 65535:
|
|
57
|
+
raise ConfigError(f"port out of range in {value!r}")
|
|
58
|
+
if not host:
|
|
59
|
+
raise ConfigError(f"empty host in {value!r}")
|
|
60
|
+
return host, port
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _check_side(value: Any, key: str) -> str:
|
|
64
|
+
if value not in ("local", "peer"):
|
|
65
|
+
raise ConfigError(f"{key} must be 'local' or 'peer', got {value!r}")
|
|
66
|
+
return value
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def proxy_from_dict(raw: Any) -> ProxyConfig:
|
|
70
|
+
"""Build and validate a proxy from a mapping (config table or wire payload)."""
|
|
71
|
+
if not isinstance(raw, dict):
|
|
72
|
+
raise ConfigError("proxy entry must be a table/object")
|
|
73
|
+
missing = [key for key in ("name", "listen", "listen_side", "backend", "backend_side") if key not in raw]
|
|
74
|
+
if missing:
|
|
75
|
+
raise ConfigError(f"proxy entry missing keys: {', '.join(missing)}")
|
|
76
|
+
name = raw["name"]
|
|
77
|
+
if not isinstance(name, str) or not name.strip():
|
|
78
|
+
raise ConfigError("proxy name must be a non-empty string")
|
|
79
|
+
listen = raw["listen"]
|
|
80
|
+
backend = raw["backend"]
|
|
81
|
+
split_host_port(listen)
|
|
82
|
+
split_host_port(backend)
|
|
83
|
+
return ProxyConfig(
|
|
84
|
+
name=name.strip(),
|
|
85
|
+
listen=str(listen),
|
|
86
|
+
listen_side=_check_side(raw["listen_side"], "listen_side"),
|
|
87
|
+
backend=str(backend),
|
|
88
|
+
backend_side=_check_side(raw["backend_side"], "backend_side"),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _parse_proxies(raw: Any) -> tuple[ProxyConfig, ...]:
|
|
93
|
+
if raw is None:
|
|
94
|
+
return ()
|
|
95
|
+
if not isinstance(raw, list):
|
|
96
|
+
raise ConfigError("'proxies' must be a list of tables")
|
|
97
|
+
proxies = tuple(proxy_from_dict(item) for item in raw)
|
|
98
|
+
seen: set[str] = set()
|
|
99
|
+
for proxy in proxies:
|
|
100
|
+
if proxy.name in seen:
|
|
101
|
+
raise ConfigError(f"duplicate proxy name: {proxy.name!r}")
|
|
102
|
+
seen.add(proxy.name)
|
|
103
|
+
return proxies
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _load_toml(path: Path) -> dict[str, Any]:
|
|
107
|
+
path = Path(path)
|
|
108
|
+
try:
|
|
109
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
110
|
+
except FileNotFoundError as exc:
|
|
111
|
+
raise ConfigError(f"config file not found: {path}") from exc
|
|
112
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
113
|
+
raise ConfigError(f"failed to parse config {path}: {exc}") from exc
|
|
114
|
+
if not isinstance(data, dict):
|
|
115
|
+
raise ConfigError(f"config {path} must be a TOML table")
|
|
116
|
+
return data
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def load_server_config(
|
|
120
|
+
path: Path,
|
|
121
|
+
*,
|
|
122
|
+
listen: str | None = None,
|
|
123
|
+
token: str | None = None,
|
|
124
|
+
tls_cert: str | None = None,
|
|
125
|
+
tls_key: str | None = None,
|
|
126
|
+
) -> ServerConfig:
|
|
127
|
+
data = _load_toml(path)
|
|
128
|
+
listen = listen if listen is not None else data.get("listen", "0.0.0.0:7000")
|
|
129
|
+
split_host_port(listen)
|
|
130
|
+
token = token if token is not None else data.get("token")
|
|
131
|
+
if token is not None and not isinstance(token, str):
|
|
132
|
+
raise ConfigError("'token' must be a string")
|
|
133
|
+
tls_raw = data.get("tls")
|
|
134
|
+
if tls_raw is None:
|
|
135
|
+
tls_raw = {}
|
|
136
|
+
if not isinstance(tls_raw, dict):
|
|
137
|
+
raise ConfigError("'tls' must be a table with 'cert' and 'key'")
|
|
138
|
+
tls_cert = tls_cert if tls_cert is not None else tls_raw.get("cert")
|
|
139
|
+
tls_key = tls_key if tls_key is not None else tls_raw.get("key")
|
|
140
|
+
if bool(tls_cert) != bool(tls_key):
|
|
141
|
+
raise ConfigError("'tls.cert' and 'tls.key' must be set together")
|
|
142
|
+
return ServerConfig(
|
|
143
|
+
listen=listen,
|
|
144
|
+
token=token,
|
|
145
|
+
tls_cert=tls_cert,
|
|
146
|
+
tls_key=tls_key,
|
|
147
|
+
proxies=_parse_proxies(data.get("proxies")),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def load_client_config(
|
|
152
|
+
path: Path,
|
|
153
|
+
*,
|
|
154
|
+
server: str | None = None,
|
|
155
|
+
token: str | None = None,
|
|
156
|
+
tls: bool | None = None,
|
|
157
|
+
tls_skip_verify: bool | None = None,
|
|
158
|
+
) -> ClientConfig:
|
|
159
|
+
data = _load_toml(path)
|
|
160
|
+
server = server if server is not None else data.get("server", "127.0.0.1:7000")
|
|
161
|
+
split_host_port(server)
|
|
162
|
+
token = token if token is not None else data.get("token")
|
|
163
|
+
if token is not None and not isinstance(token, str):
|
|
164
|
+
raise ConfigError("'token' must be a string")
|
|
165
|
+
tls = tls if tls is not None else bool(data.get("tls", False))
|
|
166
|
+
tls_skip_verify = tls_skip_verify if tls_skip_verify is not None else bool(data.get("tls_skip_verify", False))
|
|
167
|
+
return ClientConfig(
|
|
168
|
+
server=server,
|
|
169
|
+
token=token,
|
|
170
|
+
tls=tls,
|
|
171
|
+
tls_skip_verify=tls_skip_verify,
|
|
172
|
+
proxies=_parse_proxies(data.get("proxies")),
|
|
173
|
+
)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Binary wire protocol shared by tunnel nodes.
|
|
2
|
+
|
|
3
|
+
Every WebSocket message is one binary frame:
|
|
4
|
+
|
|
5
|
+
* control messages: 1 type byte + UTF-8 JSON object
|
|
6
|
+
* stream data: 1 type byte + 4-byte big-endian stream id + raw chunk
|
|
7
|
+
* ping/pong: 1 type byte
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from enum import IntEnum
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MessageType(IntEnum):
|
|
18
|
+
HELLO = 1
|
|
19
|
+
HELLO_OK = 2
|
|
20
|
+
HELLO_ERROR = 3
|
|
21
|
+
PROXY_REGISTER = 4
|
|
22
|
+
PROXY_OK = 5
|
|
23
|
+
PROXY_ERROR = 6
|
|
24
|
+
PROXY_UNREGISTER = 7
|
|
25
|
+
STREAM_OPEN = 8
|
|
26
|
+
STREAM_OK = 9
|
|
27
|
+
STREAM_ERROR = 10
|
|
28
|
+
STREAM_DATA = 11
|
|
29
|
+
STREAM_CLOSE = 12
|
|
30
|
+
PING = 13
|
|
31
|
+
PONG = 14
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
PROTOCOL_VERSION = 1
|
|
35
|
+
DATA_CHUNK_SIZE = 32 * 1024
|
|
36
|
+
MAX_CONTROL_SIZE = 1 << 20
|
|
37
|
+
STREAM_ID_SIZE = 4
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class InvalidFrameError(ValueError):
|
|
41
|
+
"""Raised when a frame cannot be decoded."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def encode_control(message_type: MessageType, payload: dict[str, Any]) -> bytes:
|
|
45
|
+
return bytes([message_type]) + json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def encode_data(stream_id: int, chunk: bytes) -> bytes:
|
|
49
|
+
return bytes([MessageType.STREAM_DATA]) + stream_id.to_bytes(STREAM_ID_SIZE, "big") + chunk
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def encode_ping() -> bytes:
|
|
53
|
+
return bytes([MessageType.PING])
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def encode_pong() -> bytes:
|
|
57
|
+
return bytes([MessageType.PONG])
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def decode(frame: bytes) -> tuple[MessageType, Any]:
|
|
61
|
+
if not frame:
|
|
62
|
+
raise InvalidFrameError("empty frame")
|
|
63
|
+
try:
|
|
64
|
+
message_type = MessageType(frame[0])
|
|
65
|
+
except ValueError as exc:
|
|
66
|
+
raise InvalidFrameError(f"unknown message type {frame[0]}") from exc
|
|
67
|
+
payload = frame[1:]
|
|
68
|
+
if message_type is MessageType.STREAM_DATA:
|
|
69
|
+
if len(payload) < STREAM_ID_SIZE:
|
|
70
|
+
raise InvalidFrameError("stream data frame too short")
|
|
71
|
+
stream_id = int.from_bytes(payload[:STREAM_ID_SIZE], "big")
|
|
72
|
+
return message_type, (stream_id, payload[STREAM_ID_SIZE:])
|
|
73
|
+
if message_type in (MessageType.PING, MessageType.PONG):
|
|
74
|
+
return message_type, None
|
|
75
|
+
try:
|
|
76
|
+
return message_type, json.loads(payload.decode("utf-8"))
|
|
77
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
78
|
+
raise InvalidFrameError(f"invalid JSON control payload: {exc}") from exc
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Tunnel server: accepts control connections from tunnel clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import ssl
|
|
8
|
+
|
|
9
|
+
import websockets
|
|
10
|
+
from websockets.asyncio.server import ServerConnection
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .config import ConfigError, ServerConfig, split_host_port
|
|
14
|
+
from .protocol import MAX_CONTROL_SIZE
|
|
15
|
+
from .session import Session
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TunnelServer:
|
|
19
|
+
def __init__(self, config: ServerConfig) -> None:
|
|
20
|
+
self._config = config
|
|
21
|
+
self._log = logging.getLogger("wtunnel.server")
|
|
22
|
+
self._sessions: set[Session] = set()
|
|
23
|
+
self._stop_event = asyncio.Event()
|
|
24
|
+
self.ready_event = asyncio.Event()
|
|
25
|
+
|
|
26
|
+
async def run(self) -> None:
|
|
27
|
+
ssl_context = self._build_ssl()
|
|
28
|
+
host, port = split_host_port(self._config.listen)
|
|
29
|
+
async with websockets.serve(
|
|
30
|
+
self._handle_connection,
|
|
31
|
+
host,
|
|
32
|
+
port,
|
|
33
|
+
ssl=ssl_context,
|
|
34
|
+
max_size=MAX_CONTROL_SIZE,
|
|
35
|
+
ping_interval=20,
|
|
36
|
+
ping_timeout=20,
|
|
37
|
+
close_timeout=1,
|
|
38
|
+
compression=None,
|
|
39
|
+
) as _:
|
|
40
|
+
scheme = "wss" if ssl_context else "ws"
|
|
41
|
+
self._log.info("wtunnel %s listening on %s:%s (%s)", __version__, host, port, scheme)
|
|
42
|
+
try:
|
|
43
|
+
await self._stop_event.wait()
|
|
44
|
+
finally:
|
|
45
|
+
await self._close_sessions()
|
|
46
|
+
|
|
47
|
+
def _build_ssl(self) -> ssl.SSLContext | None:
|
|
48
|
+
if not self._config.tls_cert:
|
|
49
|
+
return None
|
|
50
|
+
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
51
|
+
try:
|
|
52
|
+
context.load_cert_chain(self._config.tls_cert, self._config.tls_key)
|
|
53
|
+
except (OSError, ssl.SSLError) as exc:
|
|
54
|
+
raise ConfigError(f"failed to load TLS certificate: {exc}") from exc
|
|
55
|
+
return context
|
|
56
|
+
|
|
57
|
+
async def _handle_connection(self, ws: ServerConnection) -> None:
|
|
58
|
+
session = Session(
|
|
59
|
+
ws,
|
|
60
|
+
role="server",
|
|
61
|
+
own_name="server",
|
|
62
|
+
own_proxies=self._config.proxies,
|
|
63
|
+
token=self._config.token,
|
|
64
|
+
ready_event=self.ready_event,
|
|
65
|
+
logger=self._log,
|
|
66
|
+
)
|
|
67
|
+
self._sessions.add(session)
|
|
68
|
+
try:
|
|
69
|
+
await session.run()
|
|
70
|
+
finally:
|
|
71
|
+
self._sessions.discard(session)
|
|
72
|
+
|
|
73
|
+
async def _close_sessions(self) -> None:
|
|
74
|
+
sessions = list(self._sessions)
|
|
75
|
+
for session in sessions:
|
|
76
|
+
await session.close()
|
|
77
|
+
self._sessions.clear()
|
|
78
|
+
|
|
79
|
+
async def stop(self) -> None:
|
|
80
|
+
self._stop_event.set()
|
|
81
|
+
await self._close_sessions()
|