weed-cli 1.2.7__tar.gz → 1.3.0__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.7
3
+ Version: 1.3.0
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.7"
7
+ version = "1.3.0"
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()
@@ -37,6 +37,11 @@ except ImportError:
37
37
  WEB_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'web')
38
38
  DEFAULT_RELAY = 'http://127.0.0.1:9101'
39
39
  LIBRARY_PATH = os.path.expanduser('~/.weed_library.json')
40
+ # every real POST body here is a handful of JSON fields (hashes, URLs,
41
+ # titles) -- no endpoint ever legitimately needs anywhere near this much,
42
+ # it's purely a cap against a client claiming a huge Content-Length and
43
+ # making the server read an unbounded amount into memory
44
+ MAX_BODY_SIZE = 1024 * 1024
40
45
 
41
46
  # script-relative, not CWD-relative -- same reasoning as WEB_DIR above, so
42
47
  # downloads land in the same place regardless of the directory this was
@@ -137,7 +142,7 @@ def _rehydrate_jobs_from_library():
137
142
  'status': 'done', 'idx': 0, 'n_chunks': None,
138
143
  'content_hash': content_hash, 'path': rec['path'],
139
144
  'title': rec.get('title'), 'size': rec.get('size'), 'bps': rec.get('bps'),
140
- 'error': None,
145
+ 'signer_pubkey': rec.get('signer_pubkey'), 'error': None,
141
146
  }
142
147
  _lan_url = None # set once in run_web_ui() -- the base URL a phone on the
143
148
  # same LAN can actually reach this server at, or None if
@@ -211,7 +216,8 @@ def _run_host_job(host_id, archive_dir, file_name, port, price, relay_urls, adve
211
216
  _hosts[host_id].update(status='error', error=f'{type(e).__name__}: {e}')
212
217
 
213
218
 
214
- def _run_download_job(job_id, content_hash, relay_urls, out_path, k, use_lightning, title=None):
219
+ def _run_download_job(job_id, content_hash, relay_urls, out_path, k, use_lightning, title=None,
220
+ signer_pubkey=None):
215
221
  def on_progress(idx, n_chunks):
216
222
  with _lock:
217
223
  _jobs[job_id].update(idx=idx, n_chunks=n_chunks)
@@ -229,6 +235,7 @@ def _run_download_job(job_id, content_hash, relay_urls, out_path, k, use_lightni
229
235
  _library['downloads'][content_hash] = {
230
236
  'content_hash': content_hash, 'job_id': job_id, 'path': path,
231
237
  'title': title, 'downloaded_at': time.time(), 'size': size, 'bps': bps,
238
+ 'signer_pubkey': signer_pubkey,
232
239
  }
233
240
  _save_library()
234
241
  except SystemExit as e:
@@ -266,6 +273,8 @@ class Handler(BaseHTTPRequestHandler):
266
273
 
267
274
  def _read_json_body(self):
268
275
  length = int(self.headers.get('Content-Length', 0))
276
+ if length > MAX_BODY_SIZE:
277
+ raise ValueError(f'body too large ({length} bytes, max {MAX_BODY_SIZE})')
269
278
  return json.loads(self.rfile.read(length)) if length else {}
270
279
 
271
280
  def do_GET(self):
@@ -307,8 +316,30 @@ class Handler(BaseHTTPRequestHandler):
307
316
  return self._handle_qr(data)
308
317
  self._serve_static(path)
309
318
 
319
+ def _check_origin(self):
320
+ """This server has no auth at all (see module docstring) -- the
321
+ only thing stopping any webpage you happen to have open in the
322
+ same browser from POSTing here (start hosting an arbitrary local
323
+ directory, download attacker-chosen content to an
324
+ attacker-chosen path, like/subscribe as you) is confirming the
325
+ request actually came from this UI, not some other origin your
326
+ browser also has open. Browsers always set Origin on cross-origin
327
+ fetch/XHR and can't be told by page JS to fake it, so a mismatch
328
+ here means a real cross-site request; a request with no Origin at
329
+ all (curl, direct API use, older browsers on a same-origin form
330
+ post) is let through since it isn't the CSRF-from-another-tab
331
+ shape this defends against."""
332
+ origin = self.headers.get('Origin')
333
+ if not origin:
334
+ return True
335
+ host = self.headers.get('Host', '')
336
+ return origin in (f'http://{host}', f'https://{host}')
337
+
310
338
  def do_POST(self):
311
339
  path = urlparse(self.path).path
340
+ if not self._check_origin():
341
+ return self._json({'error': 'rejected: request Origin does not match this server — '
342
+ 'looks like a cross-site request, not this UI'}, status=403)
312
343
  try:
313
344
  body = self._read_json_body()
314
345
  except Exception as e:
@@ -356,13 +387,16 @@ class Handler(BaseHTTPRequestHandler):
356
387
  k = int(body.get('k') or 3)
357
388
  use_lightning = bool(body.get('lightning'))
358
389
  title = body.get('title')
390
+ signer_pubkey = body.get('signer_pubkey')
359
391
 
360
392
  job_id = uuid.uuid4().hex[:12]
361
393
  with _lock:
362
394
  _jobs[job_id] = {'status': 'running', 'idx': 0, 'n_chunks': None,
363
- 'content_hash': content_hash, 'path': None, 'title': title, 'error': None}
395
+ 'content_hash': content_hash, 'path': None, 'title': title,
396
+ 'signer_pubkey': signer_pubkey, 'error': None}
364
397
  threading.Thread(target=_run_download_job,
365
- args=(job_id, content_hash, relay_urls, out_path, k, use_lightning, title),
398
+ args=(job_id, content_hash, relay_urls, out_path, k, use_lightning, title,
399
+ signer_pubkey),
366
400
  daemon=True).start()
367
401
  self._json({'job_id': job_id})
368
402
 
@@ -501,7 +535,12 @@ class Handler(BaseHTTPRequestHandler):
501
535
  path = '/index.html'
502
536
  safe_path = os.path.normpath(path).lstrip('/')
503
537
  full_path = os.path.join(WEB_DIR, safe_path)
504
- if not os.path.abspath(full_path).startswith(os.path.abspath(WEB_DIR)) \
538
+ # the trailing os.sep matters: without it, a sibling directory
539
+ # that happens to share WEB_DIR's name as a string prefix (e.g.
540
+ # "web-private") would also pass this check -- str.startswith
541
+ # doesn't know about path boundaries, only os.path.join does
542
+ web_dir_abs = os.path.abspath(WEB_DIR)
543
+ if not os.path.abspath(full_path).startswith(web_dir_abs + os.sep) \
505
544
  or not os.path.isfile(full_path):
506
545
  return self._json({'error': 'not found'}, status=404)
507
546
  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.7
3
+ Version: 1.3.0
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