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.
- dht.py +137 -0
- discovery_relay.py +94 -0
- lightning_settle.py +90 -0
- node.py +784 -0
- poc_reputation.py +311 -0
- shell.py +420 -0
- tunnel_relay.py +204 -0
- web_ui.py +444 -0
- weed.py +238 -0
- weed_cli-0.1.0.dist-info/METADATA +870 -0
- weed_cli-0.1.0.dist-info/RECORD +14 -0
- weed_cli-0.1.0.dist-info/WHEEL +5 -0
- weed_cli-0.1.0.dist-info/entry_points.txt +2 -0
- weed_cli-0.1.0.dist-info/top_level.txt +9 -0
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()
|