weed-cli 1.9.6__tar.gz → 2.0.2__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.
Files changed (27) hide show
  1. {weed_cli-1.9.6 → weed_cli-2.0.2}/PKG-INFO +1 -1
  2. {weed_cli-1.9.6 → weed_cli-2.0.2}/pyproject.toml +1 -1
  3. weed_cli-2.0.2/tests/test_orbit_ws.py +300 -0
  4. {weed_cli-1.9.6 → weed_cli-2.0.2}/web_ui.py +356 -5
  5. {weed_cli-1.9.6 → weed_cli-2.0.2}/weed.py +8 -1
  6. {weed_cli-1.9.6 → weed_cli-2.0.2}/weed_cli.egg-info/PKG-INFO +1 -1
  7. {weed_cli-1.9.6 → weed_cli-2.0.2}/weed_cli.egg-info/SOURCES.txt +1 -0
  8. {weed_cli-1.9.6 → weed_cli-2.0.2}/LICENSE +0 -0
  9. {weed_cli-1.9.6 → weed_cli-2.0.2}/README.md +0 -0
  10. {weed_cli-1.9.6 → weed_cli-2.0.2}/dht.py +0 -0
  11. {weed_cli-1.9.6 → weed_cli-2.0.2}/discovery_relay.py +0 -0
  12. {weed_cli-1.9.6 → weed_cli-2.0.2}/lightning_settle.py +0 -0
  13. {weed_cli-1.9.6 → weed_cli-2.0.2}/node.py +0 -0
  14. {weed_cli-1.9.6 → weed_cli-2.0.2}/poc_reputation.py +0 -0
  15. {weed_cli-1.9.6 → weed_cli-2.0.2}/setup.cfg +0 -0
  16. {weed_cli-1.9.6 → weed_cli-2.0.2}/shell.py +0 -0
  17. {weed_cli-1.9.6 → weed_cli-2.0.2}/tests/test_dht.py +0 -0
  18. {weed_cli-1.9.6 → weed_cli-2.0.2}/tests/test_discovery_relay.py +0 -0
  19. {weed_cli-1.9.6 → weed_cli-2.0.2}/tests/test_host_live_reload.py +0 -0
  20. {weed_cli-1.9.6 → weed_cli-2.0.2}/tests/test_node_manifest.py +0 -0
  21. {weed_cli-1.9.6 → weed_cli-2.0.2}/tests/test_web_ui_api.py +0 -0
  22. {weed_cli-1.9.6 → weed_cli-2.0.2}/tests/testutil.py +0 -0
  23. {weed_cli-1.9.6 → weed_cli-2.0.2}/tunnel_relay.py +0 -0
  24. {weed_cli-1.9.6 → weed_cli-2.0.2}/weed_cli.egg-info/dependency_links.txt +0 -0
  25. {weed_cli-1.9.6 → weed_cli-2.0.2}/weed_cli.egg-info/entry_points.txt +0 -0
  26. {weed_cli-1.9.6 → weed_cli-2.0.2}/weed_cli.egg-info/requires.txt +0 -0
  27. {weed_cli-1.9.6 → weed_cli-2.0.2}/weed_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: weed-cli
3
- Version: 1.9.6
3
+ Version: 2.0.2
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "weed-cli"
7
- version = "1.9.6"
7
+ version = "2.0.2"
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" }
@@ -0,0 +1,300 @@
1
+ """
2
+ The orbit MJPEG relay (web_ui.py): the WebSocket frame parser and unmask
3
+ (pure functions over a byte stream, driven with io.BytesIO), the
4
+ drop-oldest fanout policy (against a real queue.Queue), and the whole
5
+ path end to end -- a hand-rolled WebSocket client pushes a frame into a
6
+ real WebUIServer and a raw-socket viewer reads it back out of
7
+ /api/orbit-view as a multipart part. Stdlib only, like the server.
8
+ """
9
+ import base64
10
+ import hashlib
11
+ import io
12
+ import os
13
+ import queue
14
+ import socket
15
+ import struct
16
+ import time
17
+
18
+ import pytest
19
+
20
+ import web_ui
21
+ from testutil import http_get_json
22
+
23
+ WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
24
+
25
+
26
+ @pytest.fixture(autouse=True)
27
+ def _reset_orbit_state():
28
+ """The relay's state is module globals that no other fixture resets;
29
+ a viewer queue left behind by one test would otherwise still be fed
30
+ (and counted) by the next."""
31
+ web_ui._orbit_subscribers.clear()
32
+ web_ui._orbit_ws_connected = False
33
+ web_ui._orbit_res = None
34
+ web_ui._orbit_rx.update(fps=0.0, kbps=0, dropped=0)
35
+ yield
36
+ web_ui._orbit_subscribers.clear()
37
+
38
+
39
+ # ── frame building (the client side of RFC 6455) ──────────────────────
40
+ def client_frame(payload, opcode=2, fin=True, mask_key=None):
41
+ """A masked client->server frame, exactly as a browser sends it."""
42
+ mask_key = mask_key or os.urandom(4)
43
+ b0 = (0x80 if fin else 0) | opcode
44
+ n = len(payload)
45
+ if n < 126:
46
+ hdr = bytes([b0, 0x80 | n])
47
+ elif n < 65536:
48
+ hdr = bytes([b0, 0x80 | 126]) + struct.pack('>H', n)
49
+ else:
50
+ hdr = bytes([b0, 0x80 | 127]) + struct.pack('>Q', n)
51
+ return hdr + mask_key + naive_unmask(payload, mask_key)
52
+
53
+
54
+ def naive_unmask(data, key):
55
+ # the per-byte definition from the RFC, i.e. what the fast path
56
+ # must agree with
57
+ return bytes(data[i] ^ key[i % 4] for i in range(len(data)))
58
+
59
+
60
+ # ── unmask ─────────────────────────────────────────────────────────────
61
+ @pytest.mark.parametrize('n', [0, 1, 3, 4, 5, 63, 64, 65, 1000, 120_000])
62
+ def test_unmask_matches_the_per_byte_definition(n):
63
+ data, key = os.urandom(n), os.urandom(4)
64
+ assert web_ui._ws_unmask(data, key) == naive_unmask(data, key)
65
+
66
+
67
+ def test_unmask_is_its_own_inverse():
68
+ data, key = os.urandom(777), os.urandom(4)
69
+ assert web_ui._ws_unmask(web_ui._ws_unmask(data, key), key) == data
70
+
71
+
72
+ # ── message parser ─────────────────────────────────────────────────────
73
+ def messages(raw):
74
+ return list(web_ui._ws_messages(io.BytesIO(raw)))
75
+
76
+
77
+ def test_single_masked_binary_frame():
78
+ payload = b'\xff\xd8' + os.urandom(100) + b'\xff\xd9'
79
+ assert messages(client_frame(payload)) == [(2, payload)]
80
+
81
+
82
+ @pytest.mark.parametrize('n', [125, 126, 65535, 65536, 200_000])
83
+ def test_every_length_encoding(n):
84
+ # 125 / 126 / 65535 / 65536 are the boundaries between the 7-bit,
85
+ # 16-bit and 64-bit length forms; 200KB is a real 720p frame's size
86
+ payload = os.urandom(n)
87
+ assert messages(client_frame(payload)) == [(2, payload)]
88
+
89
+
90
+ def test_fragmented_message_is_reassembled():
91
+ a, b, c = os.urandom(50), os.urandom(60), os.urandom(70)
92
+ raw = (client_frame(a, opcode=2, fin=False)
93
+ + client_frame(b, opcode=0, fin=False)
94
+ + client_frame(c, opcode=0, fin=True))
95
+ assert messages(raw) == [(2, a + b + c)]
96
+
97
+
98
+ def test_ping_passes_through_without_disturbing_a_fragmented_message():
99
+ a, b = os.urandom(10), os.urandom(10)
100
+ raw = (client_frame(a, opcode=2, fin=False)
101
+ + client_frame(b'hi', opcode=9)
102
+ + client_frame(b, opcode=0, fin=True))
103
+ assert messages(raw) == [(9, b'hi'), (2, a + b)]
104
+
105
+
106
+ def test_close_frame_ends_the_stream():
107
+ raw = client_frame(b'one') + client_frame(b'\x03\xe8bye', opcode=8) + client_frame(b'never')
108
+ assert messages(raw) == [(2, b'one')]
109
+
110
+
111
+ def test_truncated_stream_ends_cleanly_without_raising():
112
+ full = client_frame(os.urandom(300))
113
+ for cut in (1, 2, 5, 100, len(full) - 1):
114
+ assert messages(full[:cut]) == []
115
+ assert messages(client_frame(b'ok') + full[:7]) == [(2, b'ok')]
116
+
117
+
118
+ def test_unmasked_frames_are_accepted_too():
119
+ payload = b'abc'
120
+ raw = bytes([0x82, len(payload)]) + payload
121
+ assert messages(raw) == [(2, payload)]
122
+
123
+
124
+ def test_stray_continuation_frame_is_ignored():
125
+ assert messages(client_frame(b'orphan', opcode=0)) == []
126
+
127
+
128
+ # ── fanout policy ──────────────────────────────────────────────────────
129
+ def test_fanout_drops_oldest_and_keeps_the_viewer():
130
+ q = queue.Queue(maxsize=2)
131
+ web_ui._orbit_subscribers.add(q)
132
+ frames = [b'f%d' % i for i in range(5)]
133
+ dropped = sum(web_ui._orbit_fanout(f) for f in frames)
134
+ assert q in web_ui._orbit_subscribers # never evicted
135
+ assert dropped == 3 # 5 in, room for 2
136
+ assert [q.get_nowait(), q.get_nowait()] == frames[-2:] # the newest survive
137
+
138
+
139
+ def test_fanout_with_no_viewers_is_a_noop():
140
+ assert web_ui._orbit_fanout(b'x') == 0
141
+
142
+
143
+ # ── end to end against a real server ───────────────────────────────────
144
+ def _hostport(url):
145
+ host, _, port = url[len('http://'):].partition(':')
146
+ return host, int(port)
147
+
148
+
149
+ def _read_until(sock, marker):
150
+ buf = b''
151
+ while marker not in buf:
152
+ chunk = sock.recv(4096)
153
+ assert chunk, f'connection closed before {marker!r} arrived'
154
+ buf += chunk
155
+ return buf
156
+
157
+
158
+ def ws_connect(url, path='/api/orbit-ws'):
159
+ host, port = _hostport(url)
160
+ s = socket.create_connection((host, port), timeout=5)
161
+ key = base64.b64encode(os.urandom(16)).decode()
162
+ s.sendall((f'GET {path} HTTP/1.1\r\nHost: {host}:{port}\r\n'
163
+ 'Upgrade: websocket\r\nConnection: Upgrade\r\n'
164
+ f'Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n').encode())
165
+ resp = _read_until(s, b'\r\n\r\n')
166
+ assert resp.startswith(b'HTTP/1.1 101 ')
167
+ expected = base64.b64encode(hashlib.sha1((key + WS_GUID).encode()).digest()).decode()
168
+ assert f'Sec-WebSocket-Accept: {expected}'.encode() in resp
169
+ return s
170
+
171
+
172
+ class Viewer:
173
+ """A raw-socket MJPEG viewer: the socket plus whatever bytes past the
174
+ current part have already been read off it (socket objects can't
175
+ carry that themselves -- no __dict__)."""
176
+ def __init__(self, sock, leftover):
177
+ self.sock = sock
178
+ self.leftover = leftover
179
+
180
+ def close(self):
181
+ self.sock.close()
182
+
183
+
184
+ def viewer_connect(url):
185
+ """Returns a Viewer once the response headers are in -- the server
186
+ registers the viewer's queue *before* sending them, so any frame
187
+ pushed after this returns is guaranteed to reach it."""
188
+ host, port = _hostport(url)
189
+ s = socket.create_connection((host, port), timeout=5)
190
+ s.sendall(f'GET /api/orbit-view HTTP/1.1\r\nHost: {host}:{port}\r\n\r\n'.encode())
191
+ head = _read_until(s, b'\r\n\r\n')
192
+ assert b'HTTP/1.1 200' in head
193
+ assert b'multipart/x-mixed-replace; boundary=orbit' in head
194
+ return Viewer(s, head.split(b'\r\n\r\n', 1)[1])
195
+
196
+
197
+ def read_part(viewer):
198
+ """One multipart part off the viewer; returns its JPEG bytes."""
199
+ buf = viewer.leftover
200
+ while b'\r\n\r\n' not in buf:
201
+ chunk = viewer.sock.recv(65536)
202
+ assert chunk, 'viewer connection closed'
203
+ buf += chunk
204
+ head, body = buf.split(b'\r\n\r\n', 1)
205
+ assert head.startswith(b'--orbit\r\n')
206
+ assert b'Content-Type: image/jpeg' in head
207
+ length = int(head.split(b'Content-Length: ')[1].split(b'\r\n')[0])
208
+ while len(body) < length + 2:
209
+ chunk = viewer.sock.recv(65536)
210
+ assert chunk, 'viewer connection closed mid-frame'
211
+ body += chunk
212
+ jpeg, trailer, rest = body[:length], body[length:length + 2], body[length + 2:]
213
+ assert trailer == b'\r\n'
214
+ viewer.leftover = rest
215
+ return jpeg
216
+
217
+
218
+ def wait_status(url, pred, timeout=3.0):
219
+ deadline = time.time() + timeout
220
+ st = None
221
+ while time.time() < deadline:
222
+ st = http_get_json(f'{url}/api/orbit-stream')
223
+ if pred(st):
224
+ return st
225
+ time.sleep(0.05)
226
+ pytest.fail(f'status never matched; last seen: {st}')
227
+
228
+
229
+ def test_frame_pushed_over_websocket_comes_out_of_the_mjpeg_view(web_server):
230
+ viewer = viewer_connect(web_server)
231
+ ws = ws_connect(web_server)
232
+ try:
233
+ # 100KB forces the 8-byte length form, the one a real frame uses
234
+ frame = b'\xff\xd8' + os.urandom(100_000) + b'\xff\xd9'
235
+ ws.sendall(client_frame(frame))
236
+ assert read_part(viewer) == frame
237
+ # a second part proves the viewer connection stays open between frames
238
+ frame2 = b'\xff\xd8' + os.urandom(500) + b'\xff\xd9'
239
+ ws.sendall(client_frame(frame2))
240
+ assert read_part(viewer) == frame2
241
+ finally:
242
+ ws.close()
243
+ viewer.close()
244
+
245
+
246
+ def test_fragmented_frame_is_reassembled_end_to_end(web_server):
247
+ viewer = viewer_connect(web_server)
248
+ ws = ws_connect(web_server)
249
+ try:
250
+ a, b = os.urandom(3000), os.urandom(3000)
251
+ ws.sendall(client_frame(a, opcode=2, fin=False))
252
+ ws.sendall(client_frame(b, opcode=0, fin=True))
253
+ assert read_part(viewer) == a + b
254
+ finally:
255
+ ws.close()
256
+ viewer.close()
257
+
258
+
259
+ def test_server_answers_ping_with_pong(web_server):
260
+ ws = ws_connect(web_server)
261
+ try:
262
+ ws.sendall(client_frame(b'marco', opcode=9))
263
+ pong = _read_until(ws, b'marco')
264
+ assert pong[:2] == bytes([0x8a, 5]) # FIN|pong, unmasked, length 5
265
+ finally:
266
+ ws.close()
267
+
268
+
269
+ def test_status_tracks_the_streamer_and_its_resolution(web_server):
270
+ st = http_get_json(f'{web_server}/api/orbit-stream')
271
+ assert st['active'] is False and st['viewers'] == 0 and st['res'] is None
272
+ assert st['rx'] == {'fps': 0.0, 'kbps': 0, 'dropped': 0}
273
+ assert st['url'].endswith('/api/orbit-view')
274
+
275
+ ws = ws_connect(web_server, '/api/orbit-ws?res=480')
276
+ viewer = viewer_connect(web_server)
277
+ try:
278
+ st = wait_status(web_server, lambda s: s['active'] and s['viewers'] == 1)
279
+ assert st['res'] == '480'
280
+ finally:
281
+ ws.close()
282
+ # the streamer going away ends every viewer too, and zeroes the counters
283
+ st = wait_status(web_server, lambda s: not s['active'] and s['viewers'] == 0)
284
+ assert st['res'] is None
285
+ assert st['rx'] == {'fps': 0.0, 'kbps': 0, 'dropped': 0}
286
+ viewer.close()
287
+
288
+
289
+ def test_one_viewer_leaving_does_not_disturb_another(web_server):
290
+ ws = ws_connect(web_server)
291
+ v1 = viewer_connect(web_server)
292
+ v2 = viewer_connect(web_server)
293
+ try:
294
+ wait_status(web_server, lambda s: s['viewers'] == 2)
295
+ v1.close()
296
+ ws.sendall(client_frame(b'\xff\xd8still here\xff\xd9'))
297
+ assert read_part(v2) == b'\xff\xd8still here\xff\xd9'
298
+ finally:
299
+ ws.close()
300
+ v2.close()
@@ -21,7 +21,12 @@ import mimetypes
21
21
  import os
22
22
  import signal
23
23
  import socket
24
+ import queue
25
+ import ssl
26
+ import struct
27
+ import subprocess
24
28
  import sys
29
+ import tempfile
25
30
  import threading
26
31
  import time
27
32
  import uuid
@@ -138,6 +143,120 @@ def _with_captured_detail(msg, captured):
138
143
 
139
144
  _hosts = {} # host_id -> dict describing an actively-hosted file
140
145
  _jobs = {} # job_id -> dict describing a download's progress/result
146
+
147
+ # ── orbit MJPEG stream ───────────────────────────────────────────────────
148
+ # Browser sends JPEG frames via WebSocket /api/orbit-ws; server fans them
149
+ # out as MJPEG (multipart/x-mixed-replace) on /api/orbit-view.
150
+ _orbit_ws_connected = False # True while a browser WS is open and pushing frames
151
+ _orbit_res = None # the streamer's ?res= ('720'/'480'/'360') while connected
152
+ _orbit_ws_lock = threading.Lock()
153
+ # last 5s window of what the WS reader actually received / had to drop,
154
+ # written by the reader thread, read by /api/orbit-stream -- see the
155
+ # matching console line the browser prints on its send side
156
+ _orbit_rx = {'fps': 0.0, 'kbps': 0, 'dropped': 0}
157
+ _orbit_subscribers = set() # set of queue.Queue, one per HTTP client
158
+ _orbit_subs_lock = threading.Lock()
159
+
160
+
161
+ def _orbit_fanout(payload):
162
+ """Hand one JPEG to every viewer's queue. Returns how many viewers
163
+ were behind (queue full) when it arrived.
164
+
165
+ A full queue means that viewer's writer thread hasn't kept up. The
166
+ policy is drop-oldest, never disconnect: pop its stalest frame and
167
+ put this one, so a momentary stall (VLC buffering, a wifi hiccup)
168
+ costs a skipped frame rather than the whole stream -- and never
169
+ letting a backlog build is what keeps a viewer's latency pinned
170
+ near zero."""
171
+ dropped = 0
172
+ with _orbit_subs_lock:
173
+ for q in _orbit_subscribers:
174
+ try:
175
+ q.put_nowait(payload)
176
+ except queue.Full:
177
+ try: q.get_nowait()
178
+ except queue.Empty: pass
179
+ try: q.put_nowait(payload)
180
+ except queue.Full: pass
181
+ dropped += 1
182
+ return dropped
183
+
184
+
185
+ def _ws_unmask(data, mask_key):
186
+ """Client->server WebSocket payloads arrive XOR-masked with a 4-byte
187
+ key. Done as one big-int XOR rather than a per-byte Python loop:
188
+ ~25x faster (0.4ms vs ~9ms per 120KB frame on a desktop; on the Pi
189
+ the per-byte loop alone was eating most of a 30fps frame budget and
190
+ showed up as stutter on every viewer)."""
191
+ n = len(data)
192
+ if n == 0:
193
+ return data
194
+ mask = (mask_key * (n // 4 + 1))[:n]
195
+ return (int.from_bytes(data, 'little') ^ int.from_bytes(mask, 'little')).to_bytes(n, 'little')
196
+
197
+
198
+ def _ws_messages(rfile):
199
+ """Yield (opcode, payload) for each complete WebSocket message read
200
+ from the file-like `rfile`: length prefixes decoded, masks removed,
201
+ continuation frames reassembled. Stops at EOF, at a truncated frame,
202
+ or at a close frame (opcode 8). Control frames (ping 9 / pong 10)
203
+ are yielded as they arrive so the caller can answer them; only data
204
+ frames take part in reassembly. A pure function of the byte stream
205
+ -- no socket, no globals -- so tests drive it with io.BytesIO."""
206
+ def read_exact(n):
207
+ buf = b''
208
+ while len(buf) < n:
209
+ chunk = rfile.read(n - len(buf))
210
+ if not chunk:
211
+ return None
212
+ buf += chunk
213
+ return buf
214
+
215
+ fragments = []
216
+ msg_opcode = 0
217
+ while True:
218
+ hdr = read_exact(2)
219
+ if hdr is None:
220
+ return
221
+ b0, b1 = hdr[0], hdr[1]
222
+ fin = bool(b0 & 0x80)
223
+ opcode = b0 & 0x0F
224
+ masked = bool(b1 & 0x80)
225
+ length = b1 & 0x7F
226
+ if length == 126:
227
+ ext = read_exact(2)
228
+ if ext is None:
229
+ return
230
+ length = struct.unpack('>H', ext)[0]
231
+ elif length == 127:
232
+ ext = read_exact(8)
233
+ if ext is None:
234
+ return
235
+ length = struct.unpack('>Q', ext)[0]
236
+ mask_key = read_exact(4) if masked else None
237
+ if masked and mask_key is None:
238
+ return
239
+ chunk = read_exact(length)
240
+ if chunk is None:
241
+ return
242
+ if masked:
243
+ chunk = _ws_unmask(chunk, mask_key)
244
+ if opcode == 8:
245
+ return
246
+ if opcode >= 8: # ping / pong: never fragmented
247
+ yield opcode, chunk
248
+ continue
249
+ if opcode != 0: # text (1) / binary (2): starts a message
250
+ msg_opcode = opcode
251
+ fragments = [chunk]
252
+ elif fragments: # continuation of one already started
253
+ fragments.append(chunk)
254
+ else:
255
+ continue # continuation with nothing to continue
256
+ if fin:
257
+ payload = b''.join(fragments)
258
+ fragments = []
259
+ yield msg_opcode, payload
141
260
  _job_logs = {} # job_id -> the live io.StringIO node.py's prints are captured into (see _quiet)
142
261
  _host_logs = {} # host_id -> same, for a host job (announce progress, [host:PORT] serving, ...)
143
262
  _lock = threading.Lock()
@@ -579,12 +698,15 @@ class WebUIServer(ThreadingHTTPServer):
579
698
  preconnects all do this. socketserver's default handle_error
580
699
  prints a full traceback for every one of these; only genuinely
581
700
  unexpected errors get that treatment here."""
582
- if sys.exc_info()[0] in (ConnectionResetError, BrokenPipeError, TimeoutError):
701
+ import ssl as _ssl
702
+ if sys.exc_info()[0] in (ConnectionResetError, BrokenPipeError, TimeoutError, _ssl.SSLError, _ssl.SSLEOFError):
583
703
  return
584
704
  super().handle_error(request, client_address)
585
705
 
586
706
 
587
707
  class Handler(BaseHTTPRequestHandler):
708
+ protocol_version = 'HTTP/1.1' # needed for streaming responses (orbit-view)
709
+
588
710
  def log_message(self, fmt, *args):
589
711
  pass # quiet — this is a local UI, not a service worth logging every hit for
590
712
 
@@ -659,6 +781,12 @@ class Handler(BaseHTTPRequestHandler):
659
781
  return self._json({'pubkey': pubkey, 'score': score, 'why': why})
660
782
  if path.startswith('/api/stream/'):
661
783
  return self._handle_stream(path[len('/api/stream/'):])
784
+ if path == '/api/orbit-ws':
785
+ return self._handle_orbit_websocket(qs)
786
+ if path == '/api/orbit-view':
787
+ return self._handle_orbit_view()
788
+ if path == '/api/orbit-stream':
789
+ return self._handle_orbit_stream_status()
662
790
  if path == '/api/qr':
663
791
  data = (qs.get('data') or [''])[0]
664
792
  if not data:
@@ -1125,6 +1253,154 @@ class Handler(BaseHTTPRequestHandler):
1125
1253
  result = node.verify_local_download(content_hash, relay_urls, rec['path'])
1126
1254
  self._json(result)
1127
1255
 
1256
+ # ── orbit MJPEG stream ─────────────────────────────────────────────
1257
+ def _handle_orbit_stream_status(self):
1258
+ with _orbit_ws_lock:
1259
+ active, res = _orbit_ws_connected, _orbit_res
1260
+ with _orbit_subs_lock:
1261
+ viewers = len(_orbit_subscribers)
1262
+ host = self.headers.get('Host', '192.168.1.137:8080')
1263
+ scheme = 'https' if hasattr(self.connection, 'read') and hasattr(self.server, '_tls') else 'http'
1264
+ return self._json({
1265
+ 'active': active,
1266
+ 'res': res,
1267
+ 'viewers': viewers,
1268
+ 'rx': dict(_orbit_rx),
1269
+ 'url': f'{scheme}://{host}/api/orbit-view',
1270
+ 'vlc': f'{scheme}://{host}/api/orbit-view',
1271
+ })
1272
+
1273
+ def _handle_orbit_view(self):
1274
+ """MJPEG stream — any browser or VLC on the LAN connects here.
1275
+ No ffmpeg, no encoding: the server relays raw JPEG frames from the
1276
+ streamer's browser as multipart/x-mixed-replace. Browsers display
1277
+ it natively in an <img> tag; VLC plays it with no flags needed."""
1278
+ import queue as _q
1279
+ # 2 deep, not 8: the fanout drops the *oldest* frame when this is
1280
+ # full, so depth is purely how much latency a viewer can accumulate
1281
+ # before it starts skipping -- 2 frames at 30fps is ~66ms, vs the
1282
+ # ~800ms of drift a 10fps x 8-frame backlog used to allow.
1283
+ q = _q.Queue(maxsize=2)
1284
+ with _orbit_subs_lock:
1285
+ _orbit_subscribers.add(q)
1286
+ boundary = b'--orbit\r\n'
1287
+ try:
1288
+ # each frame is one write; don't let Nagle hold it back waiting
1289
+ # for a fuller segment
1290
+ try:
1291
+ self.connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
1292
+ except OSError:
1293
+ pass
1294
+ self.send_response(200)
1295
+ self.send_header('Content-Type', 'multipart/x-mixed-replace; boundary=orbit')
1296
+ self.send_header('Cache-Control', 'no-cache')
1297
+ self.send_header('Connection', 'close')
1298
+ self.end_headers()
1299
+ while True:
1300
+ try:
1301
+ jpeg = q.get(timeout=30)
1302
+ except _q.Empty:
1303
+ continue
1304
+ if jpeg is None:
1305
+ break
1306
+ try:
1307
+ self.wfile.write(
1308
+ boundary
1309
+ + b'Content-Type: image/jpeg\r\n'
1310
+ + f'Content-Length: {len(jpeg)}\r\n\r\n'.encode()
1311
+ + jpeg
1312
+ + b'\r\n'
1313
+ )
1314
+ self.wfile.flush()
1315
+ except OSError:
1316
+ break
1317
+ except Exception:
1318
+ pass
1319
+ finally:
1320
+ with _orbit_subs_lock:
1321
+ _orbit_subscribers.discard(q)
1322
+
1323
+ def _handle_orbit_websocket(self, qs):
1324
+ """The streamer's side: one browser pushes JPEG frames here as
1325
+ binary WebSocket messages; each one is fanned out to every
1326
+ /api/orbit-view subscriber. Frame parsing lives in _ws_messages
1327
+ and the fanout policy in _orbit_fanout -- this method is just
1328
+ the handshake, the loop, and the 5-second rx counters."""
1329
+ import base64, hashlib
1330
+ global _orbit_ws_connected, _orbit_res
1331
+
1332
+ # WebSocket handshake — write directly to the raw socket to avoid
1333
+ # BaseHTTPRequestHandler's headers buffer interfering with the upgrade.
1334
+ key = self.headers.get('Sec-WebSocket-Key', '')
1335
+ if not key:
1336
+ self.send_response(400)
1337
+ self.end_headers()
1338
+ return
1339
+ accept = base64.b64encode(
1340
+ hashlib.sha1(
1341
+ (key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').encode()
1342
+ ).digest()
1343
+ ).decode()
1344
+ self.connection.sendall((
1345
+ 'HTTP/1.1 101 Switching Protocols\r\n'
1346
+ 'Upgrade: websocket\r\n'
1347
+ 'Connection: Upgrade\r\n'
1348
+ f'Sec-WebSocket-Accept: {accept}\r\n'
1349
+ '\r\n'
1350
+ ).encode())
1351
+
1352
+ res = (qs.get('res') or [None])[0]
1353
+ with _orbit_ws_lock:
1354
+ _orbit_ws_connected = True
1355
+ _orbit_res = res
1356
+ print(f'[orbit] WebSocket open ({res or "?"}p) — streaming MJPEG to /api/orbit-view', flush=True)
1357
+
1358
+ try:
1359
+ rx_n = rx_bytes = rx_dropped = 0
1360
+ rx_t0 = time.monotonic()
1361
+ for opcode, payload in _ws_messages(self.rfile):
1362
+ if opcode == 9:
1363
+ # ping -> pong with the same payload. Browsers don't
1364
+ # send these unprompted, but a proxy in between might;
1365
+ # server->client frames are never masked.
1366
+ if len(payload) < 126:
1367
+ self.connection.sendall(b'\x8a' + bytes([len(payload)]) + payload)
1368
+ continue
1369
+ if opcode != 2 or not payload:
1370
+ continue
1371
+ rx_dropped += _orbit_fanout(payload)
1372
+ rx_n += 1
1373
+ rx_bytes += len(payload)
1374
+ now = time.monotonic()
1375
+ if now - rx_t0 >= 5:
1376
+ dt = now - rx_t0
1377
+ _orbit_rx['fps'] = round(rx_n / dt, 1)
1378
+ _orbit_rx['kbps'] = round(rx_bytes / dt / 1024)
1379
+ _orbit_rx['dropped'] = rx_dropped
1380
+ with _orbit_subs_lock:
1381
+ nviewers = len(_orbit_subscribers)
1382
+ # dropped > 0 here means a *viewer* write is the slow
1383
+ # link (its queue filled); fps well under what the
1384
+ # browser's own console line says it sent means the
1385
+ # network between browser and this socket is.
1386
+ print(f"[orbit] rx {_orbit_rx['fps']} fps, {_orbit_rx['kbps']} KB/s, "
1387
+ f"viewers={nviewers}, dropped={rx_dropped}", flush=True)
1388
+ rx_n = rx_bytes = rx_dropped = 0
1389
+ rx_t0 = now
1390
+ except Exception:
1391
+ pass
1392
+ finally:
1393
+ print('[orbit] WebSocket closed', flush=True)
1394
+ _orbit_rx.update(fps=0.0, kbps=0, dropped=0)
1395
+ with _orbit_ws_lock:
1396
+ _orbit_ws_connected = False
1397
+ _orbit_res = None
1398
+ with _orbit_subs_lock:
1399
+ subs = list(_orbit_subscribers)
1400
+ for q in subs:
1401
+ try: q.put_nowait(None)
1402
+ except Exception: pass
1403
+
1128
1404
  def _handle_stream(self, job_id):
1129
1405
  """Serve an already-downloaded job's file with real HTTP range
1130
1406
  support, so a <video> tag can seek/scrub instead of just
@@ -1239,7 +1515,56 @@ class Handler(BaseHTTPRequestHandler):
1239
1515
  self.wfile.write(body)
1240
1516
 
1241
1517
 
1242
- def run_web_ui(port=8080, bind_host='127.0.0.1', quiet=False, advertise_host=None):
1518
+ def _generate_self_signed_cert(host):
1519
+ """Return (cert_path, key_path) for a self-signed cert written to a temp dir.
1520
+
1521
+ The temp dir is NOT cleaned up — it lives for the process lifetime so the
1522
+ files stay valid as long as the server is running. Uses the `cryptography`
1523
+ package already in requirements.txt.
1524
+ """
1525
+ from cryptography import x509
1526
+ from cryptography.hazmat.primitives import hashes, serialization
1527
+ from cryptography.hazmat.primitives.asymmetric import rsa
1528
+ from cryptography.x509.oid import NameOID
1529
+ import datetime, ipaddress
1530
+
1531
+ key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
1532
+ name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, host)])
1533
+ san_list = [x509.DNSName(host)]
1534
+ try:
1535
+ san_list.append(x509.IPAddress(ipaddress.ip_address(host)))
1536
+ except ValueError:
1537
+ pass
1538
+
1539
+ now = datetime.datetime.now(datetime.timezone.utc)
1540
+ cert = (
1541
+ x509.CertificateBuilder()
1542
+ .subject_name(name)
1543
+ .issuer_name(name)
1544
+ .public_key(key.public_key())
1545
+ .serial_number(x509.random_serial_number())
1546
+ .not_valid_before(now)
1547
+ .not_valid_after(now + datetime.timedelta(days=825))
1548
+ .add_extension(x509.SubjectAlternativeName(san_list), critical=False)
1549
+ .sign(key, hashes.SHA256())
1550
+ )
1551
+
1552
+ tmp = tempfile.mkdtemp(prefix='weed-tls-')
1553
+ cert_path = os.path.join(tmp, 'cert.pem')
1554
+ key_path = os.path.join(tmp, 'key.pem')
1555
+ with open(cert_path, 'wb') as f:
1556
+ f.write(cert.public_bytes(serialization.Encoding.PEM))
1557
+ with open(key_path, 'wb') as f:
1558
+ f.write(key.private_bytes(
1559
+ serialization.Encoding.PEM,
1560
+ serialization.PrivateFormat.TraditionalOpenSSL,
1561
+ serialization.NoEncryption(),
1562
+ ))
1563
+ return cert_path, key_path
1564
+
1565
+
1566
+ def run_web_ui(port=8080, bind_host='127.0.0.1', quiet=False, advertise_host=None,
1567
+ tls=False, certfile=None, keyfile=None):
1243
1568
  global _lan_url
1244
1569
  _load_library()
1245
1570
  _rehydrate_jobs_from_library()
@@ -1247,6 +1572,20 @@ def run_web_ui(port=8080, bind_host='127.0.0.1', quiet=False, advertise_host=Non
1247
1572
  _resume_persisted_hosts()
1248
1573
  srv = WebUIServer((bind_host, port), Handler)
1249
1574
 
1575
+ if tls:
1576
+ if not certfile or not keyfile:
1577
+ reachable_for_cert = advertise_host or (_detect_lan_ip() if bind_host == '0.0.0.0' else bind_host) or bind_host
1578
+ if not quiet:
1579
+ print(f"[web] generating self-signed TLS cert for {reachable_for_cert} …", flush=True)
1580
+ certfile, keyfile = _generate_self_signed_cert(reachable_for_cert)
1581
+ if not quiet:
1582
+ print(f"[web] cert: {certfile}", flush=True)
1583
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
1584
+ ctx.load_cert_chain(certfile, keyfile)
1585
+ srv.socket = ctx.wrap_socket(srv.socket, server_side=True)
1586
+
1587
+ scheme = 'https' if tls else 'http'
1588
+
1250
1589
  # `docker compose down` (and plain Ctrl-C) sends SIGTERM -- without
1251
1590
  # this, a host that goes offline just leaves a stale, now-unreachable
1252
1591
  # publish event sitting in discover results until a relay's per-signer
@@ -1270,7 +1609,7 @@ def run_web_ui(port=8080, bind_host='127.0.0.1', quiet=False, advertise_host=Non
1270
1609
  # just fixed by telling this explicitly instead of guessing
1271
1610
  reachable_host = advertise_host or (_detect_lan_ip() if bind_host == '0.0.0.0' else bind_host)
1272
1611
  if bind_host != '127.0.0.1' and reachable_host:
1273
- _lan_url = f'http://{reachable_host}:{port}/'
1612
+ _lan_url = f'{scheme}://{reachable_host}:{port}/'
1274
1613
 
1275
1614
  if not quiet:
1276
1615
  # answers "is this container actually running the code I think it
@@ -1280,7 +1619,7 @@ def run_web_ui(port=8080, bind_host='127.0.0.1', quiet=False, advertise_host=Non
1280
1619
  # place; printing it here means the same question doesn't need a
1281
1620
  # docker exec + grep to answer for the web UI specifically
1282
1621
  print(f"[web:{port}] {node.weed_banner()}", flush=True)
1283
- print(f"[web:{port}] weed control UI at http://{bind_host}:{port}/", flush=True)
1622
+ print(f"[web:{port}] weed control UI at {scheme}://{bind_host}:{port}/", flush=True)
1284
1623
  if bind_host == '127.0.0.1':
1285
1624
  print(" bound to localhost only -- pass --bind 0.0.0.0 to reach this from your "
1286
1625
  "phone (and get a scan-to-open QR here)", flush=True)
@@ -1324,9 +1663,21 @@ def main():
1324
1663
  "or pointed at the wrong address (auto-detection guesses via an "
1325
1664
  "outbound route, which can pick the wrong interface or fail "
1326
1665
  "outright on unusual networking)")
1666
+ parser.add_argument('--tls', action='store_true',
1667
+ help='enable HTTPS; auto-generates a self-signed cert if --cert/--key '
1668
+ 'are not provided (required for Chromecast and other browser APIs '
1669
+ 'that need a secure origin)')
1670
+ parser.add_argument('--cert', metavar='CERTFILE',
1671
+ help='path to PEM certificate file (used with --tls; auto-generated if omitted)')
1672
+ parser.add_argument('--key', metavar='KEYFILE',
1673
+ help='path to PEM private key file (used with --tls; auto-generated if omitted)')
1327
1674
  args = parser.parse_args()
1328
1675
  port = args.port_flag if args.port_flag is not None else args.port
1329
- run_web_ui(port, bind_host=args.bind, advertise_host=args.advertise_host)
1676
+ certfile = args.cert or os.environ.get('WEED_TLS_CERT')
1677
+ keyfile = args.key or os.environ.get('WEED_TLS_KEY')
1678
+ tls = args.tls or bool(certfile)
1679
+ run_web_ui(port, bind_host=args.bind, advertise_host=args.advertise_host,
1680
+ tls=tls, certfile=certfile, keyfile=keyfile)
1330
1681
 
1331
1682
 
1332
1683
  if __name__ == '__main__':
@@ -157,6 +157,10 @@ def build_parser():
157
157
  help='IP/hostname for the phone QR and lan-url instead of '
158
158
  'auto-detecting it — use this if the startup QR was missing or '
159
159
  'pointed at the wrong address')
160
+ p_web.add_argument('--tls', action='store_true',
161
+ help='enable HTTPS; auto-generates a self-signed cert if --cert/--key omitted')
162
+ p_web.add_argument('--cert', metavar='CERTFILE', help='PEM certificate file (used with --tls)')
163
+ p_web.add_argument('--key', metavar='KEYFILE', help='PEM private key file (used with --tls)')
160
164
 
161
165
  p_serve = sub.add_parser('serve', help='alias for "web" with positional args, '
162
166
  'e.g. `serve 0.0.0.0 8080`')
@@ -273,7 +277,10 @@ def cmd_subscribe(args):
273
277
 
274
278
  def cmd_web(args):
275
279
  import web_ui
276
- web_ui.run_web_ui(port=args.port, bind_host=args.bind, advertise_host=args.advertise_host)
280
+ web_ui.run_web_ui(port=args.port, bind_host=args.bind, advertise_host=args.advertise_host,
281
+ tls=getattr(args, 'tls', False),
282
+ certfile=getattr(args, 'cert', None),
283
+ keyfile=getattr(args, 'key', None))
277
284
 
278
285
 
279
286
  NATIVE_COMMANDS = {
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: weed-cli
3
- Version: 1.9.6
3
+ Version: 2.0.2
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
@@ -14,6 +14,7 @@ tests/test_dht.py
14
14
  tests/test_discovery_relay.py
15
15
  tests/test_host_live_reload.py
16
16
  tests/test_node_manifest.py
17
+ tests/test_orbit_ws.py
17
18
  tests/test_web_ui_api.py
18
19
  tests/testutil.py
19
20
  weed_cli.egg-info/PKG-INFO
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
File without changes
File without changes
File without changes