weed-cli 1.2.8__tar.gz → 1.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {weed_cli-1.2.8 → weed_cli-1.3.1}/PKG-INFO +1 -1
- {weed_cli-1.2.8 → weed_cli-1.3.1}/discovery_relay.py +51 -0
- {weed_cli-1.2.8 → weed_cli-1.3.1}/node.py +32 -8
- {weed_cli-1.2.8 → weed_cli-1.3.1}/pyproject.toml +1 -1
- {weed_cli-1.2.8 → weed_cli-1.3.1}/tunnel_relay.py +64 -10
- {weed_cli-1.2.8 → weed_cli-1.3.1}/web_ui.py +45 -4
- {weed_cli-1.2.8 → weed_cli-1.3.1}/weed_cli.egg-info/PKG-INFO +1 -1
- {weed_cli-1.2.8 → weed_cli-1.3.1}/LICENSE +0 -0
- {weed_cli-1.2.8 → weed_cli-1.3.1}/README.md +0 -0
- {weed_cli-1.2.8 → weed_cli-1.3.1}/dht.py +0 -0
- {weed_cli-1.2.8 → weed_cli-1.3.1}/lightning_settle.py +0 -0
- {weed_cli-1.2.8 → weed_cli-1.3.1}/poc_reputation.py +0 -0
- {weed_cli-1.2.8 → weed_cli-1.3.1}/setup.cfg +0 -0
- {weed_cli-1.2.8 → weed_cli-1.3.1}/shell.py +0 -0
- {weed_cli-1.2.8 → weed_cli-1.3.1}/weed.py +0 -0
- {weed_cli-1.2.8 → weed_cli-1.3.1}/weed_cli.egg-info/SOURCES.txt +0 -0
- {weed_cli-1.2.8 → weed_cli-1.3.1}/weed_cli.egg-info/dependency_links.txt +0 -0
- {weed_cli-1.2.8 → weed_cli-1.3.1}/weed_cli.egg-info/entry_points.txt +0 -0
- {weed_cli-1.2.8 → weed_cli-1.3.1}/weed_cli.egg-info/requires.txt +0 -0
- {weed_cli-1.2.8 → weed_cli-1.3.1}/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.
|
|
3
|
+
Version: 1.3.1
|
|
4
4
|
Summary: Censorship-resistant video PoC — discovery/hosting/download over signed relay events, a real Kademlia DHT, or a TLS-capable NAT-traversal tunnel
|
|
5
5
|
License: MIT
|
|
6
6
|
Keywords: p2p,video,censorship-resistant,discovery,dht,kademlia,nat-traversal
|
|
@@ -41,6 +41,22 @@ DATA_PATH = os.environ.get('WEED_RELAY_DATA',
|
|
|
41
41
|
_events = []
|
|
42
42
|
_lock = threading.Lock()
|
|
43
43
|
|
|
44
|
+
# no real signed event (a handful of JSON fields: hashes, URLs, titles)
|
|
45
|
+
# is anywhere close to this; it's purely a cap against a client claiming
|
|
46
|
+
# a huge Content-Length and making this internet-facing server read an
|
|
47
|
+
# unbounded amount into memory
|
|
48
|
+
MAX_BODY_SIZE = 256 * 1024
|
|
49
|
+
|
|
50
|
+
# bounds one identity's storage footprint on this relay -- signer_pubkey
|
|
51
|
+
# is cryptographically bound to its event's signature (see
|
|
52
|
+
# verify_attestation), so it can't be spoofed to blame someone else's
|
|
53
|
+
# quota, and is genuinely what's exceeding the cap. Doesn't stop an
|
|
54
|
+
# attacker from generating a fresh keypair per event -- Ed25519 keygen is
|
|
55
|
+
# free -- but that's a fundamentally bigger attack (unlimited identities)
|
|
56
|
+
# than "spam with one key," and defending against it needs proof-of-
|
|
57
|
+
# work/stake, a bigger design decision than this PoC calls for.
|
|
58
|
+
MAX_EVENTS_PER_SIGNER = 200
|
|
59
|
+
|
|
44
60
|
|
|
45
61
|
def _load_events():
|
|
46
62
|
if not os.path.exists(DATA_PATH):
|
|
@@ -62,6 +78,33 @@ def _append_event(event):
|
|
|
62
78
|
f.write(json.dumps(event) + '\n')
|
|
63
79
|
|
|
64
80
|
|
|
81
|
+
def _rewrite_events_file():
|
|
82
|
+
"""Full rewrite instead of the usual cheap append -- only needed when
|
|
83
|
+
an entry was evicted from _events (see _evict_oldest_for_signer),
|
|
84
|
+
since dropping a line from a JSONL file means rewriting it."""
|
|
85
|
+
os.makedirs(os.path.dirname(DATA_PATH) or '.', exist_ok=True)
|
|
86
|
+
tmp = DATA_PATH + '.tmp'
|
|
87
|
+
with open(tmp, 'w') as f:
|
|
88
|
+
for event in _events:
|
|
89
|
+
f.write(json.dumps(event) + '\n')
|
|
90
|
+
os.replace(tmp, DATA_PATH)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _evict_oldest_for_signer(signer_pubkey):
|
|
94
|
+
"""Caller must hold _lock. Drops that signer's own oldest event once
|
|
95
|
+
they're at the cap, making room for the new one -- an active
|
|
96
|
+
publisher keeps working, they just eventually lose their own oldest
|
|
97
|
+
entries first, same trade as any keep-last-N retention policy."""
|
|
98
|
+
if not signer_pubkey:
|
|
99
|
+
return
|
|
100
|
+
mine = [e for e in _events if e['payload'].get('signer_pubkey') == signer_pubkey]
|
|
101
|
+
if len(mine) < MAX_EVENTS_PER_SIGNER:
|
|
102
|
+
return
|
|
103
|
+
oldest = min(mine, key=lambda e: e['payload'].get('ts', 0))
|
|
104
|
+
_events.remove(oldest)
|
|
105
|
+
_rewrite_events_file()
|
|
106
|
+
|
|
107
|
+
|
|
65
108
|
class RelayHandler(BaseHTTPRequestHandler):
|
|
66
109
|
def log_message(self, fmt, *args):
|
|
67
110
|
pass # quiet — poc_discovery.py prints what matters
|
|
@@ -70,6 +113,13 @@ class RelayHandler(BaseHTTPRequestHandler):
|
|
|
70
113
|
if self.path != '/event':
|
|
71
114
|
self.send_response(404); self.end_headers(); return
|
|
72
115
|
length = int(self.headers.get('Content-Length', 0))
|
|
116
|
+
if length > MAX_BODY_SIZE:
|
|
117
|
+
self.send_response(413)
|
|
118
|
+
self.send_header('Content-Type', 'application/json')
|
|
119
|
+
self.end_headers()
|
|
120
|
+
self.wfile.write(json.dumps({'ok': False,
|
|
121
|
+
'reason': f'body too large (max {MAX_BODY_SIZE} bytes)'}).encode())
|
|
122
|
+
return
|
|
73
123
|
try:
|
|
74
124
|
event = json.loads(self.rfile.read(length))
|
|
75
125
|
ok, reason = verify_attestation(event) # generic: works on any {payload, signature} blob
|
|
@@ -84,6 +134,7 @@ class RelayHandler(BaseHTTPRequestHandler):
|
|
|
84
134
|
eid = attestation_id(event)
|
|
85
135
|
with _lock:
|
|
86
136
|
if not any(attestation_id(e) == eid for e in _events):
|
|
137
|
+
_evict_oldest_for_signer(event['payload'].get('signer_pubkey'))
|
|
87
138
|
_events.append(event)
|
|
88
139
|
_append_event(event)
|
|
89
140
|
self.send_response(200)
|
|
@@ -347,18 +347,43 @@ def run_host_tunnel(relay_host, relay_port, token, entry, leaves, file_path, pri
|
|
|
347
347
|
DATA connection, dialed back to the relay on demand (NEWSTREAM), so
|
|
348
348
|
concurrent tunneled downloads don't block each other.
|
|
349
349
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
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.
|
|
7
|
+
version = "1.3.1"
|
|
8
8
|
description = "Censorship-resistant video PoC — discovery/hosting/download over signed relay events, a real Kademlia DHT, or a TLS-capable NAT-traversal tunnel"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -18,6 +18,9 @@ raw pipe (CONNECT/DATA, once paired):
|
|
|
18
18
|
REGISTER <token> host -> relay, persistent control connection.
|
|
19
19
|
token is whatever the host wants to be found
|
|
20
20
|
under (node.py uses the archive's content_hash).
|
|
21
|
+
Replies OK, or ERR if another still-active
|
|
22
|
+
connection already holds this token (first
|
|
23
|
+
registration wins; no stealing an active one).
|
|
21
24
|
CONNECT <token> downloader -> relay, wants to reach that host.
|
|
22
25
|
Everything sent after this line IS the tunneled
|
|
23
26
|
protocol, not a relay command.
|
|
@@ -41,7 +44,7 @@ _pending_streams = {} # stream_id -> _Pairing (defined below)
|
|
|
41
44
|
_lock = threading.Lock()
|
|
42
45
|
|
|
43
46
|
|
|
44
|
-
def recv_line(sock):
|
|
47
|
+
def recv_line(sock, timeout=10, max_len=256):
|
|
45
48
|
"""Byte-at-a-time on purpose: CONNECT/DATA connections become a raw
|
|
46
49
|
byte pipe immediately after this first line, and the real client
|
|
47
50
|
(HostConnection.connect_via_tunnel) sends its next protocol command
|
|
@@ -49,14 +52,33 @@ def recv_line(sock):
|
|
|
49
52
|
those bytes along with the handshake line, leaving nothing for pipe()
|
|
50
53
|
to forward. One byte at a time guarantees this stops exactly at the
|
|
51
54
|
newline and never reads into what comes after. Only runs once per
|
|
52
|
-
connection (a ~20-40 byte line), not on the hot path.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
213
|
+
try:
|
|
214
|
+
line = recv_line(self.request)
|
|
215
|
+
except (socket.timeout, ValueError, OSError):
|
|
216
|
+
# a client that never sent a line, sent one too slowly, or
|
|
217
|
+
# sent one too long -- recv_line's own timeout/length cap
|
|
218
|
+
# already protects the server from this; it's routine, not
|
|
219
|
+
# a bug, so close quietly instead of socketserver logging a
|
|
220
|
+
# full traceback for it
|
|
221
|
+
self.request.close()
|
|
222
|
+
return
|
|
169
223
|
parts = line.split()
|
|
170
224
|
if len(parts) < 2:
|
|
171
225
|
self.request.close()
|
|
@@ -35,8 +35,19 @@ except ImportError:
|
|
|
35
35
|
_HAS_QR = False
|
|
36
36
|
|
|
37
37
|
WEB_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'web')
|
|
38
|
-
|
|
38
|
+
# same $WEED_RELAY/$WEED_TUNNEL convention weed.py's CLI subcommands and
|
|
39
|
+
# shell.py's WeedShell already read (see weed.py's _default_relay) --
|
|
40
|
+
# without this, running web_ui.py directly ignored them entirely and
|
|
41
|
+
# silently fell back to the loopback default, even with a real relay
|
|
42
|
+
# configured for every other way of running this tool
|
|
43
|
+
DEFAULT_RELAY = os.environ.get('WEED_RELAY', 'http://127.0.0.1:9101')
|
|
44
|
+
DEFAULT_TUNNEL = os.environ.get('WEED_TUNNEL')
|
|
39
45
|
LIBRARY_PATH = os.path.expanduser('~/.weed_library.json')
|
|
46
|
+
# every real POST body here is a handful of JSON fields (hashes, URLs,
|
|
47
|
+
# titles) -- no endpoint ever legitimately needs anywhere near this much,
|
|
48
|
+
# it's purely a cap against a client claiming a huge Content-Length and
|
|
49
|
+
# making the server read an unbounded amount into memory
|
|
50
|
+
MAX_BODY_SIZE = 1024 * 1024
|
|
40
51
|
|
|
41
52
|
# script-relative, not CWD-relative -- same reasoning as WEB_DIR above, so
|
|
42
53
|
# downloads land in the same place regardless of the directory this was
|
|
@@ -268,6 +279,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
268
279
|
|
|
269
280
|
def _read_json_body(self):
|
|
270
281
|
length = int(self.headers.get('Content-Length', 0))
|
|
282
|
+
if length > MAX_BODY_SIZE:
|
|
283
|
+
raise ValueError(f'body too large ({length} bytes, max {MAX_BODY_SIZE})')
|
|
271
284
|
return json.loads(self.rfile.read(length)) if length else {}
|
|
272
285
|
|
|
273
286
|
def do_GET(self):
|
|
@@ -276,8 +289,9 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
276
289
|
|
|
277
290
|
if path == '/api/whoami':
|
|
278
291
|
return self._json({'pubkey': _identity().pubkey_hex()})
|
|
279
|
-
if path == '/api/
|
|
280
|
-
return self._json({'
|
|
292
|
+
if path == '/api/config':
|
|
293
|
+
return self._json({'lan_url': _lan_url, 'default_relay': DEFAULT_RELAY,
|
|
294
|
+
'default_tunnel': DEFAULT_TUNNEL})
|
|
281
295
|
if path == '/api/discover':
|
|
282
296
|
return self._json({'results': node.discover(qs.get('relay') or [DEFAULT_RELAY])})
|
|
283
297
|
if path == '/api/hosts':
|
|
@@ -309,8 +323,30 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
309
323
|
return self._handle_qr(data)
|
|
310
324
|
self._serve_static(path)
|
|
311
325
|
|
|
326
|
+
def _check_origin(self):
|
|
327
|
+
"""This server has no auth at all (see module docstring) -- the
|
|
328
|
+
only thing stopping any webpage you happen to have open in the
|
|
329
|
+
same browser from POSTing here (start hosting an arbitrary local
|
|
330
|
+
directory, download attacker-chosen content to an
|
|
331
|
+
attacker-chosen path, like/subscribe as you) is confirming the
|
|
332
|
+
request actually came from this UI, not some other origin your
|
|
333
|
+
browser also has open. Browsers always set Origin on cross-origin
|
|
334
|
+
fetch/XHR and can't be told by page JS to fake it, so a mismatch
|
|
335
|
+
here means a real cross-site request; a request with no Origin at
|
|
336
|
+
all (curl, direct API use, older browsers on a same-origin form
|
|
337
|
+
post) is let through since it isn't the CSRF-from-another-tab
|
|
338
|
+
shape this defends against."""
|
|
339
|
+
origin = self.headers.get('Origin')
|
|
340
|
+
if not origin:
|
|
341
|
+
return True
|
|
342
|
+
host = self.headers.get('Host', '')
|
|
343
|
+
return origin in (f'http://{host}', f'https://{host}')
|
|
344
|
+
|
|
312
345
|
def do_POST(self):
|
|
313
346
|
path = urlparse(self.path).path
|
|
347
|
+
if not self._check_origin():
|
|
348
|
+
return self._json({'error': 'rejected: request Origin does not match this server — '
|
|
349
|
+
'looks like a cross-site request, not this UI'}, status=403)
|
|
314
350
|
try:
|
|
315
351
|
body = self._read_json_body()
|
|
316
352
|
except Exception as e:
|
|
@@ -506,7 +542,12 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
506
542
|
path = '/index.html'
|
|
507
543
|
safe_path = os.path.normpath(path).lstrip('/')
|
|
508
544
|
full_path = os.path.join(WEB_DIR, safe_path)
|
|
509
|
-
|
|
545
|
+
# the trailing os.sep matters: without it, a sibling directory
|
|
546
|
+
# that happens to share WEB_DIR's name as a string prefix (e.g.
|
|
547
|
+
# "web-private") would also pass this check -- str.startswith
|
|
548
|
+
# doesn't know about path boundaries, only os.path.join does
|
|
549
|
+
web_dir_abs = os.path.abspath(WEB_DIR)
|
|
550
|
+
if not os.path.abspath(full_path).startswith(web_dir_abs + os.sep) \
|
|
510
551
|
or not os.path.isfile(full_path):
|
|
511
552
|
return self._json({'error': 'not found'}, status=404)
|
|
512
553
|
ctype, _ = mimetypes.guess_type(full_path)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: weed-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.1
|
|
4
4
|
Summary: Censorship-resistant video PoC — discovery/hosting/download over signed relay events, a real Kademlia DHT, or a TLS-capable NAT-traversal tunnel
|
|
5
5
|
License: MIT
|
|
6
6
|
Keywords: p2p,video,censorship-resistant,discovery,dht,kademlia,nat-traversal
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|