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
web_ui.py
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Local control UI for weed: a small stdlib JSON API (same tool
|
|
4
|
+
discovery_relay.py already uses — ThreadingHTTPServer, no new dependency)
|
|
5
|
+
plus a static frontend (web/), so hosting/discovering/downloading/
|
|
6
|
+
liking/subscribing don't require memorizing weed.py's CLI flags. Every
|
|
7
|
+
endpoint is a thin wrapper over the real node.py functions the CLI
|
|
8
|
+
already calls — no reimplementation of any protocol logic.
|
|
9
|
+
|
|
10
|
+
Binds 127.0.0.1 by default on purpose — this is a *local* control
|
|
11
|
+
surface, not something meant to face the internet, and there's no auth
|
|
12
|
+
built (same "reachability is on you" honesty --advertise-host's docs
|
|
13
|
+
already apply elsewhere in this repo). Pass --bind to expose it on a LAN
|
|
14
|
+
at your own risk.
|
|
15
|
+
"""
|
|
16
|
+
import contextlib
|
|
17
|
+
import io
|
|
18
|
+
import json
|
|
19
|
+
import mimetypes
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
import threading
|
|
23
|
+
import uuid
|
|
24
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
25
|
+
from urllib.parse import urlparse, parse_qs
|
|
26
|
+
|
|
27
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
28
|
+
import node
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
import qrcode # pip install qrcode -- same package ott.py's own `ott qr` already uses
|
|
32
|
+
_HAS_QR = True
|
|
33
|
+
except ImportError:
|
|
34
|
+
_HAS_QR = False
|
|
35
|
+
|
|
36
|
+
WEB_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'web')
|
|
37
|
+
DEFAULT_RELAY = 'http://127.0.0.1:9101'
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _JobStdout:
|
|
41
|
+
"""Replaces sys.stdout for this whole process so a background job
|
|
42
|
+
thread (host/download) can be muted without touching a single print()
|
|
43
|
+
in node.py -- those are correct and wanted when the same functions
|
|
44
|
+
run from weed.py or the interactive shell, where a human is actually
|
|
45
|
+
watching the terminal. They're just noise here: nothing reads this
|
|
46
|
+
process's stdout, and a job's real status already goes through
|
|
47
|
+
_jobs[job_id]/_hosts[host_id], which the API/frontend actually poll.
|
|
48
|
+
|
|
49
|
+
Routes by the *calling* thread, not a single global swap -- a
|
|
50
|
+
threading.local() flag, so two jobs running concurrently in different
|
|
51
|
+
threads (or a job running while the main thread prints its own
|
|
52
|
+
[web:PORT] startup line) can't clobber each other's output."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, real):
|
|
55
|
+
self._real = real
|
|
56
|
+
self._local = threading.local()
|
|
57
|
+
|
|
58
|
+
def write(self, s):
|
|
59
|
+
buf = getattr(self._local, 'buf', None)
|
|
60
|
+
(buf if buf is not None else self._real).write(s)
|
|
61
|
+
|
|
62
|
+
def flush(self):
|
|
63
|
+
self._real.flush()
|
|
64
|
+
|
|
65
|
+
def __getattr__(self, name):
|
|
66
|
+
return getattr(self._real, name)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
sys.stdout = _JobStdout(sys.stdout)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@contextlib.contextmanager
|
|
73
|
+
def _quiet():
|
|
74
|
+
"""Mute node.py's CLI-oriented prints for the current thread only,
|
|
75
|
+
for the duration of the with-block."""
|
|
76
|
+
sys.stdout._local.buf = io.StringIO()
|
|
77
|
+
try:
|
|
78
|
+
yield
|
|
79
|
+
finally:
|
|
80
|
+
sys.stdout._local.buf = None
|
|
81
|
+
|
|
82
|
+
_hosts = {} # host_id -> dict describing an actively-hosted file
|
|
83
|
+
_jobs = {} # job_id -> dict describing a download's progress/result
|
|
84
|
+
_lock = threading.Lock()
|
|
85
|
+
_lan_url = None # set once in run_web_ui() -- the base URL a phone on the
|
|
86
|
+
# same LAN can actually reach this server at, or None if
|
|
87
|
+
# it can't (bound to 127.0.0.1). The client can't compute
|
|
88
|
+
# this itself: window.location.origin only reflects
|
|
89
|
+
# whatever address the *desktop* browser used to load the
|
|
90
|
+
# page, which is 127.0.0.1 the moment someone opens it via
|
|
91
|
+
# localhost -- exactly the bug this fixes.
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _identity():
|
|
95
|
+
return node.load_or_create_identity()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _as_list(value, default):
|
|
99
|
+
if not value:
|
|
100
|
+
return default
|
|
101
|
+
return [value] if isinstance(value, str) else list(value)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _detect_lan_ip():
|
|
105
|
+
"""Best-effort outbound-facing LAN IP. UDP connect() here never sends a
|
|
106
|
+
packet -- it just makes the kernel pick a route -- but that's enough to
|
|
107
|
+
read back the interface address that route would use. Needed because
|
|
108
|
+
a startup/QR URL naming the literal 0.0.0.0 wildcard bind address is
|
|
109
|
+
useless: nothing can connect *to* 0.0.0.0 from another device."""
|
|
110
|
+
import socket
|
|
111
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
112
|
+
try:
|
|
113
|
+
s.connect(('8.8.8.8', 80))
|
|
114
|
+
return s.getsockname()[0]
|
|
115
|
+
except OSError:
|
|
116
|
+
return None
|
|
117
|
+
finally:
|
|
118
|
+
s.close()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _run_host_job(host_id, archive_dir, file_name, port, price, relay_urls, advertise_host, tunnel):
|
|
122
|
+
try:
|
|
123
|
+
with _quiet():
|
|
124
|
+
identity = _identity()
|
|
125
|
+
entry = node.find_manifest_entry(archive_dir, file_name)
|
|
126
|
+
# fail fast, before announcing anything — a manifest entry with no
|
|
127
|
+
# matching chunk data would otherwise get announced to the relay
|
|
128
|
+
# and only fail later, deep in a background thread with no way
|
|
129
|
+
# for the UI to ever find out
|
|
130
|
+
leaves = node.load_leaves(archive_dir, entry['sha256'])
|
|
131
|
+
announced = []
|
|
132
|
+
for relay_url in relay_urls:
|
|
133
|
+
host_addr = f'{advertise_host}:{port}'
|
|
134
|
+
node.publish(identity, relay_url, entry['sha256'], entry['name'], host_addr,
|
|
135
|
+
tunnel=tunnel)
|
|
136
|
+
announced.append(relay_url)
|
|
137
|
+
with _lock:
|
|
138
|
+
_hosts[host_id].update(name=entry['name'], content_hash=entry['sha256'],
|
|
139
|
+
announced_on=announced, status='running')
|
|
140
|
+
if tunnel:
|
|
141
|
+
relay_host, relay_port, use_tls = node._parse_tunnel(tunnel)
|
|
142
|
+
expanded_dir = os.path.expanduser(archive_dir)
|
|
143
|
+
file_path = entry.get('last_path') or os.path.join(expanded_dir, entry['name'])
|
|
144
|
+
threading.Thread(target=node.run_host_tunnel,
|
|
145
|
+
args=(relay_host, relay_port, entry['sha256'], entry, leaves,
|
|
146
|
+
file_path, price),
|
|
147
|
+
kwargs={'use_tls': use_tls, 'quiet': True}, daemon=True).start()
|
|
148
|
+
node.run_host_server(archive_dir, file_name, port, quiet=True, price=price)
|
|
149
|
+
except SystemExit as e:
|
|
150
|
+
with _lock:
|
|
151
|
+
_hosts[host_id].update(status='error', error=str(e))
|
|
152
|
+
except Exception as e:
|
|
153
|
+
with _lock:
|
|
154
|
+
_hosts[host_id].update(status='error', error=f'{type(e).__name__}: {e}')
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _run_download_job(job_id, content_hash, relay_urls, out_path, k, use_lightning):
|
|
158
|
+
def on_progress(idx, n_chunks):
|
|
159
|
+
with _lock:
|
|
160
|
+
_jobs[job_id].update(idx=idx, n_chunks=n_chunks)
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
with _quiet():
|
|
164
|
+
path = node.download_with_auction(content_hash, relay_urls, out_path=out_path, k=k,
|
|
165
|
+
use_lightning=use_lightning, on_progress=on_progress)
|
|
166
|
+
with _lock:
|
|
167
|
+
_jobs[job_id].update(status='done', path=path)
|
|
168
|
+
except SystemExit as e:
|
|
169
|
+
with _lock:
|
|
170
|
+
_jobs[job_id].update(status='error', error=str(e))
|
|
171
|
+
except Exception as e:
|
|
172
|
+
with _lock:
|
|
173
|
+
_jobs[job_id].update(status='error', error=f'{type(e).__name__}: {e}')
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class Handler(BaseHTTPRequestHandler):
|
|
177
|
+
def log_message(self, fmt, *args):
|
|
178
|
+
pass # quiet — this is a local UI, not a service worth logging every hit for
|
|
179
|
+
|
|
180
|
+
def _json(self, obj, status=200):
|
|
181
|
+
body = json.dumps(obj).encode()
|
|
182
|
+
self.send_response(status)
|
|
183
|
+
self.send_header('Content-Type', 'application/json')
|
|
184
|
+
self.send_header('Content-Length', str(len(body)))
|
|
185
|
+
self.end_headers()
|
|
186
|
+
self.wfile.write(body)
|
|
187
|
+
|
|
188
|
+
def _read_json_body(self):
|
|
189
|
+
length = int(self.headers.get('Content-Length', 0))
|
|
190
|
+
return json.loads(self.rfile.read(length)) if length else {}
|
|
191
|
+
|
|
192
|
+
def do_GET(self):
|
|
193
|
+
parsed = urlparse(self.path)
|
|
194
|
+
path, qs = parsed.path, parse_qs(parsed.query)
|
|
195
|
+
|
|
196
|
+
if path == '/api/whoami':
|
|
197
|
+
return self._json({'pubkey': _identity().pubkey_hex()})
|
|
198
|
+
if path == '/api/lan-url':
|
|
199
|
+
return self._json({'url': _lan_url})
|
|
200
|
+
if path == '/api/discover':
|
|
201
|
+
return self._json({'results': node.discover(qs.get('relay') or [DEFAULT_RELAY])})
|
|
202
|
+
if path == '/api/hosts':
|
|
203
|
+
with _lock:
|
|
204
|
+
return self._json({'hosts': list(_hosts.values())})
|
|
205
|
+
if path.startswith('/api/download/'):
|
|
206
|
+
with _lock:
|
|
207
|
+
job = _jobs.get(path[len('/api/download/'):])
|
|
208
|
+
return self._json(job) if job else self._json({'error': 'no such job'}, status=404)
|
|
209
|
+
if path.startswith('/api/reputation/'):
|
|
210
|
+
from poc_reputation import ReputationStore
|
|
211
|
+
pubkey = path[len('/api/reputation/'):]
|
|
212
|
+
reputation = ReputationStore(os.path.expanduser('~/.weed_reputation.json'))
|
|
213
|
+
score, why = reputation.trust_score(pubkey)
|
|
214
|
+
return self._json({'pubkey': pubkey, 'score': score, 'why': why})
|
|
215
|
+
if path.startswith('/api/stream/'):
|
|
216
|
+
return self._handle_stream(path[len('/api/stream/'):])
|
|
217
|
+
if path == '/api/qr':
|
|
218
|
+
data = (qs.get('data') or [''])[0]
|
|
219
|
+
if not data:
|
|
220
|
+
return self._json({'error': 'data query param required'}, status=400)
|
|
221
|
+
return self._handle_qr(data)
|
|
222
|
+
self._serve_static(path)
|
|
223
|
+
|
|
224
|
+
def do_POST(self):
|
|
225
|
+
path = urlparse(self.path).path
|
|
226
|
+
try:
|
|
227
|
+
body = self._read_json_body()
|
|
228
|
+
except Exception as e:
|
|
229
|
+
return self._json({'error': f'bad JSON body: {e}'}, status=400)
|
|
230
|
+
|
|
231
|
+
handlers = {
|
|
232
|
+
'/api/host': self._handle_host, '/api/download': self._handle_download,
|
|
233
|
+
'/api/like': self._handle_like, '/api/subscribe': self._handle_subscribe,
|
|
234
|
+
}
|
|
235
|
+
handler = handlers.get(path)
|
|
236
|
+
if not handler:
|
|
237
|
+
return self._json({'error': 'not found'}, status=404)
|
|
238
|
+
try:
|
|
239
|
+
handler(body)
|
|
240
|
+
except Exception as e:
|
|
241
|
+
self._json({'error': f'{type(e).__name__}: {e}'}, status=400)
|
|
242
|
+
|
|
243
|
+
def _handle_host(self, body):
|
|
244
|
+
archive_dir = body.get('archive_dir')
|
|
245
|
+
if not archive_dir:
|
|
246
|
+
return self._json({'error': 'archive_dir required'}, status=400)
|
|
247
|
+
port = int(body.get('port') or 9201)
|
|
248
|
+
price = int(body.get('price') or 0)
|
|
249
|
+
relay_urls = _as_list(body.get('relay'), [DEFAULT_RELAY])
|
|
250
|
+
advertise_host = body.get('advertise_host') or '127.0.0.1'
|
|
251
|
+
tunnel = body.get('tunnel') or None
|
|
252
|
+
|
|
253
|
+
host_id = uuid.uuid4().hex[:12]
|
|
254
|
+
with _lock:
|
|
255
|
+
_hosts[host_id] = {'id': host_id, 'archive_dir': archive_dir, 'port': port,
|
|
256
|
+
'price': price, 'tunnel': tunnel, 'status': 'starting'}
|
|
257
|
+
threading.Thread(target=_run_host_job,
|
|
258
|
+
args=(host_id, archive_dir, body.get('file_name'), port, price,
|
|
259
|
+
relay_urls, advertise_host, tunnel),
|
|
260
|
+
daemon=True).start()
|
|
261
|
+
self._json({'host_id': host_id})
|
|
262
|
+
|
|
263
|
+
def _handle_download(self, body):
|
|
264
|
+
content_hash = body.get('content_hash')
|
|
265
|
+
if not content_hash:
|
|
266
|
+
return self._json({'error': 'content_hash required'}, status=400)
|
|
267
|
+
relay_urls = _as_list(body.get('relay'), [DEFAULT_RELAY])
|
|
268
|
+
out_path = body.get('out_path') or f'download_{content_hash[:16]}'
|
|
269
|
+
k = int(body.get('k') or 3)
|
|
270
|
+
use_lightning = bool(body.get('lightning'))
|
|
271
|
+
|
|
272
|
+
job_id = uuid.uuid4().hex[:12]
|
|
273
|
+
with _lock:
|
|
274
|
+
_jobs[job_id] = {'status': 'running', 'idx': 0, 'n_chunks': None,
|
|
275
|
+
'content_hash': content_hash, 'path': None, 'error': None}
|
|
276
|
+
threading.Thread(target=_run_download_job,
|
|
277
|
+
args=(job_id, content_hash, relay_urls, out_path, k, use_lightning),
|
|
278
|
+
daemon=True).start()
|
|
279
|
+
self._json({'job_id': job_id})
|
|
280
|
+
|
|
281
|
+
def _handle_like(self, body):
|
|
282
|
+
content_hash = body.get('content_hash')
|
|
283
|
+
if not content_hash:
|
|
284
|
+
return self._json({'error': 'content_hash required'}, status=400)
|
|
285
|
+
identity = _identity()
|
|
286
|
+
event = identity.sign_event('like', content_hash=content_hash)
|
|
287
|
+
self._json({'result': node.post_event(body.get('relay') or DEFAULT_RELAY, event)})
|
|
288
|
+
|
|
289
|
+
def _handle_subscribe(self, body):
|
|
290
|
+
target_pubkey = body.get('target_pubkey')
|
|
291
|
+
if not target_pubkey:
|
|
292
|
+
return self._json({'error': 'target_pubkey required'}, status=400)
|
|
293
|
+
identity = _identity()
|
|
294
|
+
event = identity.sign_event('subscribe', target_pubkey=target_pubkey)
|
|
295
|
+
self._json({'result': node.post_event(body.get('relay') or DEFAULT_RELAY, event)})
|
|
296
|
+
|
|
297
|
+
def _handle_stream(self, job_id):
|
|
298
|
+
"""Serve an already-downloaded job's file with real HTTP range
|
|
299
|
+
support, so a <video> tag can seek/scrub instead of just
|
|
300
|
+
downloading the whole thing blind — the actual gap behind
|
|
301
|
+
'play/open media': the web UI could already trigger a download,
|
|
302
|
+
but the bytes only ever landed on this server's disk, never made
|
|
303
|
+
it to the browser. This is a thin BaseHTTPRequestHandler, so
|
|
304
|
+
range parsing is done by hand rather than pulled in from a
|
|
305
|
+
framework — same "no new dependency" choice as the rest of this
|
|
306
|
+
file."""
|
|
307
|
+
with _lock:
|
|
308
|
+
job = _jobs.get(job_id)
|
|
309
|
+
if not job or job.get('status') != 'done' or not job.get('path'):
|
|
310
|
+
return self._json({'error': 'no completed download for that job id'}, status=404)
|
|
311
|
+
path = job['path']
|
|
312
|
+
if not os.path.isfile(path):
|
|
313
|
+
return self._json({'error': f'{path} no longer exists on disk'}, status=404)
|
|
314
|
+
|
|
315
|
+
file_size = os.path.getsize(path)
|
|
316
|
+
ctype, _ = mimetypes.guess_type(path)
|
|
317
|
+
ctype = ctype or 'application/octet-stream'
|
|
318
|
+
|
|
319
|
+
range_header = self.headers.get('Range')
|
|
320
|
+
if range_header:
|
|
321
|
+
try:
|
|
322
|
+
_, _, range_spec = range_header.partition('=')
|
|
323
|
+
start_s, _, end_s = range_spec.partition('-')
|
|
324
|
+
start = int(start_s) if start_s else 0
|
|
325
|
+
end = int(end_s) if end_s else file_size - 1
|
|
326
|
+
end = min(end, file_size - 1)
|
|
327
|
+
except ValueError:
|
|
328
|
+
return self._json({'error': f'malformed Range header: {range_header!r}'}, status=400)
|
|
329
|
+
if start > end or start >= file_size:
|
|
330
|
+
self.send_response(416)
|
|
331
|
+
self.send_header('Content-Range', f'bytes */{file_size}')
|
|
332
|
+
self.end_headers()
|
|
333
|
+
return
|
|
334
|
+
length = end - start + 1
|
|
335
|
+
self.send_response(206)
|
|
336
|
+
self.send_header('Content-Range', f'bytes {start}-{end}/{file_size}')
|
|
337
|
+
else:
|
|
338
|
+
start, length = 0, file_size
|
|
339
|
+
self.send_response(200)
|
|
340
|
+
|
|
341
|
+
self.send_header('Content-Type', ctype)
|
|
342
|
+
self.send_header('Accept-Ranges', 'bytes')
|
|
343
|
+
self.send_header('Content-Length', str(length))
|
|
344
|
+
self.end_headers()
|
|
345
|
+
|
|
346
|
+
with open(path, 'rb') as f:
|
|
347
|
+
f.seek(start)
|
|
348
|
+
remaining = length
|
|
349
|
+
while remaining > 0:
|
|
350
|
+
chunk = f.read(min(262144, remaining))
|
|
351
|
+
if not chunk:
|
|
352
|
+
break
|
|
353
|
+
try:
|
|
354
|
+
self.wfile.write(chunk)
|
|
355
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
356
|
+
return # client seeked or closed mid-stream -- not an error
|
|
357
|
+
remaining -= len(chunk)
|
|
358
|
+
|
|
359
|
+
def _handle_qr(self, data):
|
|
360
|
+
"""PNG QR code of an arbitrary string -- same qrcode package and
|
|
361
|
+
same 'encode a URL so a phone can scan instead of type it' idea as
|
|
362
|
+
ott's own `ott qr`, just rendered as an image for the page to
|
|
363
|
+
embed instead of printed as terminal ASCII."""
|
|
364
|
+
if not _HAS_QR:
|
|
365
|
+
return self._json({'error': 'qrcode not installed on the server -- pip install qrcode'},
|
|
366
|
+
status=501)
|
|
367
|
+
import io
|
|
368
|
+
qr = qrcode.QRCode(border=1)
|
|
369
|
+
qr.add_data(data)
|
|
370
|
+
qr.make(fit=True)
|
|
371
|
+
img = qr.make_image()
|
|
372
|
+
buf = io.BytesIO()
|
|
373
|
+
img.save(buf, format='PNG')
|
|
374
|
+
body = buf.getvalue()
|
|
375
|
+
self.send_response(200)
|
|
376
|
+
self.send_header('Content-Type', 'image/png')
|
|
377
|
+
self.send_header('Content-Length', str(len(body)))
|
|
378
|
+
self.end_headers()
|
|
379
|
+
self.wfile.write(body)
|
|
380
|
+
|
|
381
|
+
def _serve_static(self, path):
|
|
382
|
+
if path == '/':
|
|
383
|
+
path = '/index.html'
|
|
384
|
+
safe_path = os.path.normpath(path).lstrip('/')
|
|
385
|
+
full_path = os.path.join(WEB_DIR, safe_path)
|
|
386
|
+
if not os.path.abspath(full_path).startswith(os.path.abspath(WEB_DIR)) \
|
|
387
|
+
or not os.path.isfile(full_path):
|
|
388
|
+
return self._json({'error': 'not found'}, status=404)
|
|
389
|
+
ctype, _ = mimetypes.guess_type(full_path)
|
|
390
|
+
with open(full_path, 'rb') as f:
|
|
391
|
+
body = f.read()
|
|
392
|
+
self.send_response(200)
|
|
393
|
+
self.send_header('Content-Type', ctype or 'application/octet-stream')
|
|
394
|
+
self.send_header('Content-Length', str(len(body)))
|
|
395
|
+
self.end_headers()
|
|
396
|
+
self.wfile.write(body)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def run_web_ui(port=8080, bind_host='127.0.0.1', quiet=False):
|
|
400
|
+
global _lan_url
|
|
401
|
+
srv = ThreadingHTTPServer((bind_host, port), Handler)
|
|
402
|
+
|
|
403
|
+
reachable_host = _detect_lan_ip() if bind_host == '0.0.0.0' else bind_host
|
|
404
|
+
if bind_host != '127.0.0.1' and reachable_host:
|
|
405
|
+
_lan_url = f'http://{reachable_host}:{port}/'
|
|
406
|
+
|
|
407
|
+
if not quiet:
|
|
408
|
+
print(f"[web:{port}] weed control UI at http://{bind_host}:{port}/", flush=True)
|
|
409
|
+
if bind_host == '127.0.0.1':
|
|
410
|
+
print(" bound to localhost only -- pass --bind 0.0.0.0 to reach this from your "
|
|
411
|
+
"phone (and get a scan-to-open QR here)", flush=True)
|
|
412
|
+
elif _lan_url:
|
|
413
|
+
if _HAS_QR:
|
|
414
|
+
print(f" scan to open on your phone ({_lan_url}):", flush=True)
|
|
415
|
+
qr = qrcode.QRCode(border=1)
|
|
416
|
+
qr.add_data(_lan_url)
|
|
417
|
+
qr.make(fit=True)
|
|
418
|
+
qr.print_ascii(invert=True)
|
|
419
|
+
else:
|
|
420
|
+
print(f" scan-to-open URL: {_lan_url} (pip install qrcode for a terminal "
|
|
421
|
+
f"QR code)", flush=True)
|
|
422
|
+
else:
|
|
423
|
+
print(" couldn't detect a LAN IP -- phone QR codes in the web UI won't work "
|
|
424
|
+
"until you restart with reachable networking", flush=True)
|
|
425
|
+
srv.serve_forever()
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def main():
|
|
429
|
+
import argparse
|
|
430
|
+
parser = argparse.ArgumentParser(description='weed local control UI')
|
|
431
|
+
parser.add_argument('port', nargs='?', type=int, default=8080,
|
|
432
|
+
help='port to listen on (default: 8080)')
|
|
433
|
+
parser.add_argument('--port', dest='port_flag', type=int,
|
|
434
|
+
help='same as the positional port arg, --port form')
|
|
435
|
+
parser.add_argument('--bind', default='127.0.0.1',
|
|
436
|
+
help='bind address (default: 127.0.0.1, local only -- no auth is '
|
|
437
|
+
'built, so only widen this on a network you trust)')
|
|
438
|
+
args = parser.parse_args()
|
|
439
|
+
port = args.port_flag if args.port_flag is not None else args.port
|
|
440
|
+
run_web_ui(port, bind_host=args.bind)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
if __name__ == '__main__':
|
|
444
|
+
main()
|
weed.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
weed — single CLI entry point for this repo's PoC mechanisms.
|
|
4
|
+
|
|
5
|
+
Two kinds of subcommand:
|
|
6
|
+
|
|
7
|
+
- demo/network/stats/... — thin argparse wrapper over the Makefile, not a
|
|
8
|
+
reimplementation of it. The Makefile stays the single source of truth
|
|
9
|
+
for what each demo actually runs; this just gives it subcommands and
|
|
10
|
+
`--help` text instead of needing to remember `make` targets.
|
|
11
|
+
|
|
12
|
+
- host/discover/download/like/subscribe/whoami — real commands, not demo
|
|
13
|
+
wrappers. These call directly into node.py and actually host a file,
|
|
14
|
+
actually download one from a peer with per-chunk verification, actually
|
|
15
|
+
post/read signed events from a relay. This is the integration piece —
|
|
16
|
+
one tool, not six disconnected scripts.
|
|
17
|
+
"""
|
|
18
|
+
import argparse
|
|
19
|
+
import os
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
|
|
23
|
+
REPO_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
24
|
+
sys.path.insert(0, REPO_DIR)
|
|
25
|
+
|
|
26
|
+
COMMANDS = {
|
|
27
|
+
'demo': ('demo', 'possession-gated auction, narrated (Parts 1 + 2)'),
|
|
28
|
+
'network': ('network', 'real sockets, single-shot challenge rounds, loopback'),
|
|
29
|
+
'stats': ('stats', 'real sockets, repeated-challenge separation analysis'),
|
|
30
|
+
'discovery': ('discovery', '3 real relays, personalized ranking, sybil test'),
|
|
31
|
+
'reputation': ('reputation', 'signed attestations + revocation demo'),
|
|
32
|
+
'chart': ('chart', 'regenerate the README separation chart'),
|
|
33
|
+
'containers': ('containers', 'same challenge test, real docker containers'),
|
|
34
|
+
'real-archive': ('real-archive', 'challenge mechanism against a real .ott video archive'),
|
|
35
|
+
'clean': ('clean', 'remove __pycache__, tmp reputation stores'),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
LIGHTNING_COMMANDS = {
|
|
39
|
+
'up': ('lightning-up', 'start bitcoind + 2 LND nodes on regtest'),
|
|
40
|
+
'down': ('lightning-down', 'tear down the lightning stack (drops chain state + wallets)'),
|
|
41
|
+
'demo': ('lightning-demo', 'poc_challenge_auction.py --lightning — real HTLC settlement'),
|
|
42
|
+
'smoke': ('lightning-smoke', 'one real test payment via lightning_settle.py'),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def run_make(target):
|
|
47
|
+
result = subprocess.run(['make', target], cwd=REPO_DIR)
|
|
48
|
+
sys.exit(result.returncode)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_parser():
|
|
52
|
+
parser = argparse.ArgumentParser(
|
|
53
|
+
prog='weed',
|
|
54
|
+
description='Censorship-resistant video PoC — real mechanisms behind the #all-pdx brainstorm.')
|
|
55
|
+
sub = parser.add_subparsers(dest='command', required=False)
|
|
56
|
+
|
|
57
|
+
sub.add_parser('shell', help='interactive shell with tab completion (also the default with no command)')
|
|
58
|
+
|
|
59
|
+
for name, (_target, help_text) in COMMANDS.items():
|
|
60
|
+
sub.add_parser(name, help=help_text)
|
|
61
|
+
|
|
62
|
+
lightning = sub.add_parser('lightning', help='real bitcoind + LND regtest stack (see lightning/README.md)')
|
|
63
|
+
lightning_sub = lightning.add_subparsers(dest='lightning_command', required=True)
|
|
64
|
+
for name, (_target, help_text) in LIGHTNING_COMMANDS.items():
|
|
65
|
+
lightning_sub.add_parser(name, help=help_text)
|
|
66
|
+
|
|
67
|
+
p_whoami = sub.add_parser('whoami', help='print your persistent node pubkey (~/.weed_identity.key)')
|
|
68
|
+
|
|
69
|
+
p_host = sub.add_parser('host', help='actually serve a real archived file to the real network')
|
|
70
|
+
p_host.add_argument('archive_dir', help='directory containing .ott/ (e.g. real_archive)')
|
|
71
|
+
p_host.add_argument('--file', help='which archived file, if more than one (default: most recent)')
|
|
72
|
+
p_host.add_argument('--port', type=int, default=9201)
|
|
73
|
+
p_host.add_argument('--price', type=int, default=0, help='sats to charge per download (default: free)')
|
|
74
|
+
p_host.add_argument('--relay', action='append', default=[], help='relay URL to announce on (repeatable)')
|
|
75
|
+
p_host.add_argument('--advertise-host', default='127.0.0.1',
|
|
76
|
+
help='address to tell the relay to advertise (set this to your real '
|
|
77
|
+
'reachable IP if hosting off localhost — or use --tunnel if you '
|
|
78
|
+
'have no reachable address at all, e.g. behind NAT/CGNAT)')
|
|
79
|
+
p_host.add_argument('--tunnel', metavar='[tls://]RELAY_HOST:PORT',
|
|
80
|
+
help='tunnel_relay.py address to register with instead of relying on a '
|
|
81
|
+
'reachable inbound port — see tunnel_relay.py. Downloaders connect '
|
|
82
|
+
'through the relay, not to you directly. Prefix with tls:// if the '
|
|
83
|
+
'relay terminates TLS at the edge (e.g. a Fly service with '
|
|
84
|
+
'handlers = ["tls"]) — the relay process itself never needs to know')
|
|
85
|
+
|
|
86
|
+
p_discover = sub.add_parser('discover', help='list real content announced on one or more relays')
|
|
87
|
+
p_discover.add_argument('--relay', action='append', default=['http://127.0.0.1:9101'],
|
|
88
|
+
help='relay URL to query (repeatable)')
|
|
89
|
+
|
|
90
|
+
p_download = sub.add_parser('download', aliases=['get'],
|
|
91
|
+
help='discover, possession-challenge, auction, optionally pay, '
|
|
92
|
+
'and download from the winning host — chunk-verified')
|
|
93
|
+
p_download.add_argument('content_hash', nargs='?', help='content hash to resolve via --relay')
|
|
94
|
+
p_download.add_argument('--from', dest='from_addr', help='host:port to connect to directly, skipping '
|
|
95
|
+
'discovery/auction entirely (no possession challenge, no reputation, no payment)')
|
|
96
|
+
p_download.add_argument('--relay', action='append', default=['http://127.0.0.1:9101'],
|
|
97
|
+
help='relay URL to resolve content_hash against (repeatable)')
|
|
98
|
+
p_download.add_argument('--out', help='output path (default: the advertised filename)')
|
|
99
|
+
p_download.add_argument('--challenge-rounds', type=int, default=3,
|
|
100
|
+
help='chunks to sample-verify per candidate host before trusting it (default: 3)')
|
|
101
|
+
p_download.add_argument('--lightning', action='store_true',
|
|
102
|
+
help='pay the winning host over a real Lightning HTLC if it has a price '
|
|
103
|
+
'(needs the lightning/ stack up — see lightning/README.md)')
|
|
104
|
+
|
|
105
|
+
p_like = sub.add_parser('like', help='sign and post a real like event')
|
|
106
|
+
p_like.add_argument('content_hash')
|
|
107
|
+
p_like.add_argument('--relay', default='http://127.0.0.1:9101')
|
|
108
|
+
|
|
109
|
+
p_subscribe = sub.add_parser('subscribe', help='sign and post a real subscribe event')
|
|
110
|
+
p_subscribe.add_argument('target_pubkey')
|
|
111
|
+
p_subscribe.add_argument('--relay', default='http://127.0.0.1:9101')
|
|
112
|
+
|
|
113
|
+
p_web = sub.add_parser('web', help='local web UI — discover/host/download/like/subscribe '
|
|
114
|
+
'from a browser instead of the CLI')
|
|
115
|
+
p_web.add_argument('--port', type=int, default=8080)
|
|
116
|
+
p_web.add_argument('--bind', default='127.0.0.1',
|
|
117
|
+
help='bind address (default 127.0.0.1 — local only; no auth is built, '
|
|
118
|
+
'so only widen this on a network you trust)')
|
|
119
|
+
|
|
120
|
+
p_serve = sub.add_parser('serve', help='alias for "web" with positional args, '
|
|
121
|
+
'e.g. `serve 0.0.0.0 8080`')
|
|
122
|
+
p_serve.add_argument('bind', nargs='?', default='127.0.0.1',
|
|
123
|
+
help='bind address (default 127.0.0.1; pass 0.0.0.0 to reach it '
|
|
124
|
+
'from your phone — no auth is built, only widen this on a '
|
|
125
|
+
'network you trust)')
|
|
126
|
+
p_serve.add_argument('port', nargs='?', type=int, default=8080)
|
|
127
|
+
|
|
128
|
+
return parser
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def cmd_whoami(args):
|
|
132
|
+
import node
|
|
133
|
+
identity = node.load_or_create_identity()
|
|
134
|
+
print(identity.pubkey_hex())
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def cmd_host(args):
|
|
138
|
+
import threading
|
|
139
|
+
import node
|
|
140
|
+
identity = node.load_or_create_identity()
|
|
141
|
+
entries = node.load_manifest_entries(args.archive_dir, args.file)
|
|
142
|
+
if args.tunnel and len(entries) > 1:
|
|
143
|
+
sys.exit(f"{len(entries)} files in {args.archive_dir} but --tunnel only serves one at a "
|
|
144
|
+
f"time — pass --file NAME to pick one")
|
|
145
|
+
# fail fast, before announcing anything — a manifest entry with no
|
|
146
|
+
# matching chunk data would otherwise get announced to the relay and
|
|
147
|
+
# only fail later, in the background server thread
|
|
148
|
+
all_leaves = {e['sha256']: node.load_leaves(args.archive_dir, e['sha256']) for e in entries}
|
|
149
|
+
for relay_url in args.relay:
|
|
150
|
+
host_addr = f'{args.advertise_host}:{args.port}'
|
|
151
|
+
for entry in entries:
|
|
152
|
+
result = node.publish(identity, relay_url, entry['sha256'], entry['name'], host_addr,
|
|
153
|
+
tunnel=args.tunnel)
|
|
154
|
+
print(f"announced {entry['name']} on {relay_url}: {result}")
|
|
155
|
+
if args.tunnel:
|
|
156
|
+
entry = entries[0]
|
|
157
|
+
relay_host, relay_port, use_tls = node._parse_tunnel(args.tunnel)
|
|
158
|
+
archive_dir = os.path.expanduser(args.archive_dir)
|
|
159
|
+
file_path = entry.get('last_path') or os.path.join(archive_dir, entry['name'])
|
|
160
|
+
threading.Thread(target=node.run_host_tunnel,
|
|
161
|
+
args=(relay_host, relay_port, entry['sha256'], entry,
|
|
162
|
+
all_leaves[entry['sha256']], file_path, args.price),
|
|
163
|
+
kwargs={'use_tls': use_tls}, daemon=True).start()
|
|
164
|
+
node.run_host_server(args.archive_dir, args.file, args.port, price=args.price)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def cmd_discover(args):
|
|
168
|
+
import node
|
|
169
|
+
results = node.discover(args.relay)
|
|
170
|
+
if not results:
|
|
171
|
+
print("nothing found (relay(s) unreachable, or nothing published yet)")
|
|
172
|
+
return
|
|
173
|
+
for r in results:
|
|
174
|
+
print(f" {r['title']!r:40s} hash={r['content_hash'][:16]}... host={r['host']} "
|
|
175
|
+
f"by={r['signer_pubkey'][:12]}...")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def cmd_download(args):
|
|
179
|
+
import node
|
|
180
|
+
if args.from_addr:
|
|
181
|
+
# skips discovery entirely, so no possession challenge, reputation,
|
|
182
|
+
# or auction happens — a deliberate escape hatch, not the normal path
|
|
183
|
+
out_path = args.out or f'download_{args.content_hash or "file"}'
|
|
184
|
+
node.download(args.from_addr, out_path)
|
|
185
|
+
return
|
|
186
|
+
if not args.content_hash:
|
|
187
|
+
sys.exit("need a content_hash (to resolve via --relay) or --from host:port")
|
|
188
|
+
node.download_with_auction(args.content_hash, args.relay, out_path=args.out,
|
|
189
|
+
k=args.challenge_rounds, use_lightning=args.lightning)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def cmd_like(args):
|
|
193
|
+
import node
|
|
194
|
+
identity = node.load_or_create_identity()
|
|
195
|
+
event = identity.sign_event('like', content_hash=args.content_hash)
|
|
196
|
+
print(node.post_event(args.relay, event))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def cmd_subscribe(args):
|
|
200
|
+
import node
|
|
201
|
+
identity = node.load_or_create_identity()
|
|
202
|
+
event = identity.sign_event('subscribe', target_pubkey=args.target_pubkey)
|
|
203
|
+
print(node.post_event(args.relay, event))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def cmd_web(args):
|
|
207
|
+
import web_ui
|
|
208
|
+
web_ui.run_web_ui(port=args.port, bind_host=args.bind)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
NATIVE_COMMANDS = {
|
|
212
|
+
'whoami': cmd_whoami, 'host': cmd_host, 'discover': cmd_discover,
|
|
213
|
+
'download': cmd_download, 'get': cmd_download, 'like': cmd_like, 'subscribe': cmd_subscribe,
|
|
214
|
+
'web': cmd_web, 'serve': cmd_web,
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def main():
|
|
219
|
+
args = build_parser().parse_args()
|
|
220
|
+
if args.command is None or args.command == 'shell':
|
|
221
|
+
import shell
|
|
222
|
+
try:
|
|
223
|
+
shell.WeedShell().cmdloop()
|
|
224
|
+
except KeyboardInterrupt:
|
|
225
|
+
print()
|
|
226
|
+
return
|
|
227
|
+
if args.command in NATIVE_COMMANDS:
|
|
228
|
+
NATIVE_COMMANDS[args.command](args)
|
|
229
|
+
return
|
|
230
|
+
if args.command == 'lightning':
|
|
231
|
+
target, _ = LIGHTNING_COMMANDS[args.lightning_command]
|
|
232
|
+
else:
|
|
233
|
+
target, _ = COMMANDS[args.command]
|
|
234
|
+
run_make(target)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
if __name__ == '__main__':
|
|
238
|
+
main()
|