weed-cli 1.2.3__tar.gz → 1.2.7__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: weed-cli
3
- Version: 1.2.3
3
+ Version: 1.2.7
4
4
  Summary: Censorship-resistant video PoC — discovery/hosting/download over signed relay events, a real Kademlia DHT, or a TLS-capable NAT-traversal tunnel
5
5
  License: MIT
6
6
  Keywords: p2p,video,censorship-resistant,discovery,dht,kademlia,nat-traversal
@@ -20,14 +20,46 @@ Ed25519 signing):
20
20
  subscribe {target_pubkey} — a viewer follows a creator/signer
21
21
  """
22
22
  import json
23
+ import os
23
24
  import sys
25
+ import threading
24
26
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
25
27
  from urllib.parse import urlparse, parse_qs
26
28
 
27
- sys.path.insert(0, __import__('os').path.dirname(__import__('os').path.abspath(__file__)))
29
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
28
30
  from poc_reputation import verify_attestation, attestation_id
29
31
 
30
- _events = [] # in-memory store — a real relay would use a real DB; irrelevant to the design
32
+ # Events are still held in-memory for serving (a real relay would use a
33
+ # real DB; irrelevant to the design) -- but write-through to a JSONL file
34
+ # too, so a restart (deploy, crash, or Fly scaling this to zero when
35
+ # idle) doesn't silently wipe every event that's ever been posted. Point
36
+ # WEED_RELAY_DATA at a mounted Fly Volume path (e.g. /data/events.jsonl)
37
+ # to survive machine restarts; the local relative-path default is fine
38
+ # for dev/shell.py use where nothing's actually being deployed.
39
+ DATA_PATH = os.environ.get('WEED_RELAY_DATA',
40
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), 'relay_events.jsonl'))
41
+ _events = []
42
+ _lock = threading.Lock()
43
+
44
+
45
+ def _load_events():
46
+ if not os.path.exists(DATA_PATH):
47
+ return
48
+ with open(DATA_PATH) as f:
49
+ for line in f:
50
+ line = line.strip()
51
+ if not line:
52
+ continue
53
+ try:
54
+ _events.append(json.loads(line))
55
+ except json.JSONDecodeError:
56
+ pass # tolerate a truncated last line from a killed-mid-write process
57
+
58
+
59
+ def _append_event(event):
60
+ os.makedirs(os.path.dirname(DATA_PATH) or '.', exist_ok=True)
61
+ with open(DATA_PATH, 'a') as f:
62
+ f.write(json.dumps(event) + '\n')
31
63
 
32
64
 
33
65
  class RelayHandler(BaseHTTPRequestHandler):
@@ -50,8 +82,10 @@ class RelayHandler(BaseHTTPRequestHandler):
50
82
  self.wfile.write(json.dumps({'ok': False, 'reason': reason}).encode())
51
83
  return
52
84
  eid = attestation_id(event)
53
- if not any(attestation_id(e) == eid for e in _events):
54
- _events.append(event)
85
+ with _lock:
86
+ if not any(attestation_id(e) == eid for e in _events):
87
+ _events.append(event)
88
+ _append_event(event)
55
89
  self.send_response(200)
56
90
  self.send_header('Content-Type', 'application/json')
57
91
  self.end_headers()
@@ -61,7 +95,8 @@ class RelayHandler(BaseHTTPRequestHandler):
61
95
  if self.path.split('?')[0] != '/events':
62
96
  self.send_response(404); self.end_headers(); return
63
97
  qs = parse_qs(urlparse(self.path).query)
64
- out = _events
98
+ with _lock:
99
+ out = list(_events)
65
100
  if 'type' in qs:
66
101
  out = [e for e in out if e['payload'].get('type') == qs['type'][0]]
67
102
  self.send_response(200)
@@ -79,9 +114,11 @@ def run_relay_server(port, quiet=False):
79
114
  read, so the two interleave and the prompt looks like it "disappeared."
80
115
  The shell already prints its own equivalent confirmation line, so this
81
116
  fixes it at the source instead of patching the visual symptom."""
117
+ _load_events()
82
118
  srv = ThreadingHTTPServer(('0.0.0.0', port), RelayHandler)
83
119
  if not quiet:
84
- print(f"[relay:{port}] up, no opinion on content, just store-and-forward", flush=True)
120
+ print(f"[relay:{port}] up, no opinion on content, just store-and-forward "
121
+ f"({len(_events)} event(s) loaded from {DATA_PATH})", flush=True)
85
122
  srv.serve_forever()
86
123
 
87
124
 
@@ -825,7 +825,26 @@ def download_with_auction(content_hash, relay_urls, out_path=None, k=3, use_ligh
825
825
 
826
826
  # ── discovery + social signals ──────────────────────────────────────────
827
827
 
828
+ def _relay_url_hint(relay_url):
829
+ """Relays are plain HTTP(S) endpoints, hit via urllib -- tls:// is a
830
+ completely different, unrelated convention that only means something
831
+ to --tunnel (a hand-rolled raw-TCP-plus-TLS protocol, see
832
+ _connect_tunnel_socket/connect_via_tunnel). Easy to mix up since both
833
+ flags take a host:port-shaped value and this same tool uses tls://
834
+ for the other one; without this check the failure is just urllib's
835
+ raw 'unknown url type: tls' with no hint about why."""
836
+ if relay_url.startswith('tls://'):
837
+ return (f"{relay_url!r} looks like a --tunnel address, not a relay URL — "
838
+ f"relays are plain HTTP(S) endpoints, try "
839
+ f"'https://{relay_url[len('tls://'):]}' for --relay instead "
840
+ f"(tls:// only means something to --tunnel)")
841
+ return None
842
+
843
+
828
844
  def post_event(relay_url, event):
845
+ hint = _relay_url_hint(relay_url)
846
+ if hint:
847
+ return {'ok': False, 'error': hint}
829
848
  req = urllib.request.Request(
830
849
  f'{relay_url}/event', data=json.dumps(event).encode(),
831
850
  headers={'Content-Type': 'application/json'}, method='POST')
@@ -845,6 +864,10 @@ def post_event(relay_url, event):
845
864
 
846
865
 
847
866
  def fetch_events(relay_url, event_type=None):
867
+ hint = _relay_url_hint(relay_url)
868
+ if hint:
869
+ print(f" {relay_url}: {hint}")
870
+ return None
848
871
  url = f'{relay_url}/events' + (f'?type={event_type}' if event_type else '')
849
872
  try:
850
873
  with urllib.request.urlopen(url, timeout=5) as resp:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "weed-cli"
7
- version = "1.2.3"
7
+ version = "1.2.7"
8
8
  description = "Censorship-resistant video PoC — discovery/hosting/download over signed relay events, a real Kademlia DHT, or a TLS-capable NAT-traversal tunnel"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -52,7 +52,15 @@ class WeedShell(cmd.Cmd):
52
52
  self.identity = node.load_or_create_identity()
53
53
  self._last_discovery = [] # cache of the last `discover` results, for tab completion
54
54
  self._host_threads = [] # background host() threads started this session
55
- self.default_relay = 'http://127.0.0.1:9101'
55
+ # $WEED_RELAY/$WEED_TUNNEL seed the session's defaults, same as
56
+ # weed.py's own CLI subcommands (see its _default_relay) -- so a
57
+ # real relay/tunnel deployment can be set once per shell session
58
+ # (or once in your shell rc) instead of retyped on every `host`/
59
+ # `discover`/`download`/`like`/`subscribe`. Explicit --relay/
60
+ # --tunnel on any command still always wins; `set` (below) can
61
+ # also change either mid-session.
62
+ self.default_relay = os.environ.get('WEED_RELAY', 'http://127.0.0.1:9101')
63
+ self.default_tunnel = os.environ.get('WEED_TUNNEL')
56
64
  self.dht_node = None # active dht.DHTNode, once `dht start` has run
57
65
 
58
66
  def preloop(self):
@@ -103,15 +111,18 @@ class WeedShell(cmd.Cmd):
103
111
  — serve every archived file in archive_dir in a background thread
104
112
  (shell stays usable), one port, downloaders SELECT which by content
105
113
  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
- Registers one control connection per file (REGISTER's token is
111
- each file's own content hash), so a whole tree tunnels fine, not
112
- just a single file. Prefix tls:// if the relay terminates TLS at
113
- the edge (e.g. a Fly service with handlers = ["tls"]) — the relay
114
- process itself never needs to know."""
114
+ on --relay (default: your session's default relay — see `relay`,
115
+ `set relay`, or $WEED_RELAY) unless --no-announce is given. --price
116
+ sets what download charges (default free). --tunnel registers with
117
+ a tunnel_relay.py instead of relying on a reachable inbound port —
118
+ for hosting behind NAT/CGNAT (default: your session's default
119
+ tunnel — see `set tunnel` or $WEED_TUNNEL). Registers one control
120
+ connection per file (REGISTER's token is each file's own content
121
+ hash), so a whole tree tunnels fine, not just a single file. Prefix
122
+ tls:// if the relay terminates TLS at the edge (e.g. a Fly service
123
+ with handlers = ["tls"]) — the relay process itself never needs to
124
+ know. An explicit --relay/--tunnel here also becomes the new
125
+ session default, same as `discover` already does for --relay."""
115
126
  parts = shlex.split(arg)
116
127
  if not parts:
117
128
  print(' usage: host <archive_dir> [--file NAME] [--port N] [--price SAT] '
@@ -125,7 +136,7 @@ class WeedShell(cmd.Cmd):
125
136
  relay = self.default_relay
126
137
  no_announce = '--no-announce' in parts
127
138
  advertise_host = '127.0.0.1'
128
- tunnel = None
139
+ tunnel = self.default_tunnel
129
140
  i = 1
130
141
  while i < len(parts):
131
142
  if parts[i] == '--file' and i + 1 < len(parts):
@@ -148,6 +159,11 @@ class WeedShell(cmd.Cmd):
148
159
  tunnel = parts[i]
149
160
  i += 1
150
161
 
162
+ # remember an explicit --relay/--tunnel for the rest of the
163
+ # session, same as `discover` already does for --relay
164
+ self.default_relay = relay
165
+ self.default_tunnel = tunnel
166
+
151
167
  entries = node.load_manifest_entries(archive_dir, file_name)
152
168
  # fail fast, before announcing anything — a manifest entry with no
153
169
  # matching chunk data would otherwise get announced to the relay
@@ -201,6 +217,36 @@ class WeedShell(cmd.Cmd):
201
217
  self.default_relay = f'http://127.0.0.1:{port}'
202
218
  print(f' relay running on port {port} in the background — set as your default relay')
203
219
 
220
+ def complete_set(self, text, line, begidx, endidx):
221
+ parts = shlex.split(line[:begidx])
222
+ if len(parts) == 1:
223
+ return [s for s in ('relay', 'tunnel') if s.startswith(text)]
224
+ return []
225
+
226
+ def do_set(self, arg):
227
+ """set relay <URL> — set the session's default relay directly,
228
+ without needing to start one (`relay`) or run `discover` first.
229
+ set tunnel <[tls://]HOST:PORT> — same, for the default tunnel.
230
+ set (no args) — show both current defaults.
231
+ Either can also be set once via $WEED_RELAY/$WEED_TUNNEL before
232
+ launching the shell, and an explicit --relay/--tunnel on `host`
233
+ or `discover` updates the session default too."""
234
+ parts = shlex.split(arg)
235
+ if not parts:
236
+ print(f' relay: {self.default_relay or "(none)"}')
237
+ print(f' tunnel: {self.default_tunnel or "(none)"}')
238
+ return
239
+ if len(parts) != 2 or parts[0] not in ('relay', 'tunnel'):
240
+ print(' usage: set relay <URL> | set tunnel <[tls://]HOST:PORT> | set')
241
+ return
242
+ what, value = parts
243
+ if what == 'relay':
244
+ self.default_relay = value
245
+ print(f' default relay set to {value}')
246
+ else:
247
+ self.default_tunnel = value
248
+ print(f' default tunnel set to {value}')
249
+
204
250
  def do_serve(self, arg):
205
251
  """serve [bind] [port] — run the local web control UI in the background
206
252
  (default 127.0.0.1:8080; pass 0.0.0.0 to reach it from your phone).
@@ -297,7 +343,7 @@ class WeedShell(cmd.Cmd):
297
343
 
298
344
  def do_discover(self, arg):
299
345
  """discover [relay_url ...] — list content announced on one or more relays
300
- (default: the last relay used, or http://127.0.0.1:9101)."""
346
+ (default: the last relay used, $WEED_RELAY, or http://127.0.0.1:9101)."""
301
347
  relays = shlex.split(arg) or [self.default_relay]
302
348
  self.default_relay = relays[0]
303
349
  results = node.discover(relays)
@@ -137,7 +137,7 @@ def _rehydrate_jobs_from_library():
137
137
  'status': 'done', 'idx': 0, 'n_chunks': None,
138
138
  'content_hash': content_hash, 'path': rec['path'],
139
139
  'title': rec.get('title'), 'size': rec.get('size'), 'bps': rec.get('bps'),
140
- 'signer_pubkey': rec.get('signer_pubkey'), 'error': None,
140
+ 'error': None,
141
141
  }
142
142
  _lan_url = None # set once in run_web_ui() -- the base URL a phone on the
143
143
  # same LAN can actually reach this server at, or None if
@@ -211,8 +211,7 @@ def _run_host_job(host_id, archive_dir, file_name, port, price, relay_urls, adve
211
211
  _hosts[host_id].update(status='error', error=f'{type(e).__name__}: {e}')
212
212
 
213
213
 
214
- def _run_download_job(job_id, content_hash, relay_urls, out_path, k, use_lightning, title=None,
215
- signer_pubkey=None):
214
+ def _run_download_job(job_id, content_hash, relay_urls, out_path, k, use_lightning, title=None):
216
215
  def on_progress(idx, n_chunks):
217
216
  with _lock:
218
217
  _jobs[job_id].update(idx=idx, n_chunks=n_chunks)
@@ -230,7 +229,6 @@ def _run_download_job(job_id, content_hash, relay_urls, out_path, k, use_lightni
230
229
  _library['downloads'][content_hash] = {
231
230
  'content_hash': content_hash, 'job_id': job_id, 'path': path,
232
231
  'title': title, 'downloaded_at': time.time(), 'size': size, 'bps': bps,
233
- 'signer_pubkey': signer_pubkey,
234
232
  }
235
233
  _save_library()
236
234
  except SystemExit as e:
@@ -358,16 +356,13 @@ class Handler(BaseHTTPRequestHandler):
358
356
  k = int(body.get('k') or 3)
359
357
  use_lightning = bool(body.get('lightning'))
360
358
  title = body.get('title')
361
- signer_pubkey = body.get('signer_pubkey')
362
359
 
363
360
  job_id = uuid.uuid4().hex[:12]
364
361
  with _lock:
365
362
  _jobs[job_id] = {'status': 'running', 'idx': 0, 'n_chunks': None,
366
- 'content_hash': content_hash, 'path': None, 'title': title,
367
- 'signer_pubkey': signer_pubkey, 'error': None}
363
+ 'content_hash': content_hash, 'path': None, 'title': title, 'error': None}
368
364
  threading.Thread(target=_run_download_job,
369
- args=(job_id, content_hash, relay_urls, out_path, k, use_lightning, title,
370
- signer_pubkey),
365
+ args=(job_id, content_hash, relay_urls, out_path, k, use_lightning, title),
371
366
  daemon=True).start()
372
367
  self._json({'job_id': job_id})
373
368
 
@@ -48,6 +48,26 @@ def run_make(target):
48
48
  sys.exit(result.returncode)
49
49
 
50
50
 
51
+ # $WEED_RELAY/$WEED_TUNNEL let a real relay/tunnel deployment (not just
52
+ # the loopback defaults everything below otherwise falls back to) be set
53
+ # once per shell session instead of retyped on every host/discover/
54
+ # download/like/subscribe invocation -- an explicit --relay/--tunnel
55
+ # flag still always wins, this only changes what happens when neither is
56
+ # given. shell.py's WeedShell reads the same two variables for the same
57
+ # reason (see its own default_relay/default_tunnel).
58
+ def _default_relay():
59
+ return os.environ.get('WEED_RELAY', 'http://127.0.0.1:9101')
60
+
61
+
62
+ def _default_relay_list():
63
+ # --relay is action='append' on p_host specifically, which normally
64
+ # defaults to [] (empty = "don't announce anywhere" is a valid,
65
+ # deliberate choice there — see cmd_host) rather than falling back to
66
+ # loopback; only seed that list from $WEED_RELAY when it's actually set
67
+ relay = os.environ.get('WEED_RELAY')
68
+ return [relay] if relay else []
69
+
70
+
51
71
  def build_parser():
52
72
  import node
53
73
  parser = argparse.ArgumentParser(
@@ -73,21 +93,24 @@ def build_parser():
73
93
  p_host.add_argument('--file', help='which archived file, if more than one (default: most recent)')
74
94
  p_host.add_argument('--port', type=int, default=9201)
75
95
  p_host.add_argument('--price', type=int, default=0, help='sats to charge per download (default: free)')
76
- p_host.add_argument('--relay', action='append', default=[], help='relay URL to announce on (repeatable)')
96
+ p_host.add_argument('--relay', action='append', default=_default_relay_list(),
97
+ help='relay URL to announce on (repeatable; default: $WEED_RELAY if set)')
77
98
  p_host.add_argument('--advertise-host', default='127.0.0.1',
78
99
  help='address to tell the relay to advertise (set this to your real '
79
100
  'reachable IP if hosting off localhost — or use --tunnel if you '
80
101
  'have no reachable address at all, e.g. behind NAT/CGNAT)')
81
- p_host.add_argument('--tunnel', metavar='[tls://]RELAY_HOST:PORT',
102
+ p_host.add_argument('--tunnel', metavar='[tls://]RELAY_HOST:PORT', default=os.environ.get('WEED_TUNNEL'),
82
103
  help='tunnel_relay.py address to register with instead of relying on a '
83
104
  'reachable inbound port — see tunnel_relay.py. Downloaders connect '
84
105
  'through the relay, not to you directly. Prefix with tls:// if the '
85
106
  'relay terminates TLS at the edge (e.g. a Fly service with '
86
- 'handlers = ["tls"]) — the relay process itself never needs to know')
107
+ 'handlers = ["tls"]) — the relay process itself never needs to know. '
108
+ 'Default: $WEED_TUNNEL if set.')
87
109
 
88
110
  p_discover = sub.add_parser('discover', help='list real content announced on one or more relays')
89
- p_discover.add_argument('--relay', action='append', default=['http://127.0.0.1:9101'],
90
- help='relay URL to query (repeatable)')
111
+ p_discover.add_argument('--relay', action='append', default=[_default_relay()],
112
+ help='relay URL to query (repeatable; default: $WEED_RELAY if set, '
113
+ 'else http://127.0.0.1:9101)')
91
114
 
92
115
  p_download = sub.add_parser('download', aliases=['get'],
93
116
  help='discover, possession-challenge, auction, optionally pay, '
@@ -95,8 +118,9 @@ def build_parser():
95
118
  p_download.add_argument('content_hash', nargs='?', help='content hash to resolve via --relay')
96
119
  p_download.add_argument('--from', dest='from_addr', help='host:port to connect to directly, skipping '
97
120
  'discovery/auction entirely (no possession challenge, no reputation, no payment)')
98
- p_download.add_argument('--relay', action='append', default=['http://127.0.0.1:9101'],
99
- help='relay URL to resolve content_hash against (repeatable)')
121
+ p_download.add_argument('--relay', action='append', default=[_default_relay()],
122
+ help='relay URL to resolve content_hash against (repeatable; '
123
+ 'default: $WEED_RELAY if set, else http://127.0.0.1:9101)')
100
124
  p_download.add_argument('--out', help='output path (default: the advertised filename)')
101
125
  p_download.add_argument('--challenge-rounds', type=int, default=3,
102
126
  help='chunks to sample-verify per candidate host before trusting it (default: 3)')
@@ -106,11 +130,13 @@ def build_parser():
106
130
 
107
131
  p_like = sub.add_parser('like', help='sign and post a real like event')
108
132
  p_like.add_argument('content_hash')
109
- p_like.add_argument('--relay', default='http://127.0.0.1:9101')
133
+ p_like.add_argument('--relay', default=_default_relay(),
134
+ help='default: $WEED_RELAY if set, else http://127.0.0.1:9101')
110
135
 
111
136
  p_subscribe = sub.add_parser('subscribe', help='sign and post a real subscribe event')
112
137
  p_subscribe.add_argument('target_pubkey')
113
- p_subscribe.add_argument('--relay', default='http://127.0.0.1:9101')
138
+ p_subscribe.add_argument('--relay', default=_default_relay(),
139
+ help='default: $WEED_RELAY if set, else http://127.0.0.1:9101')
114
140
 
115
141
  p_web = sub.add_parser('web', help='local web UI — discover/host/download/like/subscribe '
116
142
  'from a browser instead of the CLI')
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: weed-cli
3
- Version: 1.2.3
3
+ Version: 1.2.7
4
4
  Summary: Censorship-resistant video PoC — discovery/hosting/download over signed relay events, a real Kademlia DHT, or a TLS-capable NAT-traversal tunnel
5
5
  License: MIT
6
6
  Keywords: p2p,video,censorship-resistant,discovery,dht,kademlia,nat-traversal
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes