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.
@@ -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()
@@ -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()