weed-cli 1.2.8__tar.gz → 1.3.1__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.8
3
+ Version: 1.3.1
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
@@ -41,6 +41,22 @@ DATA_PATH = os.environ.get('WEED_RELAY_DATA',
41
41
  _events = []
42
42
  _lock = threading.Lock()
43
43
 
44
+ # no real signed event (a handful of JSON fields: hashes, URLs, titles)
45
+ # is anywhere close to this; it's purely a cap against a client claiming
46
+ # a huge Content-Length and making this internet-facing server read an
47
+ # unbounded amount into memory
48
+ MAX_BODY_SIZE = 256 * 1024
49
+
50
+ # bounds one identity's storage footprint on this relay -- signer_pubkey
51
+ # is cryptographically bound to its event's signature (see
52
+ # verify_attestation), so it can't be spoofed to blame someone else's
53
+ # quota, and is genuinely what's exceeding the cap. Doesn't stop an
54
+ # attacker from generating a fresh keypair per event -- Ed25519 keygen is
55
+ # free -- but that's a fundamentally bigger attack (unlimited identities)
56
+ # than "spam with one key," and defending against it needs proof-of-
57
+ # work/stake, a bigger design decision than this PoC calls for.
58
+ MAX_EVENTS_PER_SIGNER = 200
59
+
44
60
 
45
61
  def _load_events():
46
62
  if not os.path.exists(DATA_PATH):
@@ -62,6 +78,33 @@ def _append_event(event):
62
78
  f.write(json.dumps(event) + '\n')
63
79
 
64
80
 
81
+ def _rewrite_events_file():
82
+ """Full rewrite instead of the usual cheap append -- only needed when
83
+ an entry was evicted from _events (see _evict_oldest_for_signer),
84
+ since dropping a line from a JSONL file means rewriting it."""
85
+ os.makedirs(os.path.dirname(DATA_PATH) or '.', exist_ok=True)
86
+ tmp = DATA_PATH + '.tmp'
87
+ with open(tmp, 'w') as f:
88
+ for event in _events:
89
+ f.write(json.dumps(event) + '\n')
90
+ os.replace(tmp, DATA_PATH)
91
+
92
+
93
+ def _evict_oldest_for_signer(signer_pubkey):
94
+ """Caller must hold _lock. Drops that signer's own oldest event once
95
+ they're at the cap, making room for the new one -- an active
96
+ publisher keeps working, they just eventually lose their own oldest
97
+ entries first, same trade as any keep-last-N retention policy."""
98
+ if not signer_pubkey:
99
+ return
100
+ mine = [e for e in _events if e['payload'].get('signer_pubkey') == signer_pubkey]
101
+ if len(mine) < MAX_EVENTS_PER_SIGNER:
102
+ return
103
+ oldest = min(mine, key=lambda e: e['payload'].get('ts', 0))
104
+ _events.remove(oldest)
105
+ _rewrite_events_file()
106
+
107
+
65
108
  class RelayHandler(BaseHTTPRequestHandler):
66
109
  def log_message(self, fmt, *args):
67
110
  pass # quiet — poc_discovery.py prints what matters
@@ -70,6 +113,13 @@ class RelayHandler(BaseHTTPRequestHandler):
70
113
  if self.path != '/event':
71
114
  self.send_response(404); self.end_headers(); return
72
115
  length = int(self.headers.get('Content-Length', 0))
116
+ if length > MAX_BODY_SIZE:
117
+ self.send_response(413)
118
+ self.send_header('Content-Type', 'application/json')
119
+ self.end_headers()
120
+ self.wfile.write(json.dumps({'ok': False,
121
+ 'reason': f'body too large (max {MAX_BODY_SIZE} bytes)'}).encode())
122
+ return
73
123
  try:
74
124
  event = json.loads(self.rfile.read(length))
75
125
  ok, reason = verify_attestation(event) # generic: works on any {payload, signature} blob
@@ -84,6 +134,7 @@ class RelayHandler(BaseHTTPRequestHandler):
84
134
  eid = attestation_id(event)
85
135
  with _lock:
86
136
  if not any(attestation_id(e) == eid for e in _events):
137
+ _evict_oldest_for_signer(event['payload'].get('signer_pubkey'))
87
138
  _events.append(event)
88
139
  _append_event(event)
89
140
  self.send_response(200)
@@ -347,18 +347,43 @@ def run_host_tunnel(relay_host, relay_port, token, entry, leaves, file_path, pri
347
347
  DATA connection, dialed back to the relay on demand (NEWSTREAM), so
348
348
  concurrent tunneled downloads don't block each other.
349
349
 
350
- The control connection sends nothing at all between REGISTER and the
351
- first real NEWSTREAM — which can be minutes or hours if no one
352
- downloads in the meantime. Real-world proxies in the middle (observed:
353
- Fly's own edge) reset TCP connections that go idle for a few minutes,
354
- which silently unregisters the host with no error on this end until
355
- the next download attempt fails. A small periodic heartbeat keeps the
350
+ REGISTER gets one immediate OK/ERR reply (see below), then the
351
+ control connection sends nothing at all until the first real
352
+ NEWSTREAM — which can be minutes or hours if no one downloads in the
353
+ meantime. Real-world proxies in the middle (observed: Fly's own
354
+ edge) reset TCP connections that go idle for a few minutes, which
355
+ silently unregisters the host with no error on this end until the
356
+ next download attempt fails. A small periodic heartbeat keeps the
356
357
  connection looking active; tunnel_relay.py's REGISTER loop already
357
358
  discards anything it receives that isn't relevant to it (it only ever
358
- checks for EOF), so this needs zero changes on the relay side."""
359
+ checks for EOF), so this needs zero changes on the relay side.
360
+
361
+ The OK/ERR reply exists so a REGISTER for a token someone else
362
+ already holds an active registration for can be refused instead of
363
+ silently overwriting it — content_hash tokens are public (announced
364
+ via discover), so without this anyone could squat/hijack another
365
+ host's rendezvous slot for content they didn't actually publish."""
359
366
  entries_by_hash = {entry['sha256']: (entry, leaves, file_path)}
360
367
  ctrl = _connect_tunnel_socket(relay_host, relay_port, use_tls)
361
368
  ctrl.sendall(f'REGISTER {token}\n'.encode())
369
+ # one LineReader for the whole connection, created before the first
370
+ # read and reused for the NEWSTREAM loop below -- creating a second
371
+ # one later would lose whatever extra bytes this first recv() also
372
+ # happened to pick up (LineReader buffers internally; a fresh
373
+ # instance starts with an empty buffer, discarding anything already
374
+ # read into the old one)
375
+ reader = LineReader(ctrl)
376
+ ack = reader.readline()
377
+ if ack != 'OK':
378
+ ctrl.close()
379
+ # tunnel_relay.py now refuses a REGISTER for a token someone
380
+ # else already holds an active registration for, instead of
381
+ # silently letting the new one steal it -- content_hash tokens
382
+ # are public (announced via discover), so this stops anyone from
383
+ # squatting/hijacking another host's rendezvous slot for content
384
+ # they didn't actually publish
385
+ sys.exit(f"tunnel relay at {relay_host}:{relay_port} rejected REGISTER for "
386
+ f"{token[:16]}...: {ack or '(connection closed)'}")
362
387
  if not quiet:
363
388
  tls_note = ' (tls)' if use_tls else ''
364
389
  print(f"[tunnel] registered {token[:16]}... with relay {relay_host}:{relay_port}{tls_note}")
@@ -375,7 +400,6 @@ def run_host_tunnel(relay_host, relay_port, token, entry, leaves, file_path, pri
375
400
  threading.Thread(target=send_heartbeats, daemon=True).start()
376
401
 
377
402
  try:
378
- reader = LineReader(ctrl)
379
403
  while True:
380
404
  line = reader.readline()
381
405
  if not line:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "weed-cli"
7
- version = "1.2.8"
7
+ version = "1.3.1"
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" }
@@ -18,6 +18,9 @@ raw pipe (CONNECT/DATA, once paired):
18
18
  REGISTER <token> host -> relay, persistent control connection.
19
19
  token is whatever the host wants to be found
20
20
  under (node.py uses the archive's content_hash).
21
+ Replies OK, or ERR if another still-active
22
+ connection already holds this token (first
23
+ registration wins; no stealing an active one).
21
24
  CONNECT <token> downloader -> relay, wants to reach that host.
22
25
  Everything sent after this line IS the tunneled
23
26
  protocol, not a relay command.
@@ -41,7 +44,7 @@ _pending_streams = {} # stream_id -> _Pairing (defined below)
41
44
  _lock = threading.Lock()
42
45
 
43
46
 
44
- def recv_line(sock):
47
+ def recv_line(sock, timeout=10, max_len=256):
45
48
  """Byte-at-a-time on purpose: CONNECT/DATA connections become a raw
46
49
  byte pipe immediately after this first line, and the real client
47
50
  (HostConnection.connect_via_tunnel) sends its next protocol command
@@ -49,14 +52,33 @@ def recv_line(sock):
49
52
  those bytes along with the handshake line, leaving nothing for pipe()
50
53
  to forward. One byte at a time guarantees this stops exactly at the
51
54
  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()
55
+ connection (a ~20-40 byte line), not on the hot path.
56
+
57
+ The read timeout and length cap are specific to this one line, not
58
+ the socket in general: a client that never sends a newline (or
59
+ trickles bytes in one at a time forever) would otherwise tie up this
60
+ thread's connection slot indefinitely, and daemon_threads has no cap
61
+ on how many of those can pile up. Neither limit costs a real client
62
+ anything — every genuine REGISTER/CONNECT/DATA line arrives
63
+ immediately and is ~20-40 bytes. The timeout is cleared again before
64
+ returning since callers keep using this same socket afterward:
65
+ REGISTER's control connection sits idle between real downloads for
66
+ as long as minutes or hours (see node.py's heartbeat), and pipe()
67
+ needs plain unbounded blocking reads once a connection becomes a raw
68
+ byte pipe."""
69
+ sock.settimeout(timeout)
70
+ try:
71
+ buf = bytearray()
72
+ while True:
73
+ b = sock.recv(1)
74
+ if not b or b == b'\n':
75
+ break
76
+ buf += b
77
+ if len(buf) > max_len:
78
+ raise ValueError(f'line exceeds {max_len} bytes')
79
+ return buf.decode().strip()
80
+ finally:
81
+ sock.settimeout(None)
60
82
 
61
83
 
62
84
  def pipe(src, dst):
@@ -76,8 +98,31 @@ def pipe(src, dst):
76
98
 
77
99
 
78
100
  def handle_register(conn, token, quiet):
101
+ """token is a content_hash, which is public the moment a host
102
+ announces it (see discover()) -- refusing a second REGISTER while
103
+ the first is still active stops anyone else from squatting/
104
+ hijacking that host's rendezvous slot for content they didn't
105
+ publish. The entry only exists in _registrations while that
106
+ connection's own recv loop below is still blocked waiting on it, so
107
+ "still in the dict" and "still alive" are the same thing here; a
108
+ genuinely dead registration is already gone by the time this runs
109
+ again (see the `finally` cleanup)."""
79
110
  with _lock:
111
+ if token in _registrations:
112
+ try:
113
+ conn.sendall(b'ERR token already registered\n')
114
+ except OSError:
115
+ pass
116
+ conn.close()
117
+ return
80
118
  _registrations[token] = conn
119
+ try:
120
+ conn.sendall(b'OK\n')
121
+ except OSError:
122
+ with _lock:
123
+ if _registrations.get(token) is conn:
124
+ del _registrations[token]
125
+ return
81
126
  if not quiet:
82
127
  print(f"[tunnel] {token[:16]}... registered", flush=True)
83
128
  try:
@@ -165,7 +210,16 @@ def handle_data(conn, stream_id):
165
210
 
166
211
  class TunnelHandler(socketserver.BaseRequestHandler):
167
212
  def handle(self):
168
- line = recv_line(self.request)
213
+ try:
214
+ line = recv_line(self.request)
215
+ except (socket.timeout, ValueError, OSError):
216
+ # a client that never sent a line, sent one too slowly, or
217
+ # sent one too long -- recv_line's own timeout/length cap
218
+ # already protects the server from this; it's routine, not
219
+ # a bug, so close quietly instead of socketserver logging a
220
+ # full traceback for it
221
+ self.request.close()
222
+ return
169
223
  parts = line.split()
170
224
  if len(parts) < 2:
171
225
  self.request.close()
@@ -35,8 +35,19 @@ except ImportError:
35
35
  _HAS_QR = False
36
36
 
37
37
  WEB_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'web')
38
- DEFAULT_RELAY = 'http://127.0.0.1:9101'
38
+ # same $WEED_RELAY/$WEED_TUNNEL convention weed.py's CLI subcommands and
39
+ # shell.py's WeedShell already read (see weed.py's _default_relay) --
40
+ # without this, running web_ui.py directly ignored them entirely and
41
+ # silently fell back to the loopback default, even with a real relay
42
+ # configured for every other way of running this tool
43
+ DEFAULT_RELAY = os.environ.get('WEED_RELAY', 'http://127.0.0.1:9101')
44
+ DEFAULT_TUNNEL = os.environ.get('WEED_TUNNEL')
39
45
  LIBRARY_PATH = os.path.expanduser('~/.weed_library.json')
46
+ # every real POST body here is a handful of JSON fields (hashes, URLs,
47
+ # titles) -- no endpoint ever legitimately needs anywhere near this much,
48
+ # it's purely a cap against a client claiming a huge Content-Length and
49
+ # making the server read an unbounded amount into memory
50
+ MAX_BODY_SIZE = 1024 * 1024
40
51
 
41
52
  # script-relative, not CWD-relative -- same reasoning as WEB_DIR above, so
42
53
  # downloads land in the same place regardless of the directory this was
@@ -268,6 +279,8 @@ class Handler(BaseHTTPRequestHandler):
268
279
 
269
280
  def _read_json_body(self):
270
281
  length = int(self.headers.get('Content-Length', 0))
282
+ if length > MAX_BODY_SIZE:
283
+ raise ValueError(f'body too large ({length} bytes, max {MAX_BODY_SIZE})')
271
284
  return json.loads(self.rfile.read(length)) if length else {}
272
285
 
273
286
  def do_GET(self):
@@ -276,8 +289,9 @@ class Handler(BaseHTTPRequestHandler):
276
289
 
277
290
  if path == '/api/whoami':
278
291
  return self._json({'pubkey': _identity().pubkey_hex()})
279
- if path == '/api/lan-url':
280
- return self._json({'url': _lan_url})
292
+ if path == '/api/config':
293
+ return self._json({'lan_url': _lan_url, 'default_relay': DEFAULT_RELAY,
294
+ 'default_tunnel': DEFAULT_TUNNEL})
281
295
  if path == '/api/discover':
282
296
  return self._json({'results': node.discover(qs.get('relay') or [DEFAULT_RELAY])})
283
297
  if path == '/api/hosts':
@@ -309,8 +323,30 @@ class Handler(BaseHTTPRequestHandler):
309
323
  return self._handle_qr(data)
310
324
  self._serve_static(path)
311
325
 
326
+ def _check_origin(self):
327
+ """This server has no auth at all (see module docstring) -- the
328
+ only thing stopping any webpage you happen to have open in the
329
+ same browser from POSTing here (start hosting an arbitrary local
330
+ directory, download attacker-chosen content to an
331
+ attacker-chosen path, like/subscribe as you) is confirming the
332
+ request actually came from this UI, not some other origin your
333
+ browser also has open. Browsers always set Origin on cross-origin
334
+ fetch/XHR and can't be told by page JS to fake it, so a mismatch
335
+ here means a real cross-site request; a request with no Origin at
336
+ all (curl, direct API use, older browsers on a same-origin form
337
+ post) is let through since it isn't the CSRF-from-another-tab
338
+ shape this defends against."""
339
+ origin = self.headers.get('Origin')
340
+ if not origin:
341
+ return True
342
+ host = self.headers.get('Host', '')
343
+ return origin in (f'http://{host}', f'https://{host}')
344
+
312
345
  def do_POST(self):
313
346
  path = urlparse(self.path).path
347
+ if not self._check_origin():
348
+ return self._json({'error': 'rejected: request Origin does not match this server — '
349
+ 'looks like a cross-site request, not this UI'}, status=403)
314
350
  try:
315
351
  body = self._read_json_body()
316
352
  except Exception as e:
@@ -506,7 +542,12 @@ class Handler(BaseHTTPRequestHandler):
506
542
  path = '/index.html'
507
543
  safe_path = os.path.normpath(path).lstrip('/')
508
544
  full_path = os.path.join(WEB_DIR, safe_path)
509
- if not os.path.abspath(full_path).startswith(os.path.abspath(WEB_DIR)) \
545
+ # the trailing os.sep matters: without it, a sibling directory
546
+ # that happens to share WEB_DIR's name as a string prefix (e.g.
547
+ # "web-private") would also pass this check -- str.startswith
548
+ # doesn't know about path boundaries, only os.path.join does
549
+ web_dir_abs = os.path.abspath(WEB_DIR)
550
+ if not os.path.abspath(full_path).startswith(web_dir_abs + os.sep) \
510
551
  or not os.path.isfile(full_path):
511
552
  return self._json({'error': 'not found'}, status=404)
512
553
  ctype, _ = mimetypes.guess_type(full_path)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: weed-cli
3
- Version: 1.2.8
3
+ Version: 1.3.1
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
File without changes