weed-cli 1.6.0__tar.gz → 1.6.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.6.0
3
+ Version: 1.6.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
@@ -174,17 +174,28 @@ blind.
174
174
  ### Docker
175
175
 
176
176
  `Dockerfile.node` + `docker-compose.node.yml` package `web_ui.py` to run
177
- somewhere other than a laptop.
177
+ somewhere other than a laptop — alongside its own local discovery relay
178
+ (`Dockerfile.discovery-relay`), so there's always something real for it
179
+ to announce to and discover from out of the box.
178
180
 
179
181
  ```bash
180
182
  make node # build + run, http://127.0.0.1:8080
181
183
  make node-down # stop it — data persists
184
+ make node-shell # interactive weed shell, same container as the running node
182
185
  WEED_SHARE_DIR=~/Movies make node # mount a real directory of .ott archives at /share
186
+ docker compose -f docker-compose.node.yml exec node python3 weed.py discover # or a one-off CLI command
183
187
  ```
184
188
 
185
189
  - Identity key, reputation store, and library manifest all persist in a
186
190
  named volume (`node-data`, mounted at `$HOME=/data`) — one node keeps
187
191
  the same pubkey (and everything vouching for it) across restarts.
192
+ - The `relay` service is `node`'s default `WEED_RELAY` (`http://relay:9101`,
193
+ resolved over Compose's own network) — its events persist in their own
194
+ `relay-data` volume, and port 9101 is published to the host too, for
195
+ curling `/events` directly or pointing a non-Docker `weed`/shell session
196
+ at the exact same relay. Override `WEED_RELAY` (shell env or `.env`,
197
+ see `.env.sample`) to point at public infra instead, e.g. for a real
198
+ end-to-end test.
188
199
  - `/share` is a separate *bind* mount, not a named volume, so the actual
189
200
  `.ott` archives you're hosting are real files on the host you can see
190
201
  and manage directly. Point the web UI's Host form at `/share`.
@@ -144,17 +144,28 @@ blind.
144
144
  ### Docker
145
145
 
146
146
  `Dockerfile.node` + `docker-compose.node.yml` package `web_ui.py` to run
147
- somewhere other than a laptop.
147
+ somewhere other than a laptop — alongside its own local discovery relay
148
+ (`Dockerfile.discovery-relay`), so there's always something real for it
149
+ to announce to and discover from out of the box.
148
150
 
149
151
  ```bash
150
152
  make node # build + run, http://127.0.0.1:8080
151
153
  make node-down # stop it — data persists
154
+ make node-shell # interactive weed shell, same container as the running node
152
155
  WEED_SHARE_DIR=~/Movies make node # mount a real directory of .ott archives at /share
156
+ docker compose -f docker-compose.node.yml exec node python3 weed.py discover # or a one-off CLI command
153
157
  ```
154
158
 
155
159
  - Identity key, reputation store, and library manifest all persist in a
156
160
  named volume (`node-data`, mounted at `$HOME=/data`) — one node keeps
157
161
  the same pubkey (and everything vouching for it) across restarts.
162
+ - The `relay` service is `node`'s default `WEED_RELAY` (`http://relay:9101`,
163
+ resolved over Compose's own network) — its events persist in their own
164
+ `relay-data` volume, and port 9101 is published to the host too, for
165
+ curling `/events` directly or pointing a non-Docker `weed`/shell session
166
+ at the exact same relay. Override `WEED_RELAY` (shell env or `.env`,
167
+ see `.env.sample`) to point at public infra instead, e.g. for a real
168
+ end-to-end test.
158
169
  - `/share` is a separate *bind* mount, not a named volume, so the actual
159
170
  `.ott` archives you're hosting are real files on the host you can see
160
171
  and manage directly. Point the web UI's Host form at `/share`.
@@ -19,6 +19,7 @@ every previous script verified chunks locally or over a network, none of
19
19
  them reassembled a full file from a remote peer onto disk before this.
20
20
  """
21
21
  import base64
22
+ import concurrent.futures
22
23
  import hashlib
23
24
  import json
24
25
  import os
@@ -44,8 +45,12 @@ TAGLINE = 'we do in 4 what others do in 5'
44
45
 
45
46
 
46
47
  def weed_version():
47
- """Installed package version (pyproject.toml's source of truth), with a
48
- fallback for running straight from source without `pip install -e .`."""
48
+ """Installed package version (pyproject.toml's source of truth), with
49
+ fallbacks for the two other real ways this runs: straight from a git
50
+ checkout without `pip install -e .` (read pyproject.toml directly —
51
+ Dockerfile.node copies it in for exactly this, since it copies loose
52
+ .py files rather than pip-installing the package), or neither file
53
+ present at all (genuinely no version info available)."""
49
54
  try:
50
55
  from importlib.metadata import version, PackageNotFoundError
51
56
  try:
@@ -54,6 +59,15 @@ def weed_version():
54
59
  pass
55
60
  except ImportError:
56
61
  pass
62
+ pyproject_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'pyproject.toml')
63
+ try:
64
+ with open(pyproject_path) as f:
65
+ for line in f:
66
+ line = line.strip()
67
+ if line.startswith('version'):
68
+ return line.split('=', 1)[1].strip().strip('"\'')
69
+ except (OSError, IndexError):
70
+ pass
57
71
  return '0.0.0-dev'
58
72
 
59
73
 
@@ -65,9 +79,36 @@ def _git_commit_hash():
65
79
  behind a real debugging session: two checkouts reporting the same
66
80
  version, one of them missing a fix the other had. None for anything
67
81
  that isn't a git checkout at all (installed from a built wheel/sdist,
68
- no .git present) — a version number is all there is to go on there."""
82
+ no .git present) — a version number is all there is to go on there.
83
+
84
+ $WEED_GIT_COMMIT checked first — Dockerfile.node has no .git directory
85
+ at all (never copied in, on purpose: bloats the image and ships the
86
+ full history for no reason), so a container build has no way to
87
+ answer this live. docker-compose.node.yml passes the *host's* commit
88
+ hash in as a build arg at image-build time instead, baked in as this
89
+ env var, same intent as the live git lookup below just computed once,
90
+ earlier, somewhere that actually has the repo.
91
+
92
+ `git rev-parse` walks *up* parent directories looking for a .git —
93
+ a real `pip install .` (not `-e`) copies this file into site-packages,
94
+ which usually isn't a checkout at all, but if it happens to be nested
95
+ anywhere under some unrelated git-tracked ancestor directory (a
96
+ dotfiles repo, a pyenv install tracked in git, anything above it),
97
+ this would otherwise silently report *that* repo's own unrelated
98
+ commit — worse than showing nothing, since it looks plausible. Only
99
+ trust the hash once --show-toplevel confirms this file's own
100
+ directory really is that repo's root."""
101
+ env_commit = os.environ.get('WEED_GIT_COMMIT')
102
+ if env_commit:
103
+ return env_commit
69
104
  repo_dir = os.path.dirname(os.path.abspath(__file__))
70
105
  try:
106
+ toplevel = subprocess.run(['git', 'rev-parse', '--show-toplevel'], cwd=repo_dir,
107
+ capture_output=True, text=True, timeout=2)
108
+ if toplevel.returncode != 0:
109
+ return None
110
+ if os.path.realpath(toplevel.stdout.strip()) != os.path.realpath(repo_dir):
111
+ return None
71
112
  result = subprocess.run(['git', 'rev-parse', '--short', 'HEAD'], cwd=repo_dir,
72
113
  capture_output=True, text=True, timeout=2)
73
114
  if result.returncode == 0:
@@ -337,8 +378,24 @@ def serve_session(conn, entries_by_hash, default_hash, price, ln_node=None):
337
378
  _graceful_close(conn)
338
379
 
339
380
 
381
+ def bind_host_port(port, bind_host='0.0.0.0'):
382
+ """Split out of run_host_server so a caller (see web_ui.py's
383
+ _run_host_job) can bind the real, permanent listening socket early —
384
+ right where a port-collision needs to fail fast, before announcing
385
+ anything — and hand that exact socket to run_host_server later
386
+ instead of it binding a second, separate one. A bind-then-close probe
387
+ followed by a *second*, later bind of the same port isn't atomic
388
+ across two hosts starting concurrently: both can pass the probe
389
+ before either does the real bind. Binding once, early, and reusing
390
+ the same socket removes that race entirely instead of narrowing it."""
391
+ srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
392
+ srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
393
+ srv.bind((bind_host, port))
394
+ return srv
395
+
396
+
340
397
  def run_host_server(archive_dir, file_name, port, bind_host='0.0.0.0', quiet=False, price=0,
341
- ln_node=None):
398
+ ln_node=None, sock=None):
342
399
  archive_dir = os.path.expanduser(archive_dir)
343
400
  entries = load_manifest_entries(archive_dir, file_name)
344
401
  entries_by_hash = {}
@@ -350,9 +407,7 @@ def run_host_server(archive_dir, file_name, port, bind_host='0.0.0.0', quiet=Fal
350
407
  entries_by_hash[entry['sha256']] = (entry, leaves, file_path)
351
408
  default_hash = next(iter(entries_by_hash)) if len(entries_by_hash) == 1 else None
352
409
 
353
- srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
354
- srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
355
- srv.bind((bind_host, port))
410
+ srv = sock if sock is not None else bind_host_port(port, bind_host)
356
411
  srv.listen(8)
357
412
  if not quiet:
358
413
  # a background thread's print() races with cmd.Cmd's input()-driven
@@ -979,19 +1034,61 @@ def _relay_url_hint(relay_url):
979
1034
  return None
980
1035
 
981
1036
 
1037
+ def _normalize_relay_url(relay_url):
1038
+ """A bare host:port with no scheme (e.g. '127.0.0.1:9101', the most
1039
+ natural way to type a local relay) isn't a URL urllib can parse at
1040
+ all -- everything before the first ':' becomes the *scheme*, so this
1041
+ reads as scheme '127.0.0.1' and raises "unknown url type", not a
1042
+ connection error. That already lands on post_event/fetch_events'
1043
+ existing except clauses (an unreachable relay is routine, not
1044
+ exceptional), which is exactly the trap: hosting against a
1045
+ scheme-less relay used to report status: running with zero actual
1046
+ announcements ever reaching it, and discover against one silently
1047
+ came back empty -- nothing hinted that the URL itself, not the
1048
+ relay, was the problem. Every relay this project actually ships or
1049
+ documents is plain HTTP(S), so defaulting a missing scheme to
1050
+ http:// turns the common typo into the thing the user obviously
1051
+ meant instead of a silent no-op."""
1052
+ if '://' not in relay_url:
1053
+ return 'http://' + relay_url
1054
+ return relay_url
1055
+
1056
+
1057
+ _dns_bound_pool = concurrent.futures.ThreadPoolExecutor(max_workers=8, thread_name_prefix='urlopen')
1058
+
1059
+
1060
+ def _urlopen_bounded(req_or_url, timeout=5):
1061
+ """urlopen's own timeout= only bounds the connect/read phases — the
1062
+ getaddrinfo() call underneath (DNS resolution) is a blocking libc call
1063
+ with no timeout of its own, so a relay hostname that's slow or
1064
+ unreachable to resolve can hang here indefinitely no matter what
1065
+ timeout= says. That's exactly the situation right after a container
1066
+ boots and its network/DNS isn't fully settled yet: _resume_persisted_hosts
1067
+ (web_ui.py) resuming a host at that moment used to get stuck at
1068
+ "(starting…)" forever, since nothing downstream ever times out either.
1069
+ Running the call in a worker thread and bounding *that* with
1070
+ .result(timeout=) catches DNS hangs the same as connect/read hangs —
1071
+ the stuck worker thread leaks (a blocking C call can't be cancelled),
1072
+ but it's a daemon-pool thread and this process was going to give up and
1073
+ move on regardless."""
1074
+ future = _dns_bound_pool.submit(urllib.request.urlopen, req_or_url, timeout=timeout)
1075
+ return future.result(timeout=timeout + 1)
1076
+
1077
+
982
1078
  def post_event(relay_url, event):
983
1079
  hint = _relay_url_hint(relay_url)
984
1080
  if hint:
985
1081
  return {'ok': False, 'error': hint}
1082
+ relay_url = _normalize_relay_url(relay_url)
986
1083
  req = urllib.request.Request(
987
1084
  f'{relay_url}/event', data=json.dumps(event).encode(),
988
1085
  headers={'Content-Type': 'application/json'}, method='POST')
989
1086
  try:
990
- with urllib.request.urlopen(req, timeout=5) as resp:
1087
+ with _urlopen_bounded(req, timeout=5) as resp:
991
1088
  return json.loads(resp.read())
992
1089
  except urllib.error.HTTPError as e:
993
1090
  return json.loads(e.read())
994
- except (urllib.error.URLError, ConnectionRefusedError) as e:
1091
+ except (urllib.error.URLError, ConnectionRefusedError, concurrent.futures.TimeoutError) as e:
995
1092
  # download_with_auction posts the post-download attestation to every
996
1093
  # relay in relay_urls, which by default includes 127.0.0.1:9101
997
1094
  # whether or not anything is actually listening there (same default
@@ -1006,11 +1103,12 @@ def fetch_events(relay_url, event_type=None):
1006
1103
  if hint:
1007
1104
  print(f" {relay_url}: {hint}")
1008
1105
  return None
1106
+ relay_url = _normalize_relay_url(relay_url)
1009
1107
  url = f'{relay_url}/events' + (f'?type={event_type}' if event_type else '')
1010
1108
  try:
1011
- with urllib.request.urlopen(url, timeout=5) as resp:
1109
+ with _urlopen_bounded(url, timeout=5) as resp:
1012
1110
  return json.loads(resp.read())
1013
- except (urllib.error.URLError, ConnectionRefusedError):
1111
+ except (urllib.error.URLError, ConnectionRefusedError, concurrent.futures.TimeoutError):
1014
1112
  return None
1015
1113
 
1016
1114
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "weed-cli"
7
- version = "1.6.0"
7
+ version = "1.6.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" }
@@ -187,9 +187,35 @@ def _ott_status(archive_dir):
187
187
 
188
188
 
189
189
  def _resume_persisted_hosts():
190
+ """Sequential on purpose, not fire-and-forget-them-all-at-once: two
191
+ persisted configs sharing a port (the default in the Host form is
192
+ always 9201, so this is the common case, not an edge case) could
193
+ both bind successfully if started concurrently, since node.bind_host_port
194
+ for the second one wouldn't even run until the first's already had time
195
+ to occupy the port — but nothing serializes *when* each thread gets
196
+ there. Waiting for each host to reach 'bound' (or 'error', if the bind
197
+ itself failed) before starting the next one closes that window: by the
198
+ time the next one binds, the previous one already has the port or
199
+ already knows it can't.
200
+
201
+ Only waits for the bind, not all the way to 'running' — 'running'
202
+ doesn't happen until after each host's full publish loop (real network
203
+ I/O, up to 5s timeout per relay), which several persisted hosts
204
+ resuming in sequence have no reason to serialize on. That was a real
205
+ regression the first version of this function had: booting with a
206
+ handful of persisted hosts got noticeably slower, for zero extra
207
+ correctness, since the publish delay doesn't affect port ownership at
208
+ all."""
190
209
  for cfg in _persisted_hosts.values():
191
- _start_host_job(cfg['archive_dir'], cfg['file_name'], cfg['port'], cfg['price'],
192
- cfg['relay'], cfg['advertise_host'], cfg['tunnel'], cfg.get('lightning_node'))
210
+ host_id = _start_host_job(cfg['archive_dir'], cfg['file_name'], cfg['port'], cfg['price'],
211
+ cfg['relay'], cfg['advertise_host'], cfg['tunnel'],
212
+ cfg.get('lightning_node'))
213
+ for _ in range(100): # up to ~10s per host before moving on regardless
214
+ with _lock:
215
+ status = _hosts[host_id]['status']
216
+ if status in ('bound', 'running', 'error'):
217
+ break
218
+ time.sleep(0.1)
193
219
 
194
220
 
195
221
  def _unpublish_all_hosts():
@@ -284,29 +310,6 @@ def _detect_lan_ip():
284
310
  s.close()
285
311
 
286
312
 
287
- def _probe_port_free(port, bind_host='0.0.0.0'):
288
- """Bind-and-immediately-release, purely to fail fast on a port
289
- collision *before* announcing anything — see _run_host_job's real
290
- incident: the web UI's Host form always defaults to port 9201, so
291
- hosting more than one file (including multiple entries auto-resumed
292
- at once on startup, see _resume_persisted_hosts) without picking a
293
- distinct port each time meant every loser published a real, signed
294
- announcement and only discovered it couldn't actually bind *after*
295
- — a permanent, unreachable zombie listing nothing could ever clean
296
- up, since a host that never reaches 'running' never gets files/
297
- announced_on populated for the graceful-shutdown unpublish to use
298
- either. Still has a small window (this socket closes before
299
- run_host_server's own real bind), not a hard guarantee — but it
300
- catches the common case (concurrent startup, the actual incident)
301
- instead of the current zero attempt to catch it at all."""
302
- probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
303
- probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
304
- try:
305
- probe.bind((bind_host, port))
306
- finally:
307
- probe.close()
308
-
309
-
310
313
  def _run_host_job(host_id, archive_dir, file_name, port, price, relay_urls, advertise_host, tunnel,
311
314
  ln_node=None):
312
315
  captured = io.StringIO()
@@ -325,17 +328,44 @@ def _run_host_job(host_id, archive_dir, file_name, port, price, relay_urls, adve
325
328
  # and only fail later, deep in a background thread with no way
326
329
  # for the UI to ever find out
327
330
  all_leaves = {e['sha256']: node.load_leaves(archive_dir, e['sha256']) for e in entries}
328
- # also fail fast on a port collision, before announcing anything
329
- # — see _probe_port_free's docstring for the real incident this
330
- # prevents
331
- _probe_port_free(port)
331
+ # bind the real, permanent listening socket now — before
332
+ # announcing anything — and hand it to run_host_server below
333
+ # instead of it binding a second one later. A separate
334
+ # bind-then-close probe here, followed by a *later* real bind,
335
+ # isn't atomic across two hosts starting concurrently (see
336
+ # _resume_persisted_hosts): both could pass the probe before
337
+ # either did the real bind. One bind, kept alive and reused,
338
+ # has no such window — the port either really is free right
339
+ # now or this raises immediately, before any publish.
340
+ bound_sock = node.bind_host_port(port)
341
+ # a distinct status the instant the bind (the only part
342
+ # _resume_persisted_hosts actually needs to wait for) is done
343
+ # — 'running' doesn't happen until after the publish loop
344
+ # below, which is real network I/O (up to 5s timeout *per*
345
+ # relay, see post_event) that resuming several hosts
346
+ # sequentially has no reason to serialize on
347
+ with _lock:
348
+ _hosts[host_id]['status'] = 'bound'
332
349
  ott_status = node.ott_commit_status(archive_dir)
333
350
  announced = []
334
351
  for entry in entries:
335
352
  for relay_url in relay_urls:
336
353
  host_addr = f'{advertise_host}:{port}'
337
- node.publish(identity, relay_url, entry['sha256'], entry['name'], host_addr,
338
- tunnel=tunnel, ott_status=ott_status)
354
+ result = node.publish(identity, relay_url, entry['sha256'], entry['name'], host_addr,
355
+ tunnel=tunnel, ott_status=ott_status)
356
+ # publish()/post_event() report a failed announce as a
357
+ # normal {'ok': False, ...} return, not an exception (an
358
+ # unreachable or malformed relay is routine, not
359
+ # exceptional -- see post_event's own docstring) -- which
360
+ # this loop used to just ignore entirely, so a host could
361
+ # sit at status: running, "announced" on a relay it never
362
+ # actually reached, with nothing in the UI to say so.
363
+ # do_host (shell.py) already prints every result
364
+ # unconditionally; this only prints the failures; a
365
+ # working host's log staying free of routine "ok: True"
366
+ # noise matters more here than in a CLI's own scrollback.
367
+ if isinstance(result, dict) and not result.get('ok', True):
368
+ print(f' ✗ announce to {relay_url} failed: {result.get("error")}')
339
369
  announced = relay_urls
340
370
  files = [{'name': e['name'], 'content_hash': e['sha256']} for e in entries]
341
371
  with _lock:
@@ -354,7 +384,8 @@ def _run_host_job(host_id, archive_dir, file_name, port, price, relay_urls, adve
354
384
  all_leaves[entry['sha256']], file_path, price),
355
385
  kwargs={'use_tls': use_tls, 'quiet': True, 'ln_node': ln_node},
356
386
  daemon=True).start()
357
- node.run_host_server(archive_dir, file_name, port, quiet=True, price=price, ln_node=ln_node)
387
+ node.run_host_server(archive_dir, file_name, port, quiet=True, price=price, ln_node=ln_node,
388
+ sock=bound_sock)
358
389
  except SystemExit as e:
359
390
  with _lock:
360
391
  _hosts[host_id].update(status='error', error=_with_captured_detail(str(e), captured))
@@ -374,6 +405,11 @@ def _run_download_job(job_id, content_hash, relay_urls, out_path, k, use_lightni
374
405
  try:
375
406
  t0 = time.time()
376
407
  with _quiet() as captured:
408
+ # registered as soon as it's the real live buffer (not the
409
+ # placeholder above) so /api/download/<job_id> can read
410
+ # node.py's real prints as they happen, not just after the
411
+ # job finishes — see the GET handler's use of _job_logs
412
+ _job_logs[job_id] = captured
377
413
  path = node.download_with_auction(content_hash, relay_urls, out_path=out_path, k=k,
378
414
  use_lightning=use_lightning, lightning_node=lightning_node,
379
415
  on_progress=on_progress)
@@ -458,9 +494,16 @@ class Handler(BaseHTTPRequestHandler):
458
494
  'subscriptions': list(_library['subscriptions']),
459
495
  })
460
496
  if path.startswith('/api/download/'):
497
+ job_id = path[len('/api/download/'):]
461
498
  with _lock:
462
- job = _jobs.get(path[len('/api/download/'):])
463
- return self._json(job) if job else self._json({'error': 'no such job'}, status=404)
499
+ job = _jobs.get(job_id)
500
+ job = dict(job) if job else None
501
+ if not job:
502
+ return self._json({'error': 'no such job'}, status=404)
503
+ log_buf = _job_logs.get(job_id)
504
+ if log_buf is not None:
505
+ job['log'] = log_buf.getvalue()
506
+ return self._json(job)
464
507
  if path.startswith('/api/reputation/'):
465
508
  from poc_reputation import ReputationStore
466
509
  pubkey = path[len('/api/reputation/'):]
@@ -755,6 +798,13 @@ def run_web_ui(port=8080, bind_host='127.0.0.1', quiet=False, advertise_host=Non
755
798
  _lan_url = f'http://{reachable_host}:{port}/'
756
799
 
757
800
  if not quiet:
801
+ # answers "is this container actually running the code I think it
802
+ # is" directly in `docker compose logs`/`make node`'s own output —
803
+ # see node.weed_banner()'s own docstring for exactly the debugging
804
+ # session that motivated adding a commit hash to it in the first
805
+ # place; printing it here means the same question doesn't need a
806
+ # docker exec + grep to answer for the web UI specifically
807
+ print(f"[web:{port}] {node.weed_banner()}", flush=True)
758
808
  print(f"[web:{port}] weed control UI at http://{bind_host}:{port}/", flush=True)
759
809
  if bind_host == '127.0.0.1':
760
810
  print(" bound to localhost only -- pass --bind 0.0.0.0 to reach this from your "
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: weed-cli
3
- Version: 1.6.0
3
+ Version: 1.6.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
@@ -174,17 +174,28 @@ blind.
174
174
  ### Docker
175
175
 
176
176
  `Dockerfile.node` + `docker-compose.node.yml` package `web_ui.py` to run
177
- somewhere other than a laptop.
177
+ somewhere other than a laptop — alongside its own local discovery relay
178
+ (`Dockerfile.discovery-relay`), so there's always something real for it
179
+ to announce to and discover from out of the box.
178
180
 
179
181
  ```bash
180
182
  make node # build + run, http://127.0.0.1:8080
181
183
  make node-down # stop it — data persists
184
+ make node-shell # interactive weed shell, same container as the running node
182
185
  WEED_SHARE_DIR=~/Movies make node # mount a real directory of .ott archives at /share
186
+ docker compose -f docker-compose.node.yml exec node python3 weed.py discover # or a one-off CLI command
183
187
  ```
184
188
 
185
189
  - Identity key, reputation store, and library manifest all persist in a
186
190
  named volume (`node-data`, mounted at `$HOME=/data`) — one node keeps
187
191
  the same pubkey (and everything vouching for it) across restarts.
192
+ - The `relay` service is `node`'s default `WEED_RELAY` (`http://relay:9101`,
193
+ resolved over Compose's own network) — its events persist in their own
194
+ `relay-data` volume, and port 9101 is published to the host too, for
195
+ curling `/events` directly or pointing a non-Docker `weed`/shell session
196
+ at the exact same relay. Override `WEED_RELAY` (shell env or `.env`,
197
+ see `.env.sample`) to point at public infra instead, e.g. for a real
198
+ end-to-end test.
188
199
  - `/share` is a separate *bind* mount, not a named volume, so the actual
189
200
  `.ott` archives you're hosting are real files on the host you can see
190
201
  and manage directly. Point the web UI's Host form at `/share`.
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes