weed-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.
shell.py ADDED
@@ -0,0 +1,420 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Interactive shell for weed.py, same pattern as btcvm/ott.py's OttShell:
4
+ cmd.Cmd, readline tab completion, short aliases, Ctrl-D/q to exit. `host`
5
+ runs the server in a background thread instead of blocking the shell, so
6
+ you can host and discover/download/like in the same session — ott doesn't
7
+ have a precedent for a long-running command since nothing in ott blocks
8
+ forever, this is a genuinely new case, not copied.
9
+
10
+ Tab completion resolves against real state, same idea as ott's
11
+ completions (which complete against the real archive, not a fixed list):
12
+ `download`/`like` complete against content hashes actually seen in the
13
+ last `discover`; `subscribe` completes against pubkeys actually seen.
14
+ """
15
+ import cmd
16
+ import os
17
+ import shlex
18
+ import threading
19
+
20
+ import discovery_relay
21
+ import node
22
+ import web_ui
23
+
24
+
25
+ def _bg(fn, *args, **kwargs):
26
+ """Wrap a background-thread target so a real failure (bad bind address,
27
+ port already in use, whatever) prints the shell's own ' ✗ ...' one-
28
+ liner instead of Python's default thread traceback landing raw in the
29
+ middle of the prompt. onecmd()'s try/except below only ever covers the
30
+ synchronous part of a do_* call -- host/relay/serve all hand the actual
31
+ work to a background thread, and nothing catches what *that* raises
32
+ once do_* has already returned and printed its 'running in the
33
+ background' line. Every threading.Thread(target=...) in this file
34
+ should go through this instead of calling the real function directly."""
35
+ try:
36
+ fn(*args, **kwargs)
37
+ except SystemExit as e:
38
+ print(f'\n ✗ {e}')
39
+ except Exception as e:
40
+ print(f'\n ✗ {type(e).__name__}: {e}')
41
+
42
+
43
+ class WeedShell(cmd.Cmd):
44
+ intro = (
45
+ '\n weed — censorship-resistant video PoC node\n'
46
+ ' Type help or ? for commands. Tab completes. Ctrl-D or q to exit.\n'
47
+ )
48
+ prompt = 'weed> '
49
+
50
+ def __init__(self, *a, **kw):
51
+ super().__init__(*a, **kw)
52
+ self.identity = node.load_or_create_identity()
53
+ self._last_discovery = [] # cache of the last `discover` results, for tab completion
54
+ self._host_threads = [] # background host() threads started this session
55
+ self.default_relay = 'http://127.0.0.1:9101'
56
+ self.dht_node = None # active dht.DHTNode, once `dht start` has run
57
+
58
+ def preloop(self):
59
+ try:
60
+ import readline
61
+ readline.set_completer_delims(' \t\n')
62
+ except ImportError:
63
+ pass
64
+
65
+ def emptyline(self):
66
+ pass # don't repeat the last command on a bare Enter, like ott
67
+
68
+ def onecmd(self, line):
69
+ """ott's do_* methods each catch their own expected errors (OttError,
70
+ OttNotFoundError) locally rather than needing a shell-wide net —
71
+ that works there because ott's operations are all local/filesystem.
72
+ weed's commands do real network I/O, depend on packages that might
73
+ not be installed, and several node.py functions call sys.exit() on
74
+ expected failures (missing archive, hash mismatch, unreachable
75
+ host) — correct for the one-shot CLI, where sys.exit() ending the
76
+ process IS the right behavior, but fatal here: the shell's own
77
+ `quit`/`q`/Ctrl-D exit via a do_* method returning True, never via
78
+ SystemExit, so there's no legitimate case where letting SystemExit
79
+ (or anything else) propagate out of a command is correct — it would
80
+ just kill the session, including any background host/relay threads
81
+ still running. Catch broadly, print, stay alive."""
82
+ try:
83
+ return super().onecmd(line)
84
+ except ModuleNotFoundError as e:
85
+ # e.name is the *module* name (e.g. 'ott'), not necessarily the pip package
86
+ # name (btcvm) — don't suggest `pip install {e.name}`, it's wrong here and
87
+ # would be wrong again for any other module/package name mismatch.
88
+ print(f' ✗ {e} — pip install -r requirements.txt')
89
+ except SystemExit as e:
90
+ print(f' ✗ {e}')
91
+ except Exception as e:
92
+ print(f' ✗ {type(e).__name__}: {e}')
93
+
94
+ # ── commands ─────────────────────────────────────────────────────────
95
+
96
+ def do_whoami(self, arg):
97
+ """whoami — print your persistent node pubkey (~/.weed_identity.key)."""
98
+ print(f' {self.identity.pubkey_hex()}')
99
+
100
+ def do_host(self, arg):
101
+ """host <archive_dir> [--file NAME] [--port N] [--price SAT] [--relay URL]
102
+ [--no-announce] [--advertise-host HOST] [--tunnel [tls://]RELAY_HOST:PORT]
103
+ — serve every archived file in archive_dir in a background thread
104
+ (shell stays usable), one port, downloaders SELECT which by content
105
+ hash. Pass --file to restrict to a single file. Announces each file
106
+ on --relay (default: your session's default relay — see `relay`)
107
+ unless --no-announce is given. --price sets what download charges
108
+ (default free). --tunnel registers with a tunnel_relay.py instead of
109
+ relying on a reachable inbound port — for hosting behind NAT/CGNAT,
110
+ and only supports one file at a time, so requires --file if
111
+ archive_dir has more than one. Prefix tls:// if the relay
112
+ terminates TLS at the edge (e.g. a Fly service with handlers =
113
+ ["tls"]) — the relay process itself never needs to know."""
114
+ parts = shlex.split(arg)
115
+ if not parts:
116
+ print(' usage: host <archive_dir> [--file NAME] [--port N] [--price SAT] '
117
+ '[--relay URL] [--no-announce] [--advertise-host HOST] '
118
+ '[--tunnel RELAY_HOST:PORT]')
119
+ return
120
+ archive_dir = parts[0]
121
+ file_name = None
122
+ port = 9201
123
+ price = 0
124
+ relay = self.default_relay
125
+ no_announce = '--no-announce' in parts
126
+ advertise_host = '127.0.0.1'
127
+ tunnel = None
128
+ i = 1
129
+ while i < len(parts):
130
+ if parts[i] == '--file' and i + 1 < len(parts):
131
+ i += 1
132
+ file_name = parts[i]
133
+ elif parts[i] == '--port' and i + 1 < len(parts):
134
+ i += 1
135
+ port = int(parts[i])
136
+ elif parts[i] == '--price' and i + 1 < len(parts):
137
+ i += 1
138
+ price = int(parts[i])
139
+ elif parts[i] == '--relay' and i + 1 < len(parts):
140
+ i += 1
141
+ relay = parts[i]
142
+ elif parts[i] == '--advertise-host' and i + 1 < len(parts):
143
+ i += 1
144
+ advertise_host = parts[i]
145
+ elif parts[i] == '--tunnel' and i + 1 < len(parts):
146
+ i += 1
147
+ tunnel = parts[i]
148
+ i += 1
149
+
150
+ entries = node.load_manifest_entries(archive_dir, file_name)
151
+ if tunnel and len(entries) > 1:
152
+ print(f' {len(entries)} files in {archive_dir} but --tunnel only serves one at a time '
153
+ f'— pass --file NAME to pick one')
154
+ return
155
+ # fail fast, before announcing anything — a manifest entry with no
156
+ # matching chunk data would otherwise get announced to the relay
157
+ # and only fail later, in the background server thread (onecmd's
158
+ # exception net doesn't reach into background threads)
159
+ all_leaves = {e['sha256']: node.load_leaves(archive_dir, e['sha256']) for e in entries}
160
+ if relay and not no_announce:
161
+ for entry in entries:
162
+ result = node.publish(self.identity, relay, entry['sha256'], entry['name'],
163
+ f'{advertise_host}:{port}', tunnel=tunnel)
164
+ print(f' announced {entry["name"]} on {relay}: {result}')
165
+ elif not relay:
166
+ print(' no relay set (run `relay` first, or pass --relay) — hosting without announcing')
167
+ if tunnel:
168
+ entry = entries[0]
169
+ relay_host, relay_port, use_tls = node._parse_tunnel(tunnel)
170
+ expanded_dir = os.path.expanduser(archive_dir)
171
+ file_path = entry.get('last_path') or os.path.join(expanded_dir, entry['name'])
172
+ tt = threading.Thread(target=_bg,
173
+ args=(node.run_host_tunnel, relay_host, relay_port,
174
+ entry['sha256'], entry, all_leaves[entry['sha256']],
175
+ file_path, price),
176
+ kwargs={'use_tls': use_tls, 'quiet': True}, daemon=True)
177
+ tt.start()
178
+ self._host_threads.append(tt)
179
+ t = threading.Thread(target=_bg,
180
+ args=(node.run_host_server, archive_dir, file_name, port),
181
+ kwargs={'quiet': True, 'price': price}, daemon=True)
182
+ t.start()
183
+ self._host_threads.append(t)
184
+ price_note = f', {price} sat/download' if price else ', free'
185
+ tunnel_note = f', tunneled via {tunnel}' if tunnel else ''
186
+ if len(entries) == 1:
187
+ print(f' hosting {entries[0]["name"]} on port {port} in the background{price_note}'
188
+ f'{tunnel_note} — shell still usable')
189
+ else:
190
+ print(f' hosting {len(entries)} files on port {port} in the background{price_note} '
191
+ f'— shell still usable')
192
+
193
+ def do_relay(self, arg):
194
+ """relay [port] — run a real discovery relay in the background (default port 9101),
195
+ so you don't need a separate terminal for one. Sets it as the default relay for
196
+ discover/download/like/subscribe in this session."""
197
+ port = int(arg.strip()) if arg.strip() else 9101
198
+ t = threading.Thread(target=_bg, args=(discovery_relay.run_relay_server, port),
199
+ kwargs={'quiet': True}, daemon=True)
200
+ t.start()
201
+ self._host_threads.append(t)
202
+ self.default_relay = f'http://127.0.0.1:{port}'
203
+ print(f' relay running on port {port} in the background — set as your default relay')
204
+
205
+ def do_serve(self, arg):
206
+ """serve [bind] [port] — run the local web control UI in the background
207
+ (default 127.0.0.1:8080; pass 0.0.0.0 to reach it from your phone).
208
+ Same server web_ui.py runs standalone, just launched inline so you
209
+ don't need a second terminal. Its own page has a 📱 button for a
210
+ scan-to-open QR code once it's up."""
211
+ parts = shlex.split(arg)
212
+ bind_host = parts[0] if len(parts) > 0 else '127.0.0.1'
213
+ port = int(parts[1]) if len(parts) > 1 else 8080
214
+ t = threading.Thread(target=_bg, args=(web_ui.run_web_ui, port),
215
+ kwargs={'bind_host': bind_host, 'quiet': True}, daemon=True)
216
+ t.start()
217
+ self._host_threads.append(t)
218
+ if bind_host == '0.0.0.0':
219
+ print(f' web control UI running on all interfaces, port {port}, in the background '
220
+ f'— open http://127.0.0.1:{port}/ here, or use its 📱 button to reach it '
221
+ f'from your phone')
222
+ else:
223
+ print(f' web control UI running at http://{bind_host}:{port}/ in the background')
224
+
225
+ def do_dht(self, arg):
226
+ """dht start [port] [bootstrap_host:port] — start (or join) a real
227
+ Kademlia DHT node in the background (default port 8468). No relay
228
+ needed — once bootstrapped into an existing swarm, peers find each
229
+ other through the DHT itself. Omit the bootstrap address to start
230
+ a brand new swarm as its first node.
231
+ dht announce <content_hash> <host:port> [title] — announce you're
232
+ hosting content, on the active node (from the last `dht start`).
233
+ dht lookup <content_hash> — look up who's hosting content, via
234
+ the active node."""
235
+ try:
236
+ import dht
237
+ except ImportError:
238
+ print(' ✗ dht requires the kademlia package — pip install kademlia')
239
+ return
240
+ parts = shlex.split(arg)
241
+ if not parts:
242
+ print(' Usage: dht <start|announce|lookup> ...')
243
+ return
244
+ subcmd, rest = parts[0], parts[1:]
245
+ if subcmd == 'start':
246
+ port = int(rest[0]) if len(rest) > 0 else 8468
247
+ bootstrap_spec = rest[1] if len(rest) > 1 else None
248
+ try:
249
+ node_obj = dht.DHTNode(port, bootstrap_nodes=dht._parse_bootstrap(bootstrap_spec),
250
+ quiet=True)
251
+ node_obj.start()
252
+ except Exception as e:
253
+ print(f' ✗ {type(e).__name__}: {e}')
254
+ return
255
+ self.dht_node = node_obj
256
+ joined = f', joined via {bootstrap_spec}' if bootstrap_spec else ' (new swarm)'
257
+ print(f' dht node listening on port {port}{joined} — set as the active dht node')
258
+ elif subcmd == 'announce':
259
+ if not self.dht_node:
260
+ print(' no active dht node — run `dht start` first')
261
+ return
262
+ if len(rest) < 2:
263
+ print(' Usage: dht announce <content_hash> <host:port> [title]')
264
+ return
265
+ content_hash, host_addr = rest[0], rest[1]
266
+ title = rest[2] if len(rest) > 2 else None
267
+ try:
268
+ self.dht_node.announce(content_hash, host_addr, title=title)
269
+ except Exception as e:
270
+ print(f' ✗ {type(e).__name__}: {e}')
271
+ return
272
+ print(f' announced {content_hash[:16]}... at {host_addr} via dht')
273
+ elif subcmd == 'lookup':
274
+ if not self.dht_node:
275
+ print(' no active dht node — run `dht start` first')
276
+ return
277
+ if not rest:
278
+ print(' Usage: dht lookup <content_hash>')
279
+ return
280
+ try:
281
+ results = self.dht_node.lookup(rest[0])
282
+ except Exception as e:
283
+ print(f' ✗ {type(e).__name__}: {e}')
284
+ return
285
+ if not results:
286
+ print(' nothing found on the dht for that hash')
287
+ return
288
+ for r in results:
289
+ print(f' host={r["host"]} title={r.get("title") or "(untitled)"}')
290
+ else:
291
+ print(f' unknown dht subcommand: {subcmd!r} (start, announce, lookup)')
292
+
293
+ def complete_dht(self, text, line, begidx, endidx):
294
+ parts = shlex.split(line[:begidx])
295
+ if len(parts) == 1:
296
+ return [s for s in ('start', 'announce', 'lookup') if s.startswith(text)]
297
+ return []
298
+
299
+ def do_discover(self, arg):
300
+ """discover [relay_url ...] — list content announced on one or more relays
301
+ (default: the last relay used, or http://127.0.0.1:9101)."""
302
+ relays = shlex.split(arg) or [self.default_relay]
303
+ self.default_relay = relays[0]
304
+ results = node.discover(relays)
305
+ self._last_discovery = results
306
+ if not results:
307
+ print(' nothing found')
308
+ return
309
+ for r in results:
310
+ print(f' {r["title"]!r:40s} hash={r["content_hash"][:16]}... '
311
+ f'host={r["host"]} by={r["signer_pubkey"][:12]}...')
312
+
313
+ def do_download(self, arg):
314
+ """download <content_hash_or_prefix> [--out FILE] [--relay URL] [--rounds N] [--lightning]
315
+ — resolve every host publishing this content, possession-challenge
316
+ each one (N chunks sampled, default 3), auction survivors by
317
+ reputation then price, optionally pay the winner over a real
318
+ Lightning HTLC, download, and record the outcome to local
319
+ reputation. Tab-completes against the last `discover`."""
320
+ parts = shlex.split(arg)
321
+ if not parts:
322
+ print(' usage: download <content_hash_or_prefix> [--out FILE] [--relay URL] [--rounds N] [--lightning]')
323
+ return
324
+ prefix = parts[0]
325
+ out = None
326
+ relay = self.default_relay
327
+ rounds = 3
328
+ use_lightning = '--lightning' in parts
329
+ i = 1
330
+ while i < len(parts):
331
+ if parts[i] == '--out' and i + 1 < len(parts):
332
+ i += 1
333
+ out = parts[i]
334
+ elif parts[i] == '--relay' and i + 1 < len(parts):
335
+ i += 1
336
+ relay = parts[i]
337
+ elif parts[i] == '--rounds' and i + 1 < len(parts):
338
+ i += 1
339
+ rounds = int(parts[i])
340
+ i += 1
341
+
342
+ node.download_with_auction(prefix, [relay], out_path=out, k=rounds, use_lightning=use_lightning)
343
+
344
+ def do_like(self, arg):
345
+ """like <content_hash> [--relay URL] — sign and post a real like event."""
346
+ parts = shlex.split(arg)
347
+ if not parts:
348
+ print(' usage: like <content_hash> [--relay URL]')
349
+ return
350
+ relay = self.default_relay
351
+ if '--relay' in parts:
352
+ i = parts.index('--relay')
353
+ relay = parts[i + 1]
354
+ event = self.identity.sign_event('like', content_hash=parts[0])
355
+ print(f' {node.post_event(relay, event)}')
356
+
357
+ def do_subscribe(self, arg):
358
+ """subscribe <target_pubkey> [--relay URL] — sign and post a real subscribe event."""
359
+ parts = shlex.split(arg)
360
+ if not parts:
361
+ print(' usage: subscribe <target_pubkey> [--relay URL]')
362
+ return
363
+ relay = self.default_relay
364
+ if '--relay' in parts:
365
+ i = parts.index('--relay')
366
+ relay = parts[i + 1]
367
+ event = self.identity.sign_event('subscribe', target_pubkey=parts[0])
368
+ print(f' {node.post_event(relay, event)}')
369
+
370
+ def do_quit(self, arg):
371
+ """quit — exit the shell."""
372
+ return True
373
+
374
+ def do_EOF(self, arg):
375
+ print()
376
+ return True
377
+
378
+ def do_q(self, arg):
379
+ return self.do_quit(arg)
380
+
381
+ # ── short aliases, same convention as ott ───────────────────────────
382
+
383
+ def do_w(self, arg): return self.do_whoami(arg)
384
+ def do_h(self, arg): return self.do_host(arg)
385
+ def do_r(self, arg): return self.do_relay(arg)
386
+ def do_s(self, arg): return self.do_serve(arg)
387
+ def do_disc(self, arg): return self.do_discover(arg)
388
+ def do_dl(self, arg): return self.do_download(arg)
389
+ def do_get(self, arg): return self.do_download(arg)
390
+ def do_l(self, arg): return self.do_like(arg)
391
+ def do_sub(self, arg): return self.do_subscribe(arg)
392
+
393
+ # ── tab completion — resolves against real discovered state ─────────
394
+
395
+ def _known_hashes(self, text):
396
+ return [r['content_hash'] for r in self._last_discovery if r['content_hash'].startswith(text)]
397
+
398
+ def _known_pubkeys(self, text):
399
+ return [r['signer_pubkey'] for r in self._last_discovery if r['signer_pubkey'].startswith(text)]
400
+
401
+ def complete_download(self, text, line, begidx, endidx):
402
+ return self._known_hashes(text)
403
+
404
+ def complete_dl(self, *a):
405
+ return self.complete_download(*a)
406
+
407
+ def complete_get(self, *a):
408
+ return self.complete_download(*a)
409
+
410
+ def complete_like(self, text, line, begidx, endidx):
411
+ return self._known_hashes(text)
412
+
413
+ def complete_l(self, *a):
414
+ return self.complete_like(*a)
415
+
416
+ def complete_subscribe(self, text, line, begidx, endidx):
417
+ return self._known_pubkeys(text)
418
+
419
+ def complete_sub(self, *a):
420
+ return self.complete_subscribe(*a)
tunnel_relay.py ADDED
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ NAT-traversal relay. A host with no reachable inbound port registers here
4
+ over one persistent outbound CONTROL connection; a downloader connects
5
+ here too and gets paired with a fresh DATA connection the host dials back
6
+ on demand (rathole/frp's rendezvous shape, hand-rolled to match this
7
+ repo's own wire protocol instead of pulling in an external tunnel tool).
8
+ From the moment a DATA connection is paired with a downloader connection,
9
+ this relay does nothing but shovel raw bytes between the two sockets — it
10
+ never parses node.py's INFO/LEAVES/FETCH protocol riding inside the
11
+ tunnel, same "dumb relay, no opinion on the payload" design as
12
+ discovery_relay.py, just for bytes instead of signed JSON events.
13
+
14
+ Rendezvous protocol — one plain-text first line per connection, then the
15
+ socket either stays line-based (REGISTER's control channel) or becomes a
16
+ raw pipe (CONNECT/DATA, once paired):
17
+
18
+ REGISTER <token> host -> relay, persistent control connection.
19
+ token is whatever the host wants to be found
20
+ under (node.py uses the archive's content_hash).
21
+ CONNECT <token> downloader -> relay, wants to reach that host.
22
+ Everything sent after this line IS the tunneled
23
+ protocol, not a relay command.
24
+ NEWSTREAM <stream_id> relay -> host, over the control connection:
25
+ "a downloader is waiting, dial back."
26
+ DATA <stream_id> host -> relay, the dial-back this relay pairs
27
+ with the waiting CONNECT. Everything after
28
+ this line is tunneled protocol too.
29
+
30
+ This relay has zero idea what `token` means — it's purely a lookup key,
31
+ same as content_hash is everywhere else in this repo.
32
+ """
33
+ import secrets
34
+ import socket
35
+ import socketserver
36
+ import sys
37
+ import threading
38
+
39
+ _registrations = {} # token -> control socket
40
+ _pending_streams = {} # stream_id -> _Pairing (defined below)
41
+ _lock = threading.Lock()
42
+
43
+
44
+ def recv_line(sock):
45
+ """Byte-at-a-time on purpose: CONNECT/DATA connections become a raw
46
+ byte pipe immediately after this first line, and the real client
47
+ (HostConnection.connect_via_tunnel) sends its next protocol command
48
+ without waiting for any ack — a chunked recv() here could swallow
49
+ those bytes along with the handshake line, leaving nothing for pipe()
50
+ to forward. One byte at a time guarantees this stops exactly at the
51
+ newline and never reads into what comes after. Only runs once per
52
+ connection (a ~20-40 byte line), not on the hot path."""
53
+ buf = bytearray()
54
+ while True:
55
+ b = sock.recv(1)
56
+ if not b or b == b'\n':
57
+ break
58
+ buf += b
59
+ return buf.decode().strip()
60
+
61
+
62
+ def pipe(src, dst):
63
+ try:
64
+ while True:
65
+ data = src.recv(65536)
66
+ if not data:
67
+ break
68
+ dst.sendall(data)
69
+ except OSError:
70
+ pass
71
+ finally:
72
+ try:
73
+ dst.shutdown(socket.SHUT_WR)
74
+ except OSError:
75
+ pass
76
+
77
+
78
+ def handle_register(conn, token, quiet):
79
+ with _lock:
80
+ _registrations[token] = conn
81
+ if not quiet:
82
+ print(f"[tunnel] {token[:16]}... registered", flush=True)
83
+ try:
84
+ while True:
85
+ # otherwise-idle control connection — block here purely to
86
+ # detect the host disconnecting, so a dead registration
87
+ # doesn't linger and get handed out to future downloaders
88
+ data = conn.recv(4096)
89
+ if not data:
90
+ break
91
+ finally:
92
+ with _lock:
93
+ if _registrations.get(token) is conn:
94
+ del _registrations[token]
95
+ if not quiet:
96
+ print(f"[tunnel] {token[:16]}... unregistered", flush=True)
97
+
98
+
99
+ class _Pairing:
100
+ """Handshake state for one CONNECT waiting to be matched with the
101
+ host's dial-back DATA connection. `ready` fires once the DATA side
102
+ has arrived; `done` fires once piping has fully finished in both
103
+ directions — both CONNECT's and DATA's handler threads block on the
104
+ relevant event instead of returning early. That's required, not just
105
+ tidy: socketserver's ThreadingMixIn calls shutdown()+close() on a
106
+ request's socket the instant its handle() method returns, so a
107
+ handler that spawns a pipe thread and returns immediately gets its
108
+ own socket killed out from under that thread mid-transfer."""
109
+
110
+ def __init__(self):
111
+ self.data_conn = None
112
+ self.ready = threading.Event()
113
+ self.done = threading.Event()
114
+
115
+
116
+ def handle_connect(conn, token):
117
+ with _lock:
118
+ ctrl = _registrations.get(token)
119
+ if ctrl is None:
120
+ try:
121
+ conn.sendall(b'ERR no such host\n')
122
+ except OSError:
123
+ pass
124
+ return
125
+
126
+ stream_id = secrets.token_hex(8)
127
+ pairing = _Pairing()
128
+ with _lock:
129
+ _pending_streams[stream_id] = pairing
130
+ try:
131
+ ctrl.sendall(f'NEWSTREAM {stream_id}\n'.encode())
132
+ except OSError:
133
+ with _lock:
134
+ _pending_streams.pop(stream_id, None)
135
+ return
136
+
137
+ if not pairing.ready.wait(timeout=15):
138
+ with _lock:
139
+ _pending_streams.pop(stream_id, None)
140
+ return
141
+
142
+ # one direction runs inline (blocking this handler thread, which is
143
+ # the whole point), the other runs on a plain thread of our own —
144
+ # NOT a socketserver-managed one, so it's free to keep running past
145
+ # any single handle() call
146
+ data_conn = pairing.data_conn
147
+ t = threading.Thread(target=pipe, args=(data_conn, conn), daemon=True)
148
+ t.start()
149
+ pipe(conn, data_conn)
150
+ t.join()
151
+ pairing.done.set()
152
+
153
+
154
+ def handle_data(conn, stream_id):
155
+ with _lock:
156
+ pairing = _pending_streams.pop(stream_id, None)
157
+ if pairing is None:
158
+ return
159
+ pairing.data_conn = conn
160
+ pairing.ready.set()
161
+ # block here until the CONNECT side has fully finished piping both
162
+ # directions on `conn` — see _Pairing's docstring for why
163
+ pairing.done.wait()
164
+
165
+
166
+ class TunnelHandler(socketserver.BaseRequestHandler):
167
+ def handle(self):
168
+ line = recv_line(self.request)
169
+ parts = line.split()
170
+ if len(parts) < 2:
171
+ self.request.close()
172
+ return
173
+ cmd, token = parts[0], parts[1]
174
+ if cmd == 'REGISTER':
175
+ handle_register(self.request, token, getattr(self.server, 'quiet', False))
176
+ elif cmd == 'CONNECT':
177
+ handle_connect(self.request, token)
178
+ elif cmd == 'DATA':
179
+ handle_data(self.request, token)
180
+ else:
181
+ self.request.close()
182
+
183
+
184
+ class TunnelServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
185
+ allow_reuse_address = True
186
+ daemon_threads = True
187
+
188
+
189
+ def run_tunnel_relay(port, bind_host='0.0.0.0', quiet=False):
190
+ srv = TunnelServer((bind_host, port), TunnelHandler)
191
+ srv.quiet = quiet
192
+ if not quiet:
193
+ print(f"[tunnel:{port}] up, no opinion on the payload, just pairs sockets and shovels bytes",
194
+ flush=True)
195
+ srv.serve_forever()
196
+
197
+
198
+ def main():
199
+ port = int(sys.argv[1]) if len(sys.argv) > 1 else 9199
200
+ run_tunnel_relay(port)
201
+
202
+
203
+ if __name__ == '__main__':
204
+ main()