linksocks 3.0.13__cp310-cp310-macosx_14_0_universal2.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/_server.py ADDED
@@ -0,0 +1,355 @@
1
+ """
2
+ WebSocket SOCKS5 proxy server implementation.
3
+
4
+ This module provides the Server class for running a SOCKS5 proxy server
5
+ that communicates with clients over WebSocket connections.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import logging
12
+ from typing import Any, Optional
13
+
14
+ # Underlying Go bindings module (generated)
15
+ from linksockslib import linksocks # type: ignore
16
+
17
+ from ._base import (
18
+ _SnakePassthrough,
19
+ _to_duration,
20
+ _logger,
21
+ BufferZerologLogger,
22
+ ReverseTokenResult,
23
+ DurationLike,
24
+ )
25
+
26
+
27
+ class Server(_SnakePassthrough):
28
+ """WebSocket SOCKS5 proxy server.
29
+
30
+ The Server class manages WebSocket connections from clients and provides
31
+ SOCKS5 proxy functionality. It supports both forward and reverse proxy modes.
32
+
33
+ In forward mode, clients connect to the server and the server makes outbound
34
+ connections on behalf of the clients.
35
+
36
+ In reverse mode, the server runs a local SOCKS5 server and forwards connections
37
+ through WebSocket to clients, which then connect to targets directly.
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ *,
43
+ logger: Optional[logging.Logger] = None,
44
+ ws_host: Optional[str] = None,
45
+ ws_port: Optional[int] = None,
46
+ socks_host: Optional[str] = None,
47
+ port_pool: Optional[Any] = None,
48
+ socks_wait_client: Optional[bool] = None,
49
+ buffer_size: Optional[int] = None,
50
+ api_key: Optional[str] = None,
51
+ channel_timeout: Optional[DurationLike] = None,
52
+ connect_timeout: Optional[DurationLike] = None,
53
+ fast_open: Optional[bool] = None,
54
+ upstream_proxy: Optional[str] = None,
55
+ upstream_username: Optional[str] = None,
56
+ upstream_password: Optional[str] = None,
57
+ ) -> None:
58
+ """Initialize the WebSocket SOCKS5 proxy server.
59
+
60
+ Args:
61
+ logger: Python logger instance for this server
62
+ ws_host: WebSocket server listen address
63
+ ws_port: WebSocket server listen port
64
+ socks_host: SOCKS5 server listen address (for reverse mode)
65
+ port_pool: Pool of ports for SOCKS5 servers
66
+ socks_wait_client: Whether to wait for client connections before starting SOCKS5
67
+ buffer_size: Buffer size for data transfer
68
+ api_key: API key for HTTP management interface
69
+ channel_timeout: Timeout for WebSocket channels
70
+ connect_timeout: Timeout for outbound connections
71
+ fast_open: Assume connection success and allow data transfer immediately
72
+ upstream_proxy: Upstream proxy address for chaining
73
+ upstream_username: Username for upstream proxy authentication
74
+ upstream_password: Password for upstream proxy authentication
75
+ """
76
+ opt = linksocks.DefaultServerOption()
77
+ if logger is None:
78
+ logger = _logger
79
+ # Use buffer-based logger system
80
+ self._managed_logger = BufferZerologLogger(logger, f"server_{id(self)}")
81
+ opt.WithLogger(self._managed_logger.go_logger)
82
+ if ws_host is not None:
83
+ opt.WithWSHost(ws_host)
84
+ if ws_port is not None:
85
+ opt.WithWSPort(ws_port)
86
+ if socks_host is not None:
87
+ opt.WithSocksHost(socks_host)
88
+ if port_pool is not None:
89
+ opt.WithPortPool(port_pool)
90
+ if socks_wait_client is not None:
91
+ opt.WithSocksWaitClient(bool(socks_wait_client))
92
+ if buffer_size is not None:
93
+ opt.WithBufferSize(int(buffer_size))
94
+ if api_key is not None:
95
+ opt.WithAPI(api_key)
96
+ if channel_timeout is not None:
97
+ opt.WithChannelTimeout(_to_duration(channel_timeout))
98
+ if connect_timeout is not None:
99
+ opt.WithConnectTimeout(_to_duration(connect_timeout))
100
+ if fast_open is not None:
101
+ opt.WithFastOpen(bool(fast_open))
102
+ if upstream_proxy is not None:
103
+ opt.WithUpstreamProxy(upstream_proxy)
104
+ if upstream_username or upstream_password:
105
+ opt.WithUpstreamAuth(upstream_username or "", upstream_password or "")
106
+
107
+ self._raw = linksocks.NewLinkSocksServer(opt)
108
+ self._ctx = None
109
+
110
+ @property
111
+ def log(self) -> logging.Logger:
112
+ """Access the Python logger for this server instance."""
113
+ return self._managed_logger.py_logger
114
+
115
+ def add_forward_token(self, token: Optional[str] = None) -> str:
116
+ """Add a forward proxy token.
117
+
118
+ Args:
119
+ token: Token string, auto-generated if not provided
120
+
121
+ Returns:
122
+ The token string (generated or provided)
123
+ """
124
+ return self._raw.AddForwardToken(token or "")
125
+
126
+ async def async_add_forward_token(self, token: Optional[str] = None) -> str:
127
+ """Add a forward proxy token asynchronously.
128
+
129
+ Args:
130
+ token: Token string, auto-generated if not provided
131
+
132
+ Returns:
133
+ The token string (generated or provided)
134
+ """
135
+ return await asyncio.to_thread(self._raw.AddForwardToken, token or "")
136
+
137
+ def add_reverse_token(
138
+ self,
139
+ *,
140
+ token: Optional[str] = None,
141
+ port: Optional[int] = None,
142
+ username: Optional[str] = None,
143
+ password: Optional[str] = None,
144
+ allow_manage_connector: Optional[bool] = None,
145
+ ) -> ReverseTokenResult:
146
+ """Add a reverse proxy token.
147
+
148
+ Args:
149
+ token: Token string, auto-generated if not provided
150
+ port: SOCKS5 server port, auto-assigned if not provided
151
+ username: SOCKS5 authentication username
152
+ password: SOCKS5 authentication password
153
+ allow_manage_connector: Whether to allow clients to manage connector tokens
154
+
155
+ Returns:
156
+ Result containing the token and assigned port
157
+ """
158
+ opts = linksocks.DefaultReverseTokenOptions()
159
+ if token:
160
+ opts.Token = token
161
+ if port is not None:
162
+ opts.Port = int(port)
163
+ if username is not None:
164
+ opts.Username = username
165
+ if password is not None:
166
+ opts.Password = password
167
+ if allow_manage_connector is not None:
168
+ opts.AllowManageConnector = bool(allow_manage_connector)
169
+ result = self._raw.AddReverseToken(opts)
170
+ return ReverseTokenResult(token=result.Token, port=result.Port)
171
+
172
+ async def async_add_reverse_token(
173
+ self,
174
+ *,
175
+ token: Optional[str] = None,
176
+ port: Optional[int] = None,
177
+ username: Optional[str] = None,
178
+ password: Optional[str] = None,
179
+ allow_manage_connector: Optional[bool] = None,
180
+ ) -> ReverseTokenResult:
181
+ """Add a reverse proxy token asynchronously.
182
+
183
+ Args:
184
+ token: Token string, auto-generated if not provided
185
+ port: SOCKS5 server port, auto-assigned if not provided
186
+ username: SOCKS5 authentication username
187
+ password: SOCKS5 authentication password
188
+ allow_manage_connector: Whether to allow clients to manage connector tokens
189
+
190
+ Returns:
191
+ Result containing the token and assigned port
192
+ """
193
+ opts = linksocks.DefaultReverseTokenOptions()
194
+ if token:
195
+ opts.Token = token
196
+ if port is not None:
197
+ opts.Port = int(port)
198
+ if username is not None:
199
+ opts.Username = username
200
+ if password is not None:
201
+ opts.Password = password
202
+ if allow_manage_connector is not None:
203
+ opts.AllowManageConnector = bool(allow_manage_connector)
204
+ result = await asyncio.to_thread(self._raw.AddReverseToken, opts)
205
+ return ReverseTokenResult(token=result.Token, port=result.Port)
206
+
207
+ def add_connector_token(self, connector_token: Optional[str], reverse_token: str) -> str:
208
+ """Add a connector token for reverse proxy.
209
+
210
+ Args:
211
+ connector_token: Connector token string, auto-generated if not provided
212
+ reverse_token: Associated reverse proxy token
213
+
214
+ Returns:
215
+ The connector token string (generated or provided)
216
+ """
217
+ return self._raw.AddConnectorToken(connector_token or "", reverse_token)
218
+
219
+ async def async_add_connector_token(self, connector_token: Optional[str], reverse_token: str) -> str:
220
+ """Add a connector token for reverse proxy asynchronously.
221
+
222
+ Args:
223
+ connector_token: Connector token string, auto-generated if not provided
224
+ reverse_token: Associated reverse proxy token
225
+
226
+ Returns:
227
+ The connector token string (generated or provided)
228
+ """
229
+ return await asyncio.to_thread(self._raw.AddConnectorToken, connector_token or "", reverse_token)
230
+
231
+ def remove_token(self, token: str) -> bool:
232
+ """Remove a token from the server.
233
+
234
+ Args:
235
+ token: Token to remove
236
+
237
+ Returns:
238
+ True if token was removed, False if not found
239
+ """
240
+ return self._raw.RemoveToken(token)
241
+
242
+ async def async_remove_token(self, token: str) -> bool:
243
+ """Remove a token from the server asynchronously.
244
+
245
+ Args:
246
+ token: Token to remove
247
+
248
+ Returns:
249
+ True if token was removed, False if not found
250
+ """
251
+ return await asyncio.to_thread(self._raw.RemoveToken, token)
252
+
253
+ def wait_ready(self, timeout: Optional[DurationLike] = None) -> None:
254
+ """Wait for the server to be ready.
255
+
256
+ Args:
257
+ timeout: Maximum time to wait, no timeout if None
258
+ """
259
+ if not self._ctx:
260
+ self._ctx = linksocks.NewContextWithCancel()
261
+ timeout = _to_duration(timeout) if timeout is not None else 0
262
+ self._raw.WaitReady(ctx=self._ctx.Context(), timeout=timeout)
263
+
264
+ async def async_wait_ready(self, timeout: Optional[DurationLike] = None) -> None:
265
+ """Wait for the server to be ready asynchronously.
266
+
267
+ Args:
268
+ timeout: Maximum time to wait, no timeout if None
269
+ """
270
+ if not self._ctx:
271
+ self._ctx = linksocks.NewContextWithCancel()
272
+ timeout = _to_duration(timeout) if timeout is not None else 0
273
+ try:
274
+ return await asyncio.to_thread(self._raw.WaitReady, ctx=self._ctx.Context(), timeout=timeout)
275
+ except asyncio.CancelledError:
276
+ # Ensure the underlying Go server stops when startup wait is cancelled
277
+ try:
278
+ try:
279
+ self._ctx.Cancel()
280
+ except Exception:
281
+ pass
282
+ await asyncio.shield(asyncio.to_thread(self._raw.Close))
283
+ if hasattr(self, '_managed_logger') and self._managed_logger:
284
+ try:
285
+ self._managed_logger.cleanup()
286
+ except Exception:
287
+ pass
288
+ finally:
289
+ raise
290
+
291
+ def close(self) -> None:
292
+ """Close the server and clean up resources."""
293
+ # Close server
294
+ if hasattr(self, '_raw') and self._raw:
295
+ self._raw.Close()
296
+ # Clean up managed logger
297
+ if hasattr(self, '_managed_logger') and self._managed_logger:
298
+ try:
299
+ self._managed_logger.cleanup()
300
+ except:
301
+ # Ignore cleanup errors
302
+ pass
303
+ # Close context
304
+ if hasattr(self, '_ctx') and self._ctx:
305
+ try:
306
+ self._ctx.Cancel()
307
+ except Exception:
308
+ # Ignore errors during context close
309
+ pass
310
+
311
+ async def async_close(self) -> None:
312
+ """Close the server and clean up resources asynchronously."""
313
+ # Close server
314
+ if hasattr(self, '_raw') and self._raw:
315
+ await asyncio.to_thread(self._raw.Close)
316
+ # Clean up managed logger
317
+ if hasattr(self, '_managed_logger') and self._managed_logger:
318
+ try:
319
+ self._managed_logger.cleanup()
320
+ except:
321
+ # Ignore cleanup errors
322
+ pass
323
+ # Close context
324
+ if hasattr(self, '_ctx') and self._ctx:
325
+ try:
326
+ self._ctx.Cancel()
327
+ except Exception:
328
+ # Ignore errors during context close
329
+ pass
330
+
331
+ def __enter__(self) -> "Server":
332
+ """Context manager entry."""
333
+ self.wait_ready()
334
+ return self
335
+
336
+ def __exit__(self, exc_type, exc, tb) -> None:
337
+ """Context manager exit."""
338
+ self.close()
339
+
340
+ async def __aenter__(self) -> "Server":
341
+ """Async context manager entry."""
342
+ await self.async_wait_ready()
343
+ return self
344
+
345
+ async def __aexit__(self, exc_type, exc, tb) -> None:
346
+ """Async context manager exit."""
347
+ await self.async_close()
348
+
349
+ def __del__(self):
350
+ """Destructor - clean up resources."""
351
+ try:
352
+ self.close()
353
+ except Exception:
354
+ # Ignore errors during cleanup
355
+ pass
linksocks/_utils.py ADDED
@@ -0,0 +1,144 @@
1
+ """
2
+ Utility functions for linksocks CLI.
3
+
4
+ This module provides common utility functions used across the linksocks
5
+ command-line interface, including SOCKS proxy URL parsing and environment
6
+ variable handling.
7
+ """
8
+
9
+ import os
10
+ import urllib.parse
11
+ from typing import Optional, Tuple
12
+
13
+
14
+ def parse_socks_proxy(proxy_url: str) -> Tuple[str, Optional[str], Optional[str]]:
15
+ """
16
+ Parse a SOCKS5 proxy URL and extract connection details.
17
+
18
+ This function parses SOCKS5 proxy URLs in the standard format and extracts
19
+ the host address, username, and password for authentication. It supports
20
+ URLs with optional authentication credentials and port numbers.
21
+
22
+ Args:
23
+ proxy_url: URL string in format socks5://[user:pass@]host[:port]
24
+ Examples:
25
+ - "socks5://127.0.0.1:9870"
26
+ - "socks5://user:pass@proxy.example.com:9870"
27
+ - "socks5://proxy.example.com" (uses default port 9870)
28
+
29
+ Returns:
30
+ A tuple containing:
31
+ - address (str): Host and port in "host:port" format
32
+ - username (str | None): Username for authentication, if provided
33
+ - password (str | None): Password for authentication, if provided
34
+
35
+ Raises:
36
+ ValueError: If the URL is invalid, malformed, or uses an unsupported scheme
37
+
38
+ Examples:
39
+ >>> parse_socks_proxy("socks5://127.0.0.1:9870")
40
+ ("127.0.0.1:9870", None, None)
41
+
42
+ >>> parse_socks_proxy("socks5://user:pass@proxy.example.com:9870")
43
+ ("proxy.example.com:9870", "user", "pass")
44
+
45
+ >>> parse_socks_proxy("socks5://proxy.example.com")
46
+ ("proxy.example.com:9870", None, None)
47
+ """
48
+ if not proxy_url:
49
+ return "", None, None
50
+
51
+ try:
52
+ parsed_url = urllib.parse.urlparse(proxy_url)
53
+ except Exception as e:
54
+ raise ValueError(f"Invalid proxy URL: {e}") from e
55
+
56
+ # Validate the URL scheme
57
+ if parsed_url.scheme != "socks5":
58
+ raise ValueError(f"Unsupported proxy scheme: {parsed_url.scheme}. Only 'socks5' is supported.")
59
+
60
+ # Extract authentication credentials if present
61
+ username = None
62
+ password = None
63
+ if parsed_url.username:
64
+ username = urllib.parse.unquote(parsed_url.username)
65
+ if parsed_url.password:
66
+ password = urllib.parse.unquote(parsed_url.password)
67
+
68
+ # Build the address with default port if not specified
69
+ host = parsed_url.hostname
70
+ if not host:
71
+ raise ValueError("Proxy URL must specify a hostname")
72
+
73
+ port = parsed_url.port or 9870 # Default SOCKS5 port
74
+ address = f"{host}:{port}"
75
+
76
+ return address, username, password
77
+
78
+
79
+ def get_env_or_flag(flag_value: Optional[str], env_var: str) -> Optional[str]:
80
+ """
81
+ Get a configuration value from command-line flag or environment variable.
82
+
83
+ This utility function implements the common pattern of checking for a value
84
+ in a command-line flag first, and falling back to an environment variable
85
+ if the flag is not provided. This allows users to configure the application
86
+ either through command-line arguments or environment variables.
87
+
88
+ Args:
89
+ flag_value: The value provided via command-line flag (may be None)
90
+ env_var: The name of the environment variable to check as fallback
91
+
92
+ Returns:
93
+ The configuration value from the flag if provided, otherwise from
94
+ the environment variable, or None if neither is set
95
+
96
+ Examples:
97
+ >>> os.environ['LINKSOCKS_TOKEN'] = 'env-token'
98
+ >>> get_env_or_flag(None, 'LINKSOCKS_TOKEN')
99
+ 'env-token'
100
+
101
+ >>> get_env_or_flag('flag-token', 'LINKSOCKS_TOKEN')
102
+ 'flag-token'
103
+
104
+ >>> get_env_or_flag(None, 'NONEXISTENT_VAR')
105
+ None
106
+
107
+ Note:
108
+ Command-line flags always take precedence over environment variables.
109
+ This allows users to override environment settings on a per-invocation basis.
110
+ """
111
+ if flag_value:
112
+ return flag_value
113
+ return os.getenv(env_var)
114
+
115
+
116
+ def validate_required_token(token: Optional[str], env_var: str = "LINKSOCKS_TOKEN") -> str:
117
+ """
118
+ Validate that a required authentication token is provided.
119
+
120
+ This function checks that an authentication token is available either from
121
+ a command-line flag or environment variable, and raises a descriptive error
122
+ if neither is provided.
123
+
124
+ Args:
125
+ token: The token value from command-line flag (may be None)
126
+ env_var: The environment variable name to check (default: "LINKSOCKS_TOKEN")
127
+
128
+ Returns:
129
+ The validated token string
130
+
131
+ Raises:
132
+ ValueError: If no token is provided via flag or environment variable
133
+
134
+ Example:
135
+ >>> validate_required_token("my-token")
136
+ "my-token"
137
+
138
+ >>> validate_required_token(None) # Will check LINKSOCKS_TOKEN env var
139
+ ValueError: Token is required. Provide via --token or LINKSOCKS_TOKEN environment variable.
140
+ """
141
+ actual_token = get_env_or_flag(token, env_var)
142
+ if not actual_token:
143
+ raise ValueError(f"Token is required. Provide via --token or {env_var} environment variable.")
144
+ return actual_token