plexo-cli 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.
- plexo/__init__.py +4 -0
- plexo/cli.py +190 -0
- plexo/core/__init__.py +1 -0
- plexo/core/adapter.py +139 -0
- plexo/core/client.py +148 -0
- plexo/core/engine.py +266 -0
- plexo/core/qos.py +76 -0
- plexo/core/sparse.py +92 -0
- plexo/proxy/__init__.py +1 -0
- plexo/proxy/server.py +170 -0
- plexo/torrent/__init__.py +1 -0
- plexo/torrent/bencode.py +62 -0
- plexo/torrent/client.py +225 -0
- plexo/torrent/metadata.py +117 -0
- plexo/ui/__init__.py +1 -0
- plexo/ui/dashboard.py +98 -0
- plexo_cli-0.1.0.dist-info/METADATA +91 -0
- plexo_cli-0.1.0.dist-info/RECORD +20 -0
- plexo_cli-0.1.0.dist-info/WHEEL +4 -0
- plexo_cli-0.1.0.dist-info/entry_points.txt +2 -0
plexo/core/engine.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Parallel multi-adapter bonding download engine.
|
|
2
|
+
|
|
3
|
+
Distributes file chunks across verified network adapters concurrently,
|
|
4
|
+
tracks live per-adapter throughput, and writes to disk via sparse file mapping.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import concurrent.futures
|
|
10
|
+
import queue
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Callable, List, Optional
|
|
16
|
+
from .adapter import NetworkAdapter, get_bonded_pool
|
|
17
|
+
from .client import BoundRangeClient, ProbeResult, probe_url
|
|
18
|
+
from .qos import QoSWatchdog
|
|
19
|
+
from .sparse import SparseFileWriter
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class DownloadChunk:
|
|
24
|
+
chunk_id: int
|
|
25
|
+
start_byte: int
|
|
26
|
+
end_byte: int
|
|
27
|
+
retries: int = 0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class ProgressUpdate:
|
|
32
|
+
total_bytes: int
|
|
33
|
+
downloaded_bytes: int
|
|
34
|
+
speed_bytes_sec: float
|
|
35
|
+
adapter_speeds: dict[str, float] # ip -> bytes/sec
|
|
36
|
+
is_completed: bool = False
|
|
37
|
+
filename: str = ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class BondingDownloadEngine:
|
|
41
|
+
"""Orchestrates multi-adapter parallel downloads."""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
url: str,
|
|
46
|
+
output_path: Optional[str | Path] = None,
|
|
47
|
+
adapters: Optional[List[NetworkAdapter]] = None,
|
|
48
|
+
chunk_size: int = 8 * 1024 * 1024, # 8 MB default chunk
|
|
49
|
+
workers_per_adapter: int = 2,
|
|
50
|
+
):
|
|
51
|
+
self.url = url
|
|
52
|
+
self.chunk_size = chunk_size
|
|
53
|
+
self.workers_per_adapter = workers_per_adapter
|
|
54
|
+
self.adapters = adapters or get_bonded_pool()
|
|
55
|
+
self.output_path = output_path
|
|
56
|
+
|
|
57
|
+
self.probe: Optional[ProbeResult] = None
|
|
58
|
+
self.watchdog = QoSWatchdog()
|
|
59
|
+
self._cancel_event = threading.Event()
|
|
60
|
+
self._completed_bytes = 0
|
|
61
|
+
self._bytes_lock = threading.Lock()
|
|
62
|
+
self._start_time = 0.0
|
|
63
|
+
|
|
64
|
+
def _prepare(self) -> ProbeResult:
|
|
65
|
+
"""Probes the remote server and sets output path."""
|
|
66
|
+
self.probe = probe_url(self.url)
|
|
67
|
+
|
|
68
|
+
if not self.output_path:
|
|
69
|
+
self.output_path = Path.cwd() / self.probe.filename
|
|
70
|
+
else:
|
|
71
|
+
self.output_path = Path(self.output_path)
|
|
72
|
+
if self.output_path.is_dir():
|
|
73
|
+
self.output_path = self.output_path / self.probe.filename
|
|
74
|
+
|
|
75
|
+
return self.probe
|
|
76
|
+
|
|
77
|
+
def execute(
|
|
78
|
+
self,
|
|
79
|
+
progress_callback: Optional[Callable[[ProgressUpdate], None]] = None,
|
|
80
|
+
) -> Path:
|
|
81
|
+
"""Executes the bonded download and returns final output file path."""
|
|
82
|
+
probe = self._prepare()
|
|
83
|
+
target_path = Path(self.output_path)
|
|
84
|
+
self._start_time = time.time()
|
|
85
|
+
|
|
86
|
+
# If file doesn't support ranges or file size unknown, fall back to single-stream
|
|
87
|
+
if not probe.accepts_ranges or probe.total_bytes <= 0:
|
|
88
|
+
return self._execute_single_stream(probe, target_path, progress_callback)
|
|
89
|
+
|
|
90
|
+
return self._execute_bonded_parallel(probe, target_path, progress_callback)
|
|
91
|
+
|
|
92
|
+
def _execute_single_stream(
|
|
93
|
+
self,
|
|
94
|
+
probe: ProbeResult,
|
|
95
|
+
target_path: Path,
|
|
96
|
+
progress_callback: Optional[Callable[[ProgressUpdate], None]],
|
|
97
|
+
) -> Path:
|
|
98
|
+
"""Fallback for non-range servers."""
|
|
99
|
+
adapter = self.adapters[0] if self.adapters else None
|
|
100
|
+
local_ip = adapter.ip_address if adapter else "0.0.0.0"
|
|
101
|
+
client = BoundRangeClient(local_ip=local_ip)
|
|
102
|
+
|
|
103
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
downloaded = 0
|
|
105
|
+
|
|
106
|
+
with open(target_path, "wb") as f:
|
|
107
|
+
for chunk in client.fetch_range(probe.url, 0, probe.total_bytes - 1 if probe.total_bytes > 0 else 0):
|
|
108
|
+
f.write(chunk)
|
|
109
|
+
downloaded += len(chunk)
|
|
110
|
+
if progress_callback:
|
|
111
|
+
elapsed = max(0.001, time.time() - self._start_time)
|
|
112
|
+
progress_callback(
|
|
113
|
+
ProgressUpdate(
|
|
114
|
+
total_bytes=probe.total_bytes or downloaded,
|
|
115
|
+
downloaded_bytes=downloaded,
|
|
116
|
+
speed_bytes_sec=downloaded / elapsed,
|
|
117
|
+
adapter_speeds={local_ip: downloaded / elapsed},
|
|
118
|
+
is_completed=False,
|
|
119
|
+
filename=target_path.name,
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if progress_callback:
|
|
124
|
+
elapsed = max(0.001, time.time() - self._start_time)
|
|
125
|
+
progress_callback(
|
|
126
|
+
ProgressUpdate(
|
|
127
|
+
total_bytes=downloaded,
|
|
128
|
+
downloaded_bytes=downloaded,
|
|
129
|
+
speed_bytes_sec=downloaded / elapsed,
|
|
130
|
+
adapter_speeds={local_ip: downloaded / elapsed},
|
|
131
|
+
is_completed=True,
|
|
132
|
+
filename=target_path.name,
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
return target_path
|
|
137
|
+
|
|
138
|
+
def _execute_bonded_parallel(
|
|
139
|
+
self,
|
|
140
|
+
probe: ProbeResult,
|
|
141
|
+
target_path: Path,
|
|
142
|
+
progress_callback: Optional[Callable[[ProgressUpdate], None]],
|
|
143
|
+
) -> Path:
|
|
144
|
+
"""Executes multi-socket bonded chunk downloading."""
|
|
145
|
+
total_bytes = probe.total_bytes
|
|
146
|
+
chunk_queue: queue.Queue[DownloadChunk] = queue.Queue()
|
|
147
|
+
|
|
148
|
+
# Split into chunks
|
|
149
|
+
offset = 0
|
|
150
|
+
chunk_id = 0
|
|
151
|
+
while offset < total_bytes:
|
|
152
|
+
end = min(offset + self.chunk_size - 1, total_bytes - 1)
|
|
153
|
+
chunk_queue.put(DownloadChunk(chunk_id=chunk_id, start_byte=offset, end_byte=end))
|
|
154
|
+
offset = end + 1
|
|
155
|
+
chunk_id += 1
|
|
156
|
+
|
|
157
|
+
total_chunks = chunk_id
|
|
158
|
+
|
|
159
|
+
# Register adapters in QoS watchdog
|
|
160
|
+
for a in self.adapters:
|
|
161
|
+
self.watchdog.register(a.name, a.ip_address, a.kind)
|
|
162
|
+
|
|
163
|
+
# Pre-allocate sparse file
|
|
164
|
+
with SparseFileWriter(target_path, total_bytes) as writer:
|
|
165
|
+
workers = []
|
|
166
|
+
with concurrent.futures.ThreadPoolExecutor(
|
|
167
|
+
max_workers=len(self.adapters) * self.workers_per_adapter
|
|
168
|
+
) as executor:
|
|
169
|
+
for adapter in self.adapters:
|
|
170
|
+
for _ in range(self.workers_per_adapter):
|
|
171
|
+
future = executor.submit(
|
|
172
|
+
self._worker_loop,
|
|
173
|
+
adapter,
|
|
174
|
+
chunk_queue,
|
|
175
|
+
writer,
|
|
176
|
+
probe.url,
|
|
177
|
+
progress_callback,
|
|
178
|
+
)
|
|
179
|
+
workers.append(future)
|
|
180
|
+
|
|
181
|
+
# Wait for all workers or cancellation
|
|
182
|
+
for future in concurrent.futures.as_completed(workers):
|
|
183
|
+
# Raise any unexpected exceptions
|
|
184
|
+
future.result()
|
|
185
|
+
|
|
186
|
+
if progress_callback:
|
|
187
|
+
elapsed = max(0.001, time.time() - self._start_time)
|
|
188
|
+
progress_callback(
|
|
189
|
+
ProgressUpdate(
|
|
190
|
+
total_bytes=total_bytes,
|
|
191
|
+
downloaded_bytes=total_bytes,
|
|
192
|
+
speed_bytes_sec=total_bytes / elapsed,
|
|
193
|
+
adapter_speeds={a.ip_address: self.watchdog.get(a.ip_address).speed_bytes_per_sec for a in self.adapters},
|
|
194
|
+
is_completed=True,
|
|
195
|
+
filename=target_path.name,
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
return target_path
|
|
200
|
+
|
|
201
|
+
def _worker_loop(
|
|
202
|
+
self,
|
|
203
|
+
adapter: NetworkAdapter,
|
|
204
|
+
chunk_queue: queue.Queue[DownloadChunk],
|
|
205
|
+
writer: SparseFileWriter,
|
|
206
|
+
url: str,
|
|
207
|
+
progress_callback: Optional[Callable[[ProgressUpdate], None]],
|
|
208
|
+
) -> None:
|
|
209
|
+
"""Worker thread dedicated to a specific network adapter."""
|
|
210
|
+
client = BoundRangeClient(local_ip=adapter.ip_address)
|
|
211
|
+
metric = self.watchdog.get(adapter.ip_address)
|
|
212
|
+
|
|
213
|
+
while not self._cancel_event.is_set():
|
|
214
|
+
try:
|
|
215
|
+
chunk = chunk_queue.get_nowait()
|
|
216
|
+
except queue.Empty:
|
|
217
|
+
break
|
|
218
|
+
|
|
219
|
+
metric.active_chunks += 1
|
|
220
|
+
chunk_data = bytearray()
|
|
221
|
+
success = False
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
def on_bytes(count: int):
|
|
225
|
+
metric.record_bytes(count)
|
|
226
|
+
with self._bytes_lock:
|
|
227
|
+
self._completed_bytes += count
|
|
228
|
+
if progress_callback:
|
|
229
|
+
elapsed = max(0.001, time.time() - self._start_time)
|
|
230
|
+
speeds = {
|
|
231
|
+
a.ip_address: self.watchdog.get(a.ip_address).speed_bytes_per_sec
|
|
232
|
+
for a in self.adapters
|
|
233
|
+
}
|
|
234
|
+
progress_callback(
|
|
235
|
+
ProgressUpdate(
|
|
236
|
+
total_bytes=self.probe.total_bytes,
|
|
237
|
+
downloaded_bytes=self._completed_bytes,
|
|
238
|
+
speed_bytes_sec=self._completed_bytes / elapsed,
|
|
239
|
+
adapter_speeds=speeds,
|
|
240
|
+
is_completed=False,
|
|
241
|
+
filename=self.output_path.name if self.output_path else "",
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
for block in client.fetch_range(
|
|
246
|
+
url, chunk.start_byte, chunk.end_byte, on_bytes=on_bytes
|
|
247
|
+
):
|
|
248
|
+
chunk_data.extend(block)
|
|
249
|
+
|
|
250
|
+
# Write directly into sparse file offset
|
|
251
|
+
writer.write_chunk(chunk.start_byte, bytes(chunk_data))
|
|
252
|
+
success = True
|
|
253
|
+
|
|
254
|
+
except Exception as err:
|
|
255
|
+
metric.record_error()
|
|
256
|
+
chunk.retries += 1
|
|
257
|
+
if chunk.retries < 5:
|
|
258
|
+
# Re-queue for another healthy adapter
|
|
259
|
+
chunk_queue.put(chunk)
|
|
260
|
+
else:
|
|
261
|
+
self._cancel_event.set()
|
|
262
|
+
raise RuntimeError(f"Chunk {chunk.chunk_id} failed permanently: {err}")
|
|
263
|
+
|
|
264
|
+
finally:
|
|
265
|
+
metric.active_chunks -= 1
|
|
266
|
+
chunk_queue.task_done()
|
plexo/core/qos.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""QoS watchdog and adapter health monitoring.
|
|
2
|
+
|
|
3
|
+
Tracks real-time throughput, detects hotspot stalls, and triggers
|
|
4
|
+
failover to healthy connections.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Dict
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class AdapterMetrics:
|
|
16
|
+
"""Live performance metrics for a specific adapter."""
|
|
17
|
+
name: str
|
|
18
|
+
ip_address: str
|
|
19
|
+
kind: str
|
|
20
|
+
bytes_transferred: int = 0
|
|
21
|
+
active_chunks: int = 0
|
|
22
|
+
last_byte_time: float = field(default_factory=time.time)
|
|
23
|
+
start_time: float = field(default_factory=time.time)
|
|
24
|
+
errors: int = 0
|
|
25
|
+
is_healthy: bool = True
|
|
26
|
+
|
|
27
|
+
def record_bytes(self, count: int) -> None:
|
|
28
|
+
self.bytes_transferred += count
|
|
29
|
+
self.last_byte_time = time.time()
|
|
30
|
+
|
|
31
|
+
def record_error(self) -> None:
|
|
32
|
+
self.errors += 1
|
|
33
|
+
if self.errors > 3:
|
|
34
|
+
self.is_healthy = False
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def speed_bytes_per_sec(self) -> float:
|
|
38
|
+
elapsed = time.time() - self.start_time
|
|
39
|
+
if elapsed <= 0:
|
|
40
|
+
return 0.0
|
|
41
|
+
return self.bytes_transferred / elapsed
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def speed_mbps(self) -> float:
|
|
45
|
+
return (self.speed_bytes_per_sec * 8) / (1024 * 1024)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def is_stalled(self) -> bool:
|
|
49
|
+
"""Returns true if active chunks exist but no data received for > 8 seconds."""
|
|
50
|
+
if self.active_chunks > 0 and (time.time() - self.last_byte_time > 8.0):
|
|
51
|
+
return True
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class QoSWatchdog:
|
|
56
|
+
"""Coordinates multi-adapter health and failure detection."""
|
|
57
|
+
|
|
58
|
+
def __init__(self):
|
|
59
|
+
self.adapters: Dict[str, AdapterMetrics] = {}
|
|
60
|
+
|
|
61
|
+
def register(self, name: str, ip_address: str, kind: str) -> AdapterMetrics:
|
|
62
|
+
metric = AdapterMetrics(name=name, ip_address=ip_address, kind=kind)
|
|
63
|
+
self.adapters[ip_address] = metric
|
|
64
|
+
return metric
|
|
65
|
+
|
|
66
|
+
def get(self, ip_address: str) -> AdapterMetrics:
|
|
67
|
+
return self.adapters[ip_address]
|
|
68
|
+
|
|
69
|
+
def check_health(self) -> Dict[str, bool]:
|
|
70
|
+
"""Audits all adapters and returns active health status."""
|
|
71
|
+
status = {}
|
|
72
|
+
for ip, metric in self.adapters.items():
|
|
73
|
+
if metric.is_stalled:
|
|
74
|
+
metric.is_healthy = False
|
|
75
|
+
status[ip] = metric.is_healthy
|
|
76
|
+
return status
|
plexo/core/sparse.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Sparse file allocation and concurrent chunk writer.
|
|
2
|
+
|
|
3
|
+
Allows multiple worker threads/tasks to write disjoint byte ranges
|
|
4
|
+
directly into their target offsets concurrently.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import os
|
|
11
|
+
import threading
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import BinaryIO, Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SparseFileWriter:
|
|
17
|
+
"""Manages thread-safe random-access writes into a pre-allocated file."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, target_path: str | Path, total_bytes: int):
|
|
20
|
+
self.target_path = Path(target_path).resolve()
|
|
21
|
+
self.total_bytes = total_bytes
|
|
22
|
+
self._lock = threading.Lock()
|
|
23
|
+
self._file: Optional[BinaryIO] = None
|
|
24
|
+
self._written_bytes = 0
|
|
25
|
+
|
|
26
|
+
# Ensure parent directory exists
|
|
27
|
+
self.target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
self._allocate()
|
|
29
|
+
|
|
30
|
+
def _allocate(self) -> None:
|
|
31
|
+
"""Pre-allocates the file size on disk."""
|
|
32
|
+
with open(self.target_path, "wb") as f:
|
|
33
|
+
if self.total_bytes > 0:
|
|
34
|
+
f.seek(self.total_bytes - 1)
|
|
35
|
+
f.write(b"\0")
|
|
36
|
+
f.flush()
|
|
37
|
+
|
|
38
|
+
# Open in r+b mode for concurrent random-access writes
|
|
39
|
+
self._file = open(self.target_path, "r+b")
|
|
40
|
+
|
|
41
|
+
def write_chunk(self, offset: int, data: bytes) -> int:
|
|
42
|
+
"""Writes a byte block at the specified offset."""
|
|
43
|
+
if not self._file:
|
|
44
|
+
raise RuntimeError("File writer is closed.")
|
|
45
|
+
|
|
46
|
+
length = len(data)
|
|
47
|
+
if offset + length > self.total_bytes:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"Write out of bounds: offset {offset} + len {length} > total {self.total_bytes}"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
with self._lock:
|
|
53
|
+
self._file.seek(offset)
|
|
54
|
+
self._file.write(data)
|
|
55
|
+
self._written_bytes += length
|
|
56
|
+
|
|
57
|
+
return length
|
|
58
|
+
|
|
59
|
+
def flush(self) -> None:
|
|
60
|
+
"""Flushes written buffers to disk."""
|
|
61
|
+
with self._lock:
|
|
62
|
+
if self._file:
|
|
63
|
+
self._file.flush()
|
|
64
|
+
try:
|
|
65
|
+
os.fsync(self._file.fileno())
|
|
66
|
+
except OSError:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
def close(self) -> None:
|
|
70
|
+
"""Closes the underlying file handle."""
|
|
71
|
+
with self._lock:
|
|
72
|
+
if self._file:
|
|
73
|
+
try:
|
|
74
|
+
self._file.flush()
|
|
75
|
+
self._file.close()
|
|
76
|
+
finally:
|
|
77
|
+
self._file = None
|
|
78
|
+
|
|
79
|
+
def calculate_checksum(self, algorithm: str = "sha256") -> str:
|
|
80
|
+
"""Calculates file checksum to verify integrity."""
|
|
81
|
+
self.flush()
|
|
82
|
+
hasher = hashlib.new(algorithm)
|
|
83
|
+
with open(self.target_path, "rb") as f:
|
|
84
|
+
while chunk := f.read(1024 * 1024): # 1 MB blocks
|
|
85
|
+
hasher.update(chunk)
|
|
86
|
+
return hasher.hexdigest()
|
|
87
|
+
|
|
88
|
+
def __enter__(self) -> "SparseFileWriter":
|
|
89
|
+
return self
|
|
90
|
+
|
|
91
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
92
|
+
self.close()
|
plexo/proxy/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Local forward proxy module for zero-touch bandwidth bonding."""
|
plexo/proxy/server.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Local HTTP/HTTPS forward proxy for accelerating terminal tools (pip, git, curl).
|
|
2
|
+
|
|
3
|
+
Routes requests through the multi-adapter bonding engine or tunnels HTTPS.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import socket
|
|
10
|
+
from typing import Optional
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from ..core.adapter import get_bonded_pool
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LocalBondedProxy:
|
|
16
|
+
"""Asynchronous local HTTP/HTTPS accelerator proxy."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, host: str = "127.0.0.1", port: int = 8899, console: Optional[Console] = None):
|
|
19
|
+
self.host = host
|
|
20
|
+
self.port = port
|
|
21
|
+
self.console = console or Console()
|
|
22
|
+
self.server: Optional[asyncio.Server] = None
|
|
23
|
+
self._adapters = []
|
|
24
|
+
|
|
25
|
+
async def start(self) -> None:
|
|
26
|
+
"""Starts the local proxy server."""
|
|
27
|
+
self._adapters = get_bonded_pool()
|
|
28
|
+
self.server = await asyncio.start_server(
|
|
29
|
+
self._handle_client, self.host, self.port
|
|
30
|
+
)
|
|
31
|
+
addrs = ", ".join(str(sock.getsockname()) for sock in self.server.sockets)
|
|
32
|
+
self.console.print(
|
|
33
|
+
f"[bold green]Plexo Proxy running on {self.host}:{self.port}[/bold green]"
|
|
34
|
+
)
|
|
35
|
+
self.console.print(
|
|
36
|
+
f"[cyan]Bonding across {len(self._adapters)} network interfaces:[/cyan]"
|
|
37
|
+
)
|
|
38
|
+
for a in self._adapters:
|
|
39
|
+
self.console.print(f" * {a.name} ({a.ip_address})")
|
|
40
|
+
|
|
41
|
+
self.console.print(
|
|
42
|
+
"\n[dim]To use with pip:[/dim]\n"
|
|
43
|
+
f" pip install <package> --proxy http://{self.host}:{self.port}\n\n"
|
|
44
|
+
"[dim]To use globally in terminal:[/dim]\n"
|
|
45
|
+
f" $env:HTTPS_PROXY = \"http://{self.host}:{self.port}\"\n"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
async with self.server:
|
|
49
|
+
await self.server.serve_forever()
|
|
50
|
+
|
|
51
|
+
async def _handle_client(
|
|
52
|
+
self, client_reader: asyncio.StreamReader, client_writer: asyncio.StreamWriter
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Handles client connection (HTTP GET or HTTPS CONNECT)."""
|
|
55
|
+
try:
|
|
56
|
+
request_line = await client_reader.readline()
|
|
57
|
+
if not request_line:
|
|
58
|
+
client_writer.close()
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
words = request_line.decode("latin1").split()
|
|
62
|
+
if len(words) < 2:
|
|
63
|
+
client_writer.close()
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
method, url = words[0], words[1]
|
|
67
|
+
|
|
68
|
+
if method.upper() == "CONNECT":
|
|
69
|
+
# HTTPS Tunneling
|
|
70
|
+
await self._handle_connect(url, client_reader, client_writer)
|
|
71
|
+
else:
|
|
72
|
+
# Plain HTTP proxy
|
|
73
|
+
await self._handle_http(method, url, request_line, client_reader, client_writer)
|
|
74
|
+
|
|
75
|
+
except Exception:
|
|
76
|
+
try:
|
|
77
|
+
client_writer.close()
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
async def _handle_connect(
|
|
82
|
+
self, target: str, client_reader: asyncio.StreamReader, client_writer: asyncio.StreamWriter
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Tunnels HTTPS connections directly."""
|
|
85
|
+
host, port_str = target.split(":") if ":" in target else (target, "443")
|
|
86
|
+
port = int(port_str)
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
# Round-robin or primary adapter source IP
|
|
90
|
+
adapter = self._adapters[0] if self._adapters else None
|
|
91
|
+
local_addr = (adapter.ip_address, 0) if adapter else None
|
|
92
|
+
|
|
93
|
+
remote_reader, remote_writer = await asyncio.open_connection(
|
|
94
|
+
host, port, local_addr=local_addr
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
client_writer.write(b"HTTP/1.1 200 Connection Established\r\n\r\n")
|
|
98
|
+
await client_writer.drain()
|
|
99
|
+
|
|
100
|
+
# Bidirectional pipe
|
|
101
|
+
await asyncio.gather(
|
|
102
|
+
self._pipe(client_reader, remote_writer),
|
|
103
|
+
self._pipe(remote_reader, client_writer),
|
|
104
|
+
return_exceptions=True,
|
|
105
|
+
)
|
|
106
|
+
except Exception:
|
|
107
|
+
client_writer.write(b"HTTP/1.1 502 Bad Gateway\r\n\r\n")
|
|
108
|
+
await client_writer.drain()
|
|
109
|
+
client_writer.close()
|
|
110
|
+
|
|
111
|
+
async def _handle_http(
|
|
112
|
+
self,
|
|
113
|
+
method: str,
|
|
114
|
+
url: str,
|
|
115
|
+
initial_line: bytes,
|
|
116
|
+
client_reader: asyncio.StreamReader,
|
|
117
|
+
client_writer: asyncio.StreamWriter,
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Forwards standard HTTP requests."""
|
|
120
|
+
# Read remaining headers
|
|
121
|
+
headers = bytearray(initial_line)
|
|
122
|
+
while True:
|
|
123
|
+
line = await client_reader.readline()
|
|
124
|
+
headers.extend(line)
|
|
125
|
+
if line == b"\r\n" or not line:
|
|
126
|
+
break
|
|
127
|
+
|
|
128
|
+
# Parse host from URL
|
|
129
|
+
from urllib.parse import urlparse
|
|
130
|
+
parsed = urlparse(url)
|
|
131
|
+
host = parsed.hostname or "localhost"
|
|
132
|
+
port = parsed.port or 80
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
remote_reader, remote_writer = await asyncio.open_connection(host, port)
|
|
136
|
+
remote_writer.write(headers)
|
|
137
|
+
await remote_writer.drain()
|
|
138
|
+
|
|
139
|
+
await asyncio.gather(
|
|
140
|
+
self._pipe(client_reader, remote_writer),
|
|
141
|
+
self._pipe(remote_reader, client_writer),
|
|
142
|
+
return_exceptions=True,
|
|
143
|
+
)
|
|
144
|
+
except Exception:
|
|
145
|
+
client_writer.close()
|
|
146
|
+
|
|
147
|
+
async def _pipe(
|
|
148
|
+
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
|
149
|
+
) -> None:
|
|
150
|
+
"""Pipes bytes between reader and writer until EOF."""
|
|
151
|
+
try:
|
|
152
|
+
while not reader.at_eof():
|
|
153
|
+
data = await reader.read(65536)
|
|
154
|
+
if not data:
|
|
155
|
+
break
|
|
156
|
+
writer.write(data)
|
|
157
|
+
await writer.drain()
|
|
158
|
+
except Exception:
|
|
159
|
+
pass
|
|
160
|
+
finally:
|
|
161
|
+
try:
|
|
162
|
+
writer.close()
|
|
163
|
+
except Exception:
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def run_proxy(host: str = "127.0.0.1", port: int = 8899) -> None:
|
|
168
|
+
"""Synchronous launcher for the proxy server."""
|
|
169
|
+
proxy = LocalBondedProxy(host=host, port=port)
|
|
170
|
+
asyncio.run(proxy.start())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Plexo BitTorrent multi-homing engine."""
|
plexo/torrent/bencode.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Lightweight Bencoding encoder/decoder for BitTorrent metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Tuple
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def bdecode(data: bytes) -> Any:
|
|
9
|
+
"""Decodes bencoded bytes into Python types."""
|
|
10
|
+
val, _ = _bdecode_part(data, 0)
|
|
11
|
+
return val
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _bdecode_part(data: bytes, idx: int) -> Tuple[Any, int]:
|
|
15
|
+
char = data[idx : idx + 1]
|
|
16
|
+
|
|
17
|
+
if char == b"i":
|
|
18
|
+
end = data.index(b"e", idx)
|
|
19
|
+
return int(data[idx + 1 : end]), end + 1
|
|
20
|
+
|
|
21
|
+
if char == b"l":
|
|
22
|
+
items = []
|
|
23
|
+
curr = idx + 1
|
|
24
|
+
while data[curr : curr + 1] != b"e":
|
|
25
|
+
item, curr = _bdecode_part(data, curr)
|
|
26
|
+
items.append(item)
|
|
27
|
+
return items, curr + 1
|
|
28
|
+
|
|
29
|
+
if char == b"d":
|
|
30
|
+
mapping = {}
|
|
31
|
+
curr = idx + 1
|
|
32
|
+
while data[curr : curr + 1] != b"e":
|
|
33
|
+
key, curr = _bdecode_part(data, curr)
|
|
34
|
+
val, curr = _bdecode_part(data, curr)
|
|
35
|
+
mapping[key.decode("latin1")] = val
|
|
36
|
+
return mapping, curr + 1
|
|
37
|
+
|
|
38
|
+
# String / Bytes: <length>:<bytes>
|
|
39
|
+
colon = data.index(b":", idx)
|
|
40
|
+
length = int(data[idx:colon])
|
|
41
|
+
start = colon + 1
|
|
42
|
+
end = start + length
|
|
43
|
+
return data[start:end], end
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def bencode(val: Any) -> bytes:
|
|
47
|
+
"""Encodes Python types into bencoded bytes."""
|
|
48
|
+
if isinstance(val, int):
|
|
49
|
+
return f"i{val}e".encode("ascii")
|
|
50
|
+
if isinstance(val, (bytes, bytearray)):
|
|
51
|
+
return f"{len(val)}:".encode("ascii") + bytes(val)
|
|
52
|
+
if isinstance(val, str):
|
|
53
|
+
encoded = val.encode("utf-8")
|
|
54
|
+
return f"{len(encoded)}:".encode("ascii") + encoded
|
|
55
|
+
if isinstance(val, list):
|
|
56
|
+
return b"l" + b"".join(bencode(i) for i in val) + b"e"
|
|
57
|
+
if isinstance(val, dict):
|
|
58
|
+
out = b"d"
|
|
59
|
+
for k in sorted(val.keys()):
|
|
60
|
+
out += bencode(k) + bencode(val[k])
|
|
61
|
+
return out + b"e"
|
|
62
|
+
raise TypeError(f"Cannot bencode type: {type(val)}")
|