weed-cli 1.9.5__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.
- {weed_cli-1.9.5 → weed_cli-2.0.2}/PKG-INFO +1 -1
- {weed_cli-1.9.5 → weed_cli-2.0.2}/node.py +11 -2
- {weed_cli-1.9.5 → weed_cli-2.0.2}/pyproject.toml +1 -1
- weed_cli-2.0.2/tests/test_orbit_ws.py +300 -0
- {weed_cli-1.9.5 → weed_cli-2.0.2}/web_ui.py +356 -5
- {weed_cli-1.9.5 → weed_cli-2.0.2}/weed.py +8 -1
- {weed_cli-1.9.5 → weed_cli-2.0.2}/weed_cli.egg-info/PKG-INFO +1 -1
- {weed_cli-1.9.5 → weed_cli-2.0.2}/weed_cli.egg-info/SOURCES.txt +1 -0
- {weed_cli-1.9.5 → weed_cli-2.0.2}/LICENSE +0 -0
- {weed_cli-1.9.5 → weed_cli-2.0.2}/README.md +0 -0
- {weed_cli-1.9.5 → weed_cli-2.0.2}/dht.py +0 -0
- {weed_cli-1.9.5 → weed_cli-2.0.2}/discovery_relay.py +0 -0
- {weed_cli-1.9.5 → weed_cli-2.0.2}/lightning_settle.py +0 -0
- {weed_cli-1.9.5 → weed_cli-2.0.2}/poc_reputation.py +0 -0
- {weed_cli-1.9.5 → weed_cli-2.0.2}/setup.cfg +0 -0
- {weed_cli-1.9.5 → weed_cli-2.0.2}/shell.py +0 -0
- {weed_cli-1.9.5 → weed_cli-2.0.2}/tests/test_dht.py +0 -0
- {weed_cli-1.9.5 → weed_cli-2.0.2}/tests/test_discovery_relay.py +0 -0
- {weed_cli-1.9.5 → weed_cli-2.0.2}/tests/test_host_live_reload.py +0 -0
- {weed_cli-1.9.5 → weed_cli-2.0.2}/tests/test_node_manifest.py +0 -0
- {weed_cli-1.9.5 → weed_cli-2.0.2}/tests/test_web_ui_api.py +0 -0
- {weed_cli-1.9.5 → weed_cli-2.0.2}/tests/testutil.py +0 -0
- {weed_cli-1.9.5 → weed_cli-2.0.2}/tunnel_relay.py +0 -0
- {weed_cli-1.9.5 → weed_cli-2.0.2}/weed_cli.egg-info/dependency_links.txt +0 -0
- {weed_cli-1.9.5 → weed_cli-2.0.2}/weed_cli.egg-info/entry_points.txt +0 -0
- {weed_cli-1.9.5 → weed_cli-2.0.2}/weed_cli.egg-info/requires.txt +0 -0
- {weed_cli-1.9.5 → 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:
|
|
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
|
|
@@ -249,10 +249,19 @@ def load_manifest_entries(archive_dir, file_name=None):
|
|
|
249
249
|
sys.exit(f"no .ott/manifest.jsonl in {archive_dir} — archive a file with ott first")
|
|
250
250
|
with open(manifest_path) as f:
|
|
251
251
|
raw = [json.loads(line) for line in f if line.strip()]
|
|
252
|
+
# Deduplicate by sha256 first (last-write-wins), matching ott's own
|
|
253
|
+
# load_manifest convention. A rename appends a new entry with an
|
|
254
|
+
# updated name/last_path under the same sha256; without this step the
|
|
255
|
+
# old entry (wrong name, missing path) survives as a "different file"
|
|
256
|
+
# and causes a spurious "not found on disk" error at startup.
|
|
257
|
+
by_hash: dict = {}
|
|
258
|
+
for e in raw:
|
|
259
|
+
by_hash[e['sha256']] = e
|
|
260
|
+
deduped = list(by_hash.values())
|
|
252
261
|
if file_name:
|
|
253
|
-
|
|
262
|
+
deduped = [e for e in deduped if e['name'] == file_name]
|
|
254
263
|
by_name = {}
|
|
255
|
-
for e in
|
|
264
|
+
for e in deduped:
|
|
256
265
|
by_name[e['name']] = e
|
|
257
266
|
all_entries = list(by_name.values())
|
|
258
267
|
entries = [e for e in all_entries if e.get('type') in ('video', 'audio')]
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "weed-cli"
|
|
7
|
-
version = "
|
|
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
|
-
|
|
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
|
|
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'
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|