linksocks 1.7.14__py3-none-win_amd64.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.
linksocks/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """linksocks: SOCKS5 over WebSocket proxy library."""
2
+
3
+ __version__ = "1.7.14"
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,509 @@
1
+ """Base classes and utilities for linksocks.
2
+
3
+ This module contains shared functionality used by Server and Client classes.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import logging
10
+ import threading
11
+ import time
12
+ from dataclasses import dataclass
13
+ from datetime import timedelta
14
+ from typing import Any, Callable, Dict, List, Optional, Union
15
+
16
+ _BACKEND: str
17
+
18
+ from linksocks_ffi import cancel_log_waiters as _ffi_cancel_log_waiters
19
+ from linksocks_ffi import parse_duration as _ffi_parse_duration
20
+ from linksocks_ffi import seconds as _ffi_seconds
21
+ from linksocks_ffi import wait_for_log_entries as _ffi_wait_for_log_entries
22
+
23
+ _BACKEND = "ffi"
24
+
25
+ _logger = logging.getLogger(__name__)
26
+
27
+ # Type aliases
28
+ DurationLike = Union[int, float, timedelta, str]
29
+
30
+
31
+ def _snake_to_camel(name: str) -> str:
32
+ """Convert snake_case to CamelCase."""
33
+ parts = name.split("_")
34
+ return "".join(p.capitalize() for p in parts if p)
35
+
36
+
37
+ def _camel_to_snake(name: str) -> str:
38
+ """Convert CamelCase to snake_case."""
39
+ out: List[str] = []
40
+ for ch in name:
41
+ if ch.isupper() and out:
42
+ out.append("_")
43
+ out.append(ch.lower())
44
+ return "".join(out)
45
+
46
+
47
+ def _to_duration(value: Optional[DurationLike]) -> Any:
48
+ """Convert seconds/str/timedelta to Go time.Duration via bindings.
49
+
50
+ - None -> 0
51
+ - int/float -> seconds (supports fractions)
52
+ - timedelta -> total seconds
53
+ - str -> parsed by Go (e.g., "1.5s", "300ms")
54
+
55
+ Returns an int since Go's time.Duration is int64.
56
+ """
57
+ if value is None:
58
+ return 0
59
+ if isinstance(value, timedelta):
60
+ seconds = value.total_seconds()
61
+ return int(seconds * int(_ffi_seconds()))
62
+ if isinstance(value, (int, float)):
63
+ return int(value * int(_ffi_seconds()))
64
+ if isinstance(value, str):
65
+ try:
66
+ return int(_ffi_parse_duration(value))
67
+ except Exception as exc:
68
+ raise ValueError(f"Invalid duration string: {value}") from exc
69
+ raise TypeError(f"Unsupported duration type: {type(value)!r}")
70
+
71
+
72
+ class _NoopLogger:
73
+ def __call__(self, *args: Any, **kwargs: Any) -> None:
74
+ return None
75
+
76
+
77
+ class _DummyManagedLogger:
78
+ def __init__(self, py_logger: logging.Logger, logger_id: str):
79
+ self.py_logger = py_logger
80
+ self.logger_id = logger_id
81
+ self.go_logger = _NoopLogger()
82
+
83
+ def cleanup(self) -> None:
84
+ return None
85
+
86
+
87
+ def _json_key(snake: str) -> str:
88
+ return snake
89
+
90
+
91
+ class _FFIReverseTokenOptions:
92
+ def __init__(self) -> None:
93
+ self.Token = ""
94
+ self.Port: Optional[int] = None
95
+ self.Username = ""
96
+ self.Password = ""
97
+ self.AllowManageConnector: Optional[bool] = None
98
+
99
+
100
+ class _FFIServerOption:
101
+ def __init__(self) -> None:
102
+ self._cfg: Dict[str, Any] = {}
103
+
104
+ def WithLogger(self, logger: Any) -> None:
105
+ logger_id: Optional[str] = None
106
+ if isinstance(logger, str):
107
+ logger_id = logger
108
+ else:
109
+ logger_id = getattr(logger, "logger_id", None)
110
+ if logger_id:
111
+ self._cfg[_json_key("logger_id")] = str(logger_id)
112
+
113
+ def WithWSHost(self, v: str) -> None:
114
+ self._cfg[_json_key("ws_host")] = v
115
+
116
+ def WithWSPort(self, v: int) -> None:
117
+ self._cfg[_json_key("ws_port")] = int(v)
118
+
119
+ def WithSocksHost(self, v: str) -> None:
120
+ self._cfg[_json_key("socks_host")] = v
121
+
122
+ def WithSocksWaitClient(self, v: bool) -> None:
123
+ self._cfg[_json_key("socks_wait_client")] = bool(v)
124
+
125
+ def WithPortPool(self, _v: Any) -> None:
126
+ raise NotImplementedError("port_pool is not supported by the cffi backend")
127
+
128
+ def WithBufferSize(self, v: int) -> None:
129
+ self._cfg[_json_key("buffer_size")] = int(v)
130
+
131
+ def WithAPI(self, v: str) -> None:
132
+ self._cfg[_json_key("api_key")] = v
133
+
134
+ def WithChannelTimeout(self, v: int) -> None:
135
+ self._cfg[_json_key("channel_timeout_ns")] = int(v)
136
+
137
+ def WithConnectTimeout(self, v: int) -> None:
138
+ self._cfg[_json_key("connect_timeout_ns")] = int(v)
139
+
140
+ def WithFastOpen(self, v: bool) -> None:
141
+ self._cfg[_json_key("fast_open")] = bool(v)
142
+
143
+ def WithUpstreamProxy(self, v: str) -> None:
144
+ self._cfg[_json_key("upstream_proxy")] = v
145
+
146
+ def WithUpstreamAuth(self, username: str, password: str) -> None:
147
+ self._cfg[_json_key("upstream_username")] = username
148
+ self._cfg[_json_key("upstream_password")] = password
149
+
150
+ def to_cfg(self) -> Dict[str, Any]:
151
+ return dict(self._cfg)
152
+
153
+
154
+ class _FFIClientOption:
155
+ def __init__(self) -> None:
156
+ self._cfg: Dict[str, Any] = {}
157
+
158
+ def WithLogger(self, logger: Any) -> None:
159
+ logger_id: Optional[str] = None
160
+ if isinstance(logger, str):
161
+ logger_id = logger
162
+ else:
163
+ logger_id = getattr(logger, "logger_id", None)
164
+ if logger_id:
165
+ self._cfg[_json_key("logger_id")] = str(logger_id)
166
+
167
+ def WithWSURL(self, v: str) -> None:
168
+ self._cfg[_json_key("ws_url")] = v
169
+
170
+ def WithReverse(self, v: bool) -> None:
171
+ self._cfg[_json_key("reverse")] = bool(v)
172
+
173
+ def WithSocksHost(self, v: str) -> None:
174
+ self._cfg[_json_key("socks_host")] = v
175
+
176
+ def WithSocksPort(self, v: int) -> None:
177
+ self._cfg[_json_key("socks_port")] = int(v)
178
+
179
+ def WithSocksUsername(self, v: str) -> None:
180
+ self._cfg[_json_key("socks_username")] = v
181
+
182
+ def WithSocksPassword(self, v: str) -> None:
183
+ self._cfg[_json_key("socks_password")] = v
184
+
185
+ def WithSocksWaitServer(self, v: bool) -> None:
186
+ self._cfg[_json_key("socks_wait_server")] = bool(v)
187
+
188
+ def WithReconnect(self, v: bool) -> None:
189
+ self._cfg[_json_key("reconnect")] = bool(v)
190
+
191
+ def WithReconnectDelay(self, v: int) -> None:
192
+ self._cfg[_json_key("reconnect_delay_ns")] = int(v)
193
+
194
+ def WithBufferSize(self, v: int) -> None:
195
+ self._cfg[_json_key("buffer_size")] = int(v)
196
+
197
+ def WithChannelTimeout(self, v: int) -> None:
198
+ self._cfg[_json_key("channel_timeout_ns")] = int(v)
199
+
200
+ def WithConnectTimeout(self, v: int) -> None:
201
+ self._cfg[_json_key("connect_timeout_ns")] = int(v)
202
+
203
+ def WithThreads(self, v: int) -> None:
204
+ self._cfg[_json_key("threads")] = int(v)
205
+
206
+ def WithFastOpen(self, v: bool) -> None:
207
+ self._cfg[_json_key("fast_open")] = bool(v)
208
+
209
+ def WithUpstreamProxy(self, v: str) -> None:
210
+ self._cfg[_json_key("upstream_proxy")] = v
211
+
212
+ def WithUpstreamAuth(self, username: str, password: str) -> None:
213
+ self._cfg[_json_key("upstream_username")] = username
214
+ self._cfg[_json_key("upstream_password")] = password
215
+
216
+ def WithNoEnvProxy(self, v: bool) -> None:
217
+ self._cfg[_json_key("no_env_proxy")] = bool(v)
218
+
219
+ def to_cfg(self) -> Dict[str, Any]:
220
+ return dict(self._cfg)
221
+
222
+
223
+ class _FFIRawServer:
224
+ def __init__(self, cfg: Dict[str, Any]):
225
+ from linksocks_ffi import Server as FFIServer
226
+
227
+ self._srv = FFIServer(cfg)
228
+
229
+ def WaitReady(self, *, ctx: Any = None, timeout: int = 0) -> None:
230
+ self._srv.wait_ready(int(timeout))
231
+
232
+ def Close(self) -> None:
233
+ self._srv.close()
234
+
235
+ def AddForwardToken(self, token: str = "") -> str:
236
+ return self._srv.add_forward_token(token or "")
237
+
238
+ def AddReverseToken(self, opts: Any) -> Any:
239
+ payload: Dict[str, Any] = {}
240
+ token = getattr(opts, "Token", "")
241
+ if token:
242
+ payload["token"] = token
243
+ port = getattr(opts, "Port", None)
244
+ if port is not None:
245
+ payload["port"] = int(port)
246
+ username = getattr(opts, "Username", "")
247
+ if username:
248
+ payload["username"] = username
249
+ password = getattr(opts, "Password", "")
250
+ if password:
251
+ payload["password"] = password
252
+ allow = getattr(opts, "AllowManageConnector", None)
253
+ if allow is not None:
254
+ payload["allow_manage_connector"] = bool(allow)
255
+ return self._srv.add_reverse_token(payload)
256
+
257
+ def RemoveToken(self, token: str) -> bool:
258
+ return bool(self._srv.remove_token(token))
259
+
260
+ def AddConnectorToken(self, connector: str, reverse_token: str) -> str:
261
+ return self._srv.add_connector_token(connector, reverse_token)
262
+
263
+
264
+ class _FFIRawClient:
265
+ def __init__(self, token: str, cfg: Dict[str, Any]):
266
+ from linksocks_ffi import Client as FFIClient
267
+
268
+ self.SocksPort = cfg.get("socks_port")
269
+ self._cli = FFIClient(token, cfg)
270
+
271
+ def WaitReady(self, *, ctx: Any = None, timeout: int = 0) -> None:
272
+ self._cli.wait_ready(int(timeout))
273
+
274
+ def Close(self) -> None:
275
+ self._cli.close()
276
+
277
+ def AddConnector(self, token: str) -> str:
278
+ return self._cli.add_connector(token)
279
+
280
+
281
+ class _FFIContextWithCancel:
282
+ def __init__(self) -> None:
283
+ self._cancelled = False
284
+
285
+ def Context(self) -> None:
286
+ return None
287
+
288
+ def Cancel(self) -> None:
289
+ self._cancelled = True
290
+
291
+
292
+ class _FFIBackend:
293
+ def DefaultServerOption(self) -> Any:
294
+ return _FFIServerOption()
295
+
296
+ def DefaultClientOption(self) -> Any:
297
+ return _FFIClientOption()
298
+
299
+ def DefaultReverseTokenOptions(self) -> Any:
300
+ return _FFIReverseTokenOptions()
301
+
302
+ def NewLinkSocksServer(self, opt: Any) -> Any:
303
+ return _FFIRawServer(opt.to_cfg())
304
+
305
+ def NewLinkSocksClient(self, token: str, opt: Any) -> Any:
306
+ return _FFIRawClient(token, opt.to_cfg())
307
+
308
+ def NewContextWithCancel(self) -> Any:
309
+ return _FFIContextWithCancel()
310
+
311
+ def NewLoggerWithID(self, _logger_id: str) -> Any:
312
+ return str(_logger_id)
313
+
314
+ def NewLogger(self, _cb: Any) -> Any:
315
+ return f"logger_{id(_cb)}"
316
+
317
+ def WaitForLogEntries(self, ms: int) -> list[Any]:
318
+ try:
319
+ entries = _ffi_wait_for_log_entries(int(ms)) # type: ignore[misc]
320
+ if not entries:
321
+ return []
322
+ out: List[Any] = []
323
+ for e in entries:
324
+ if isinstance(e, dict):
325
+ out.append({
326
+ "LoggerID": e.get("LoggerID") or e.get("logger_id") or "",
327
+ "Message": e.get("Message") or e.get("message") or "",
328
+ "Time": e.get("Time") or e.get("time") or 0,
329
+ })
330
+ return out
331
+ except Exception:
332
+ time.sleep(max(int(ms), 1) / 1000.0)
333
+ return []
334
+
335
+ def CancelLogWaiters(self) -> None:
336
+ try:
337
+ _ffi_cancel_log_waiters() # type: ignore[misc]
338
+ except Exception:
339
+ return None
340
+
341
+ def Second(self) -> int:
342
+ return int(_ffi_seconds()) # type: ignore[misc]
343
+
344
+ def ParseDuration(self, s: str) -> int:
345
+ return int(_ffi_parse_duration(s)) # type: ignore[misc]
346
+
347
+
348
+ backend = _FFIBackend()
349
+
350
+
351
+ # Shared Go->Python log dispatcher
352
+ _def_level_map = {
353
+ "trace": logging.DEBUG,
354
+ "debug": logging.DEBUG,
355
+ "info": logging.INFO,
356
+ "warn": logging.WARNING,
357
+ "warning": logging.WARNING,
358
+ "error": logging.ERROR,
359
+ "fatal": logging.CRITICAL,
360
+ "panic": logging.CRITICAL,
361
+ }
362
+
363
+
364
+ def _emit_go_log(py_logger: logging.Logger, line: str) -> None:
365
+ """Process a Go log line and emit it to the Python logger."""
366
+ try:
367
+ obj = json.loads(line)
368
+ except Exception:
369
+ py_logger.info(line)
370
+ return
371
+ level = str(obj.get("level", "")).lower()
372
+ message = obj.get("message") or obj.get("msg") or ""
373
+ extras: Dict[str, Any] = {}
374
+ for k, v in obj.items():
375
+ if k in ("level", "time", "message", "msg"):
376
+ continue
377
+ extras[k] = v
378
+ py_logger.log(_def_level_map.get(level, logging.INFO), message, extra={"go": extras})
379
+
380
+
381
+ # Global registry for logger instances
382
+ _logger_registry: Dict[str, logging.Logger] = {}
383
+
384
+ # Event-driven log monitoring system
385
+ _log_listeners: List[Callable[[List], None]] = []
386
+ _listener_thread: Optional[threading.Thread] = None
387
+ _listener_active: bool = False
388
+
389
+
390
+ def _start_log_listener() -> None:
391
+ """Start background thread to drain Go log buffer and forward to Python loggers."""
392
+ global _listener_thread, _listener_active
393
+ if _listener_active and _listener_thread and _listener_thread.is_alive():
394
+ return
395
+ _listener_active = True
396
+
397
+ def _run() -> None:
398
+ # Drain loop: wait for entries with timeout to allow graceful shutdown
399
+ while _listener_active:
400
+ entries = backend.WaitForLogEntries(2000)
401
+
402
+ if not entries:
403
+ continue
404
+
405
+ # Iterate returned entries; handle both attr and dict styles
406
+ for entry in entries:
407
+ try:
408
+ logger_id = getattr(entry, "LoggerID", None)
409
+ if logger_id is None and isinstance(entry, dict):
410
+ logger_id = entry.get("LoggerID")
411
+
412
+ message = getattr(entry, "Message", None)
413
+ if message is None and isinstance(entry, dict):
414
+ message = entry.get("Message")
415
+
416
+ if not message:
417
+ continue
418
+
419
+ py_logger = _logger_registry.get(str(logger_id)) or _logger
420
+ _emit_go_log(py_logger, str(message))
421
+ except Exception:
422
+ # Never let logging path crash the listener
423
+ continue
424
+
425
+ _listener_thread = threading.Thread(target=_run, name="linksocks-go-log-listener", daemon=True)
426
+ _listener_thread.start()
427
+
428
+
429
+ def _stop_log_listener() -> None:
430
+ """Stop the background log listener thread."""
431
+ global _listener_active
432
+ _listener_active = False
433
+ try:
434
+ backend.CancelLogWaiters()
435
+ except Exception:
436
+ pass
437
+
438
+
439
+ class BufferZerologLogger:
440
+ """Buffer-based logger system for Go bindings."""
441
+
442
+ def __init__(self, py_logger: logging.Logger, logger_id: str):
443
+ self.py_logger = py_logger
444
+ self.logger_id = logger_id
445
+ # Ensure background listener is running
446
+ _start_log_listener()
447
+
448
+ # Prefer Go logger with explicit ID so we can map entries back
449
+ try:
450
+ # Newer binding that tags entries with our provided ID
451
+ self.go_logger = backend.NewLoggerWithID(self.logger_id)
452
+ except Exception:
453
+ # Fallback to older API; if present, still try callback path
454
+ try:
455
+ def log_callback(line: str) -> None:
456
+ _emit_go_log(py_logger, line)
457
+
458
+ self.go_logger = backend.NewLogger(log_callback)
459
+ except Exception:
460
+ # As a last resort, create a default Go logger
461
+ self.go_logger = backend.NewLoggerWithID(self.logger_id) # may still raise; surface to caller
462
+ _logger_registry[logger_id] = py_logger
463
+
464
+ def cleanup(self):
465
+ """Clean up logger resources."""
466
+ if self.logger_id in _logger_registry:
467
+ del _logger_registry[self.logger_id]
468
+
469
+
470
+ @dataclass
471
+ class ReverseTokenResult:
472
+ """Result of adding a reverse token."""
473
+ token: str
474
+ port: int
475
+
476
+
477
+ class _SnakePassthrough:
478
+ """Mixin to map snake_case attribute access to underlying CamelCase.
479
+
480
+ Only used when an explicit Pythonic method/attribute is not defined.
481
+ """
482
+
483
+ def __getattr__(self, name: str) -> Any:
484
+ raw = super().__getattribute__("_raw") # type: ignore[attr-defined]
485
+ camel = _snake_to_camel(name)
486
+ try:
487
+ return getattr(raw, camel)
488
+ except AttributeError:
489
+ raise
490
+
491
+ def __dir__(self) -> List[str]:
492
+ # Expose snake_case versions of underlying CamelCase for IDEs
493
+ names = set(super().__dir__())
494
+ try:
495
+ raw = super().__getattribute__("_raw") # type: ignore[attr-defined]
496
+ for attr in dir(raw):
497
+ if not attr or attr.startswith("_"):
498
+ continue
499
+ names.add(_camel_to_snake(attr))
500
+ except Exception:
501
+ pass
502
+ return sorted(names)
503
+
504
+
505
+ def set_log_level(level: Union[int, str]) -> None:
506
+ """Set the global log level for linksocks."""
507
+ if isinstance(level, str):
508
+ level = getattr(logging, level.upper())
509
+ _logger.setLevel(level)