linksocks 3.0.13__cp310-cp310-macosx_13_0_x86_64.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.

Potentially problematic release.


This version of linksocks might be problematic. Click here for more details.

linksocks/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """linksocks: SOCKS5 over WebSocket proxy library."""
2
+
3
+ __version__ = "3.0.13"
4
+
5
+ from ._server import Server
6
+ from ._client import Client
7
+ from ._base import ReverseTokenResult, set_log_level
8
+
9
+ __all__ = ["Server", "Client", "ReverseTokenResult", "set_log_level"]
linksocks/_base.py ADDED
@@ -0,0 +1,230 @@
1
+ """
2
+ Base classes and utilities for linksocks.
3
+
4
+ This module contains shared functionality used by Server and Client classes.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import asyncio
12
+ from dataclasses import dataclass
13
+ import threading
14
+ import time
15
+ from datetime import timedelta
16
+ from typing import Any, Callable, Dict, Iterable, Optional, Tuple, Union, List
17
+
18
+ # Underlying Go bindings module (generated)
19
+ from linksockslib import linksocks # type: ignore
20
+
21
+ _logger = logging.getLogger(__name__)
22
+
23
+ # Type aliases
24
+ DurationLike = Union[int, float, timedelta, str]
25
+
26
+
27
+ def _snake_to_camel(name: str) -> str:
28
+ """Convert snake_case to CamelCase."""
29
+ parts = name.split("_")
30
+ return "".join(p.capitalize() for p in parts if p)
31
+
32
+
33
+ def _camel_to_snake(name: str) -> str:
34
+ """Convert CamelCase to snake_case."""
35
+ out: List[str] = []
36
+ for ch in name:
37
+ if ch.isupper() and out:
38
+ out.append("_")
39
+ out.append(ch.lower())
40
+ return "".join(out)
41
+
42
+
43
+ def _to_duration(value: Optional[DurationLike]) -> Any:
44
+ """Convert seconds/str/timedelta to Go time.Duration via bindings.
45
+
46
+ - None -> 0
47
+ - int/float -> seconds (supports fractions)
48
+ - timedelta -> total seconds
49
+ - str -> parsed by Go (e.g., "1.5s", "300ms")
50
+ """
51
+ if value is None:
52
+ return 0
53
+ if isinstance(value, timedelta):
54
+ seconds = value.total_seconds()
55
+ return seconds * linksocks.Second()
56
+ if isinstance(value, (int, float)):
57
+ return value * linksocks.Second()
58
+ if isinstance(value, str):
59
+ try:
60
+ return linksocks.ParseDuration(value)
61
+ except Exception as exc:
62
+ raise ValueError(f"Invalid duration string: {value}") from exc
63
+ raise TypeError(f"Unsupported duration type: {type(value)!r}")
64
+
65
+
66
+ # Shared Go->Python log dispatcher
67
+ _def_level_map = {
68
+ "trace": logging.DEBUG,
69
+ "debug": logging.DEBUG,
70
+ "info": logging.INFO,
71
+ "warn": logging.WARNING,
72
+ "warning": logging.WARNING,
73
+ "error": logging.ERROR,
74
+ "fatal": logging.CRITICAL,
75
+ "panic": logging.CRITICAL,
76
+ }
77
+
78
+
79
+ def _emit_go_log(py_logger: logging.Logger, line: str) -> None:
80
+ """Process a Go log line and emit it to the Python logger."""
81
+ try:
82
+ obj = json.loads(line)
83
+ except Exception:
84
+ py_logger.info(line)
85
+ return
86
+ level = str(obj.get("level", "")).lower()
87
+ message = obj.get("message") or obj.get("msg") or ""
88
+ extras: Dict[str, Any] = {}
89
+ for k, v in obj.items():
90
+ if k in ("level", "time", "message", "msg"):
91
+ continue
92
+ extras[k] = v
93
+ py_logger.log(_def_level_map.get(level, logging.INFO), message, extra={"go": extras})
94
+
95
+
96
+ # Global registry for logger instances
97
+ _logger_registry: Dict[str, logging.Logger] = {}
98
+
99
+ # Event-driven log monitoring system
100
+ _log_listeners: List[Callable[[List], None]] = []
101
+ _listener_thread: Optional[threading.Thread] = None
102
+ _listener_active: bool = False
103
+
104
+
105
+ def _start_log_listener() -> None:
106
+ """Start background thread to drain Go log buffer and forward to Python loggers."""
107
+ global _listener_thread, _listener_active
108
+ if _listener_active and _listener_thread and _listener_thread.is_alive():
109
+ return
110
+ _listener_active = True
111
+
112
+ def _run() -> None:
113
+ # Drain loop: wait for entries with timeout to allow graceful shutdown
114
+ while _listener_active:
115
+ try:
116
+ entries = linksocks.WaitForLogEntries(2000) # wait up to 2s
117
+ except Exception:
118
+ # Backoff on unexpected errors to avoid busy loop
119
+ time.sleep(0.2)
120
+ continue
121
+
122
+ if not entries:
123
+ continue
124
+
125
+ # Iterate returned entries; handle both attr and dict styles
126
+ for entry in entries:
127
+ try:
128
+ logger_id = getattr(entry, "LoggerID", None)
129
+ if logger_id is None and isinstance(entry, dict):
130
+ logger_id = entry.get("LoggerID")
131
+
132
+ message = getattr(entry, "Message", None)
133
+ if message is None and isinstance(entry, dict):
134
+ message = entry.get("Message")
135
+
136
+ if not message:
137
+ continue
138
+
139
+ py_logger = _logger_registry.get(str(logger_id)) or _logger
140
+ _emit_go_log(py_logger, str(message))
141
+ except Exception:
142
+ # Never let logging path crash the listener
143
+ continue
144
+
145
+ _listener_thread = threading.Thread(target=_run, name="linksocks-go-log-listener", daemon=True)
146
+ _listener_thread.start()
147
+
148
+
149
+ def _stop_log_listener() -> None:
150
+ """Stop the background log listener thread."""
151
+ global _listener_active
152
+ _listener_active = False
153
+ try:
154
+ # Unblock WaitForLogEntries callers
155
+ linksocks.CancelLogWaiters()
156
+ except Exception:
157
+ pass
158
+
159
+
160
+ class BufferZerologLogger:
161
+ """Buffer-based logger system for Go bindings."""
162
+
163
+ def __init__(self, py_logger: logging.Logger, logger_id: str):
164
+ self.py_logger = py_logger
165
+ self.logger_id = logger_id
166
+ # Ensure background listener is running
167
+ _start_log_listener()
168
+
169
+ # Prefer Go logger with explicit ID so we can map entries back
170
+ try:
171
+ # Newer binding that tags entries with our provided ID
172
+ self.go_logger = linksocks.NewLoggerWithID(self.logger_id)
173
+ except Exception:
174
+ # Fallback to older API; if present, still try callback path
175
+ try:
176
+ def log_callback(line: str) -> None:
177
+ _emit_go_log(py_logger, line)
178
+
179
+ self.go_logger = linksocks.NewLogger(log_callback)
180
+ except Exception:
181
+ # As a last resort, create a default Go logger
182
+ self.go_logger = linksocks.NewLoggerWithID(self.logger_id) # may still raise; surface to caller
183
+ _logger_registry[logger_id] = py_logger
184
+
185
+ def cleanup(self):
186
+ """Clean up logger resources."""
187
+ if self.logger_id in _logger_registry:
188
+ del _logger_registry[self.logger_id]
189
+
190
+
191
+ @dataclass
192
+ class ReverseTokenResult:
193
+ """Result of adding a reverse token."""
194
+ token: str
195
+ port: int
196
+
197
+
198
+ class _SnakePassthrough:
199
+ """Mixin to map snake_case attribute access to underlying CamelCase.
200
+
201
+ Only used when an explicit Pythonic method/attribute is not defined.
202
+ """
203
+
204
+ def __getattr__(self, name: str) -> Any:
205
+ raw = super().__getattribute__("_raw") # type: ignore[attr-defined]
206
+ camel = _snake_to_camel(name)
207
+ try:
208
+ return getattr(raw, camel)
209
+ except AttributeError:
210
+ raise
211
+
212
+ def __dir__(self) -> List[str]:
213
+ # Expose snake_case versions of underlying CamelCase for IDEs
214
+ names = set(super().__dir__())
215
+ try:
216
+ raw = super().__getattribute__("_raw") # type: ignore[attr-defined]
217
+ for attr in dir(raw):
218
+ if not attr or attr.startswith("_"):
219
+ continue
220
+ names.add(_camel_to_snake(attr))
221
+ except Exception:
222
+ pass
223
+ return sorted(names)
224
+
225
+
226
+ def set_log_level(level: Union[int, str]) -> None:
227
+ """Set the global log level for linksocks."""
228
+ if isinstance(level, str):
229
+ level = getattr(logging, level.upper())
230
+ _logger.setLevel(level)
linksocks/_cli.py ADDED
@@ -0,0 +1,291 @@
1
+ """
2
+ Command-line interface for linksocks.
3
+
4
+ This module provides all CLI commands for the linksocks SOCKS5 over WebSocket proxy tool.
5
+ """
6
+
7
+ import asyncio
8
+ import logging
9
+ import platform
10
+ from typing import Optional
11
+
12
+ import click
13
+
14
+ from ._logging_config import init_logging
15
+ from ._utils import get_env_or_flag, parse_socks_proxy, validate_required_token
16
+
17
+
18
+ @click.group()
19
+ def cli():
20
+ """SOCKS5 over WebSocket proxy tool."""
21
+ pass
22
+
23
+
24
+ @click.command()
25
+ def version():
26
+ """Print version and platform information."""
27
+ try:
28
+ from linksocks import __version__
29
+ version_str = __version__
30
+ except ImportError:
31
+ version_str = "unknown"
32
+
33
+ platform_str = platform.platform()
34
+ click.echo(f"linksocks version {version_str} {platform_str}")
35
+
36
+
37
+ @click.command()
38
+ @click.option("--token", "-t", help="Authentication token (env: LINKSOCKS_TOKEN)")
39
+ @click.option("--url", "-u", default="ws://localhost:8765", help="WebSocket server address")
40
+ @click.option("--reverse", "-r", is_flag=True, default=False, help="Use reverse SOCKS5 proxy")
41
+ @click.option("--connector-token", "-c", default=None, help="Connector token for reverse proxy (env: LINKSOCKS_CONNECTOR_TOKEN)")
42
+ @click.option("--socks-host", "-s", default="127.0.0.1", help="SOCKS5 server listen address")
43
+ @click.option("--socks-port", "-p", default=9870, help="SOCKS5 server listen port")
44
+ @click.option("--socks-username", "-n", help="SOCKS5 authentication username")
45
+ @click.option("--socks-password", "-w", help="SOCKS5 authentication password (env: LINKSOCKS_SOCKS_PASSWORD)")
46
+ @click.option("--socks-no-wait", "-i", is_flag=True, default=False, help="Start SOCKS server immediately")
47
+ @click.option("--no-reconnect", "-R", is_flag=True, default=False, help="Stop when server disconnects")
48
+ @click.option("--debug", "-d", count=True, help="Debug logging (-d for debug, -dd for trace)")
49
+ @click.option("--threads", "-T", default=1, help="Number of threads for data transfer")
50
+ @click.option("--upstream-proxy", "-x", help="Upstream SOCKS5 proxy (socks5://[user:pass@]host[:port])")
51
+ @click.option("--fast-open", "-f", is_flag=True, default=False, help="Assume connection success and allow data transfer immediately")
52
+ @click.option("--no-env-proxy", "-E", is_flag=True, default=False, help="Ignore proxy env vars for WebSocket connection")
53
+ def client(
54
+ token: Optional[str],
55
+ url: str,
56
+ reverse: bool,
57
+ connector_token: Optional[str],
58
+ socks_host: str,
59
+ socks_port: int,
60
+ socks_username: Optional[str],
61
+ socks_password: Optional[str],
62
+ socks_no_wait: bool,
63
+ no_reconnect: bool,
64
+ debug: int,
65
+ threads: int,
66
+ upstream_proxy: Optional[str],
67
+ fast_open: bool,
68
+ no_env_proxy: bool,
69
+ ):
70
+ """Start SOCKS5 over WebSocket proxy client."""
71
+ from ._client import Client
72
+
73
+ async def main():
74
+ # Get values from flags or environment
75
+ actual_token = get_env_or_flag(token, "LINKSOCKS_TOKEN")
76
+ actual_connector_token = get_env_or_flag(connector_token, "LINKSOCKS_CONNECTOR_TOKEN")
77
+ actual_socks_password = get_env_or_flag(socks_password, "LINKSOCKS_SOCKS_PASSWORD")
78
+
79
+ # Validate required token
80
+ try:
81
+ validated_token = validate_required_token(actual_token)
82
+ except ValueError as e:
83
+ raise click.ClickException(str(e)) from e
84
+
85
+ # Setup logging
86
+ if debug == 0:
87
+ log_level = logging.INFO
88
+ elif debug == 1:
89
+ log_level = logging.DEBUG
90
+ else:
91
+ log_level = 5 # TRACE level
92
+
93
+ init_logging(level=log_level)
94
+
95
+ # Parse upstream proxy
96
+ if upstream_proxy:
97
+ try:
98
+ upstream_host, upstream_username, upstream_password = parse_socks_proxy(upstream_proxy)
99
+ except ValueError as e:
100
+ raise click.ClickException(f"Invalid upstream proxy: {e}") from e
101
+ else:
102
+ upstream_host = upstream_username = upstream_password = None
103
+
104
+ # Create client
105
+ client = Client(
106
+ ws_url=url,
107
+ token=validated_token,
108
+ reverse=reverse,
109
+ socks_host=socks_host,
110
+ socks_port=socks_port,
111
+ socks_username=socks_username,
112
+ socks_password=actual_socks_password,
113
+ socks_wait_server=not socks_no_wait,
114
+ reconnect=not no_reconnect,
115
+ upstream_proxy=upstream_host,
116
+ upstream_username=upstream_username,
117
+ upstream_password=upstream_password,
118
+ threads=threads,
119
+ fast_open=fast_open,
120
+ no_env_proxy=no_env_proxy,
121
+ )
122
+
123
+ # Add connector for reverse mode
124
+ if actual_connector_token and reverse:
125
+ await client.async_add_connector(actual_connector_token)
126
+ await asyncio.sleep(0.2)
127
+
128
+ # Run client
129
+ async with client:
130
+ await asyncio.Future()
131
+
132
+ asyncio.run(main())
133
+
134
+
135
+ @click.command()
136
+ @click.option("--ws-host", "-H", default="0.0.0.0", help="WebSocket server listen address")
137
+ @click.option("--ws-port", "-P", default=8765, help="WebSocket server listen port")
138
+ @click.option("--token", "-t", default=None, help="Auth token, auto-generated if not provided (env: LINKSOCKS_TOKEN)")
139
+ @click.option("--connector-token", "-c", default=None, help="Connector token for reverse proxy (env: LINKSOCKS_CONNECTOR_TOKEN)")
140
+ @click.option("--connector-autonomy", "-a", is_flag=True, default=False, help="Allow clients to manage connector tokens")
141
+ @click.option("--buffer-size", "-b", default=4096, help="Buffer size for data transfer")
142
+ @click.option("--reverse", "-r", is_flag=True, default=False, help="Use reverse SOCKS5 proxy")
143
+ @click.option("--socks-host", "-s", default="127.0.0.1", help="SOCKS5 server listen address for reverse proxy")
144
+ @click.option("--socks-port", "-p", default=9870, help="SOCKS5 server listen port for reverse proxy")
145
+ @click.option("--socks-username", "-n", help="SOCKS5 username for authentication")
146
+ @click.option("--socks-password", "-w", help="SOCKS5 password for authentication (env: LINKSOCKS_SOCKS_PASSWORD)")
147
+ @click.option("--socks-nowait", "-i", is_flag=True, default=False, help="Start SOCKS server immediately")
148
+ @click.option("--debug", "-d", count=True, help="Debug logging (-d for debug, -dd for trace)")
149
+ @click.option("--api-key", "-k", help="Enable HTTP API with specified key")
150
+ @click.option("--upstream-proxy", "-x", help="Upstream SOCKS5 proxy (socks5://[user:pass@]host[:port])")
151
+ @click.option("--fast-open", "-f", is_flag=True, default=False, help="Assume connection success and allow data transfer immediately")
152
+ def server(
153
+ ws_host: str,
154
+ ws_port: int,
155
+ token: Optional[str],
156
+ connector_token: Optional[str],
157
+ connector_autonomy: bool,
158
+ buffer_size: int,
159
+ reverse: bool,
160
+ socks_host: str,
161
+ socks_port: int,
162
+ socks_username: Optional[str],
163
+ socks_password: Optional[str],
164
+ socks_nowait: bool,
165
+ debug: int,
166
+ api_key: Optional[str],
167
+ upstream_proxy: Optional[str],
168
+ fast_open: bool,
169
+ ):
170
+ """Start SOCKS5 over WebSocket proxy server."""
171
+ from ._server import Server
172
+
173
+ async def main():
174
+ # Get values from flags or environment
175
+ actual_token = get_env_or_flag(token, "LINKSOCKS_TOKEN")
176
+ actual_connector_token = get_env_or_flag(connector_token, "LINKSOCKS_CONNECTOR_TOKEN")
177
+ actual_socks_password = get_env_or_flag(socks_password, "LINKSOCKS_SOCKS_PASSWORD")
178
+
179
+ # Setup logging
180
+ if debug == 0:
181
+ log_level = logging.INFO
182
+ elif debug == 1:
183
+ log_level = logging.DEBUG
184
+ else:
185
+ log_level = 5 # TRACE level
186
+
187
+ init_logging(level=log_level)
188
+
189
+ # Parse upstream proxy
190
+ if upstream_proxy:
191
+ try:
192
+ upstream_host, upstream_username, upstream_password = parse_socks_proxy(upstream_proxy)
193
+ except ValueError as e:
194
+ raise click.ClickException(f"Invalid upstream proxy: {e}") from e
195
+ else:
196
+ upstream_host = upstream_username = upstream_password = None
197
+
198
+ # Create server
199
+ server = Server(
200
+ ws_host=ws_host,
201
+ ws_port=ws_port,
202
+ socks_host=socks_host,
203
+ socks_wait_client=not socks_nowait,
204
+ upstream_proxy=upstream_host,
205
+ upstream_username=upstream_username,
206
+ upstream_password=upstream_password,
207
+ buffer_size=buffer_size,
208
+ api_key=api_key,
209
+ fast_open=fast_open,
210
+ )
211
+
212
+ # Configure tokens if no API key
213
+ if not api_key:
214
+ if reverse:
215
+ result = await server.async_add_reverse_token(
216
+ token=actual_token,
217
+ port=socks_port,
218
+ username=socks_username,
219
+ password=actual_socks_password,
220
+ allow_manage_connector=connector_autonomy,
221
+ )
222
+ use_token = result.token
223
+
224
+ if not connector_autonomy:
225
+ use_connector_token = await server.async_add_connector_token(
226
+ actual_connector_token, use_token
227
+ )
228
+ await asyncio.sleep(0.2)
229
+
230
+ server.log.info("Configuration:")
231
+ server.log.info(" Mode: reverse proxy (SOCKS5 on server -> client -> network)")
232
+ server.log.info(f" Token: {use_token}")
233
+ server.log.info(f" SOCKS5 port: {result.port}")
234
+ if socks_username and actual_socks_password:
235
+ server.log.info(f" SOCKS5 username: {socks_username}")
236
+ if not connector_autonomy:
237
+ server.log.info(f" Connector Token: {use_connector_token}")
238
+ if connector_autonomy:
239
+ server.log.info(" Connector autonomy: enabled")
240
+ else:
241
+ use_token = await server.async_add_forward_token(actual_token)
242
+ await asyncio.sleep(0.2)
243
+ server.log.info("Configuration:")
244
+ server.log.info(" Mode: forward proxy (SOCKS5 on client -> server -> network)")
245
+ server.log.info(f" Token: {use_token}")
246
+
247
+ # Run server
248
+ async with server:
249
+ await asyncio.Future()
250
+
251
+ asyncio.run(main())
252
+
253
+
254
+ def create_provider_command():
255
+ """Create provider command as alias for client -r."""
256
+ def provider_wrapper(*args, **kwargs):
257
+ kwargs['reverse'] = True
258
+ return client(*args, **kwargs)
259
+
260
+ return click.Command(
261
+ name="provider",
262
+ callback=provider_wrapper,
263
+ params=client.params.copy(),
264
+ help="Start reverse SOCKS5 proxy client (alias for 'client -r')"
265
+ )
266
+
267
+
268
+ def create_connector_command():
269
+ """Create connector command as alias for client."""
270
+ return click.Command(
271
+ name="connector",
272
+ callback=client,
273
+ params=client.params.copy(),
274
+ help="Start SOCKS5 proxy client (alias for 'client')"
275
+ )
276
+
277
+
278
+ # Create command aliases
279
+ provider_cmd = create_provider_command()
280
+ connector_cmd = create_connector_command()
281
+
282
+ # Register commands
283
+ cli.add_command(client)
284
+ cli.add_command(connector_cmd)
285
+ cli.add_command(provider_cmd)
286
+ cli.add_command(server)
287
+ cli.add_command(version)
288
+
289
+
290
+ if __name__ == "__main__":
291
+ cli()