weed-cli 0.1.0__py3-none-any.whl
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.
- dht.py +137 -0
- discovery_relay.py +94 -0
- lightning_settle.py +90 -0
- node.py +784 -0
- poc_reputation.py +311 -0
- shell.py +420 -0
- tunnel_relay.py +204 -0
- web_ui.py +444 -0
- weed.py +238 -0
- weed_cli-0.1.0.dist-info/METADATA +870 -0
- weed_cli-0.1.0.dist-info/RECORD +14 -0
- weed_cli-0.1.0.dist-info/WHEEL +5 -0
- weed_cli-0.1.0.dist-info/entry_points.txt +2 -0
- weed_cli-0.1.0.dist-info/top_level.txt +9 -0
node.py
ADDED
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
The missing integration piece: a real node that hosts, discovers, and
|
|
4
|
+
downloads — not another isolated demo. Wires together pieces already built
|
|
5
|
+
tonight rather than reinventing them:
|
|
6
|
+
|
|
7
|
+
- real chunk-serve protocol, extended from poc_network_challenge.py's
|
|
8
|
+
holder (adds INFO/LEAVES so a downloader can learn the archive's shape
|
|
9
|
+
before fetching)
|
|
10
|
+
- real ott archives (same .ott/ format poc_real_archive_challenge.py
|
|
11
|
+
read from, via `pip install btcvm`)
|
|
12
|
+
- real signed events (Identity/sign_event from poc_reputation.py), now
|
|
13
|
+
persisted to disk instead of regenerated fresh every run — a real node
|
|
14
|
+
needs a stable identity across invocations
|
|
15
|
+
- the same discovery relay protocol from discovery_relay.py
|
|
16
|
+
|
|
17
|
+
What's actually new here, not just wired: a real client-driven download —
|
|
18
|
+
every previous script verified chunks locally or over a network, none of
|
|
19
|
+
them reassembled a full file from a remote peer onto disk before this.
|
|
20
|
+
"""
|
|
21
|
+
import base64
|
|
22
|
+
import hashlib
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import random
|
|
26
|
+
import socket
|
|
27
|
+
import ssl
|
|
28
|
+
import sys
|
|
29
|
+
import threading
|
|
30
|
+
import time
|
|
31
|
+
import urllib.request
|
|
32
|
+
|
|
33
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
34
|
+
from poc_reputation import Identity, verify_attestation, attestation_id
|
|
35
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
36
|
+
from cryptography.hazmat.primitives import serialization
|
|
37
|
+
|
|
38
|
+
IDENTITY_PATH = os.path.expanduser('~/.weed_identity.key')
|
|
39
|
+
IDENTITY_ARMOR_HEADER = '-----BEGIN WEED IDENTITY KEY-----'
|
|
40
|
+
IDENTITY_ARMOR_FOOTER = '-----END WEED IDENTITY KEY-----'
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _armor_identity(raw_bytes):
|
|
44
|
+
"""Base64 text with a header/footer — NOT real OpenPGP armor (no
|
|
45
|
+
CRC24, no packet framing), and not encryption: this only changes how
|
|
46
|
+
the same private key bytes are encoded on disk, from opaque binary
|
|
47
|
+
(`file` calls it "data") to something readable/diffable/copy-
|
|
48
|
+
pasteable. A weed-specific label on purpose, not a PGP one — this
|
|
49
|
+
repo already decided against adopting the real OpenPGP format (see
|
|
50
|
+
README's "Transitive trust" section), so nothing here should look
|
|
51
|
+
like it's actually PGP-compatible."""
|
|
52
|
+
b64 = base64.b64encode(raw_bytes).decode()
|
|
53
|
+
lines = [b64[i:i + 64] for i in range(0, len(b64), 64)]
|
|
54
|
+
return IDENTITY_ARMOR_HEADER + '\n' + '\n'.join(lines) + '\n' + IDENTITY_ARMOR_FOOTER + '\n'
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _dearmor_identity(text):
|
|
58
|
+
lines = text.strip().splitlines()
|
|
59
|
+
if len(lines) < 2 or lines[0].strip() != IDENTITY_ARMOR_HEADER \
|
|
60
|
+
or lines[-1].strip() != IDENTITY_ARMOR_FOOTER:
|
|
61
|
+
raise ValueError('not a weed-armored identity key')
|
|
62
|
+
return base64.b64decode(''.join(lines[1:-1]))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_or_create_identity():
|
|
66
|
+
"""A real node needs a stable pubkey across runs — regenerating fresh
|
|
67
|
+
every invocation (like every other script tonight) would mean nobody
|
|
68
|
+
could ever build reputation or subscribe to a host's key for real."""
|
|
69
|
+
identity = Identity('local')
|
|
70
|
+
if os.path.exists(IDENTITY_PATH):
|
|
71
|
+
with open(IDENTITY_PATH, 'rb') as f:
|
|
72
|
+
file_bytes = f.read()
|
|
73
|
+
try:
|
|
74
|
+
key_bytes = _dearmor_identity(file_bytes.decode())
|
|
75
|
+
except (UnicodeDecodeError, ValueError):
|
|
76
|
+
# pre-armor identity file (raw 32 bytes, no wrapper) — same key,
|
|
77
|
+
# just not encoded yet. Re-armor it in place so this only ever
|
|
78
|
+
# has to happen once; the underlying private key bytes (and
|
|
79
|
+
# therefore the pubkey) are untouched.
|
|
80
|
+
key_bytes = file_bytes
|
|
81
|
+
with open(IDENTITY_PATH, 'w') as f:
|
|
82
|
+
f.write(_armor_identity(key_bytes))
|
|
83
|
+
os.chmod(IDENTITY_PATH, 0o600)
|
|
84
|
+
identity._priv = Ed25519PrivateKey.from_private_bytes(key_bytes)
|
|
85
|
+
identity.pub = identity._priv.public_key()
|
|
86
|
+
else:
|
|
87
|
+
key_bytes = identity._priv.private_bytes(
|
|
88
|
+
serialization.Encoding.Raw, serialization.PrivateFormat.Raw,
|
|
89
|
+
serialization.NoEncryption())
|
|
90
|
+
with open(IDENTITY_PATH, 'w') as f:
|
|
91
|
+
f.write(_armor_identity(key_bytes))
|
|
92
|
+
os.chmod(IDENTITY_PATH, 0o600)
|
|
93
|
+
return identity
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ── wire protocol — text command line, JSON/base64 bodies where needed ─────
|
|
97
|
+
|
|
98
|
+
def recv_line(sock):
|
|
99
|
+
buf = b''
|
|
100
|
+
while not buf.endswith(b'\n'):
|
|
101
|
+
chunk = sock.recv(65536)
|
|
102
|
+
if not chunk:
|
|
103
|
+
break
|
|
104
|
+
buf += chunk
|
|
105
|
+
return buf.decode().strip()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class LineReader:
|
|
109
|
+
"""Buffered line reader for a connection that can receive more than
|
|
110
|
+
one newline-terminated message per recv() — recv_line above assumes
|
|
111
|
+
exactly one line arrives per call, true everywhere else in this
|
|
112
|
+
protocol (strict request/response, never pipelined) but not true for
|
|
113
|
+
the tunnel control channel: if two downloaders CONNECT to
|
|
114
|
+
tunnel_relay.py around the same time, both NEWSTREAM messages can
|
|
115
|
+
legitimately land in a single recv(), and recv_line would silently
|
|
116
|
+
fold both into one malformed line and drop the second one."""
|
|
117
|
+
|
|
118
|
+
def __init__(self, sock):
|
|
119
|
+
self.sock = sock
|
|
120
|
+
self.buf = b''
|
|
121
|
+
|
|
122
|
+
def readline(self):
|
|
123
|
+
while b'\n' not in self.buf:
|
|
124
|
+
chunk = self.sock.recv(4096)
|
|
125
|
+
if not chunk:
|
|
126
|
+
line, self.buf = self.buf, b''
|
|
127
|
+
return line.decode().strip()
|
|
128
|
+
self.buf += chunk
|
|
129
|
+
line, self.buf = self.buf.split(b'\n', 1)
|
|
130
|
+
return line.decode().strip()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def find_manifest_entry(archive_dir, file_name=None):
|
|
134
|
+
archive_dir = os.path.expanduser(archive_dir) # os.path.join never expands ~, it stays literal
|
|
135
|
+
manifest_path = os.path.join(archive_dir, '.ott', 'manifest.jsonl')
|
|
136
|
+
if not os.path.exists(manifest_path):
|
|
137
|
+
sys.exit(f"no .ott/manifest.jsonl in {archive_dir} — archive a file with ott first")
|
|
138
|
+
with open(manifest_path) as f:
|
|
139
|
+
entries = [json.loads(line) for line in f if line.strip()]
|
|
140
|
+
if file_name:
|
|
141
|
+
entries = [e for e in entries if e['name'] == file_name]
|
|
142
|
+
if not entries:
|
|
143
|
+
sys.exit(f"no archived file found in {archive_dir}" + (f" matching {file_name}" if file_name else ""))
|
|
144
|
+
return entries[-1] # last-write-wins, same convention ott itself uses
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def load_manifest_entries(archive_dir, file_name=None):
|
|
148
|
+
"""Every distinct file in the archive, not just one — find_manifest_entry
|
|
149
|
+
collapses to a single entries[-1], which is exactly why `host <dir>` with
|
|
150
|
+
no --file only ever served the single most-recently-added file out of a
|
|
151
|
+
45-video archive. Dedupes by name (last-write-wins, same convention)."""
|
|
152
|
+
archive_dir = os.path.expanduser(archive_dir)
|
|
153
|
+
manifest_path = os.path.join(archive_dir, '.ott', 'manifest.jsonl')
|
|
154
|
+
if not os.path.exists(manifest_path):
|
|
155
|
+
sys.exit(f"no .ott/manifest.jsonl in {archive_dir} — archive a file with ott first")
|
|
156
|
+
with open(manifest_path) as f:
|
|
157
|
+
raw = [json.loads(line) for line in f if line.strip()]
|
|
158
|
+
if file_name:
|
|
159
|
+
raw = [e for e in raw if e['name'] == file_name]
|
|
160
|
+
by_name = {}
|
|
161
|
+
for e in raw:
|
|
162
|
+
by_name[e['name']] = e
|
|
163
|
+
entries = list(by_name.values())
|
|
164
|
+
if not entries:
|
|
165
|
+
sys.exit(f"no archived file found in {archive_dir}" + (f" matching {file_name}" if file_name else ""))
|
|
166
|
+
return entries
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def load_leaves(archive_dir, root_hash):
|
|
170
|
+
archive_dir = os.path.expanduser(archive_dir)
|
|
171
|
+
chunks_path = os.path.join(archive_dir, '.ott', 'chunks', f'{root_hash}.json')
|
|
172
|
+
if not os.path.exists(chunks_path):
|
|
173
|
+
sys.exit(f"no chunks file at {chunks_path} — the manifest entry for this hash has no "
|
|
174
|
+
f"matching chunk data in this archive_dir (stale/mismatched .ott/ state, or "
|
|
175
|
+
f"this isn't the archive_dir that file was actually added from)")
|
|
176
|
+
with open(chunks_path) as f:
|
|
177
|
+
return json.load(f)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def serve_session(conn, entries_by_hash, default_hash, price):
|
|
181
|
+
"""Handle every command on one connection, not just one — a download
|
|
182
|
+
needs INFO + LEAVES + one FETCH per chunk (thousands, for a real
|
|
183
|
+
archive), and reconnecting per command is what makes a tunneled
|
|
184
|
+
connection (see tunnel_relay.py) pay a full relay rendezvous per
|
|
185
|
+
chunk instead of once per session. Shared by the direct accept() loop
|
|
186
|
+
below and by run_host_tunnel's per-stream data connections.
|
|
187
|
+
|
|
188
|
+
entries_by_hash lets one server (one port) hold more than one file —
|
|
189
|
+
a downloader picks which via SELECT <content_hash_or_prefix> before
|
|
190
|
+
anything else. default_hash (set only when the host has exactly one
|
|
191
|
+
file) means a single-file host never needs SELECT at all, so the
|
|
192
|
+
`download --from host:port` discovery-skipping escape hatch and the
|
|
193
|
+
tunnel path (already scoped to one file before this function runs)
|
|
194
|
+
keep working unchanged."""
|
|
195
|
+
selected = default_hash
|
|
196
|
+
with conn:
|
|
197
|
+
while True:
|
|
198
|
+
line = recv_line(conn)
|
|
199
|
+
if not line:
|
|
200
|
+
break
|
|
201
|
+
parts = line.split()
|
|
202
|
+
if not parts:
|
|
203
|
+
continue
|
|
204
|
+
if parts[0] == 'SELECT':
|
|
205
|
+
match = next((h for h in entries_by_hash if len(parts) > 1 and h.startswith(parts[1])), None)
|
|
206
|
+
if match:
|
|
207
|
+
selected = match
|
|
208
|
+
conn.sendall(b'OK\n')
|
|
209
|
+
else:
|
|
210
|
+
conn.sendall(b'ERR unknown content hash\n')
|
|
211
|
+
continue
|
|
212
|
+
if selected is None:
|
|
213
|
+
conn.sendall(b'ERR this host serves more than one file '
|
|
214
|
+
b'- send SELECT <content_hash> first\n')
|
|
215
|
+
continue
|
|
216
|
+
entry, leaves, file_path = entries_by_hash[selected]
|
|
217
|
+
if parts[0] == 'INFO':
|
|
218
|
+
conn.sendall((json.dumps({
|
|
219
|
+
'name': entry['name'], 'sha256': entry['sha256'], 'size': entry['size'],
|
|
220
|
+
'n_chunks': entry['n_chunks'], 'chunk_size': entry['chunk_size'],
|
|
221
|
+
}) + '\n').encode())
|
|
222
|
+
elif parts[0] == 'LEAVES':
|
|
223
|
+
conn.sendall((json.dumps(leaves) + '\n').encode())
|
|
224
|
+
elif parts[0] == 'CHALLENGE':
|
|
225
|
+
idx, nonce_hex = int(parts[1]), parts[2]
|
|
226
|
+
with open(file_path, 'rb') as f:
|
|
227
|
+
f.seek(idx * entry['chunk_size'])
|
|
228
|
+
data = f.read(entry['chunk_size'])
|
|
229
|
+
h = hashlib.sha256(data + bytes.fromhex(nonce_hex)).hexdigest()
|
|
230
|
+
conn.sendall(f'HASH {h}\n'.encode())
|
|
231
|
+
elif parts[0] == 'FETCH':
|
|
232
|
+
idx = int(parts[1])
|
|
233
|
+
with open(file_path, 'rb') as f:
|
|
234
|
+
f.seek(idx * entry['chunk_size'])
|
|
235
|
+
data = f.read(entry['chunk_size'])
|
|
236
|
+
conn.sendall((f'DATA {base64.b64encode(data).decode()}\n').encode())
|
|
237
|
+
elif parts[0] == 'PRICE':
|
|
238
|
+
conn.sendall(f'PRICE {price}\n'.encode())
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def run_host_server(archive_dir, file_name, port, bind_host='0.0.0.0', quiet=False, price=0):
|
|
242
|
+
archive_dir = os.path.expanduser(archive_dir)
|
|
243
|
+
entries = load_manifest_entries(archive_dir, file_name)
|
|
244
|
+
entries_by_hash = {}
|
|
245
|
+
for entry in entries:
|
|
246
|
+
leaves = load_leaves(archive_dir, entry['sha256'])
|
|
247
|
+
file_path = entry.get('last_path') or os.path.join(archive_dir, entry['name'])
|
|
248
|
+
if not os.path.exists(file_path):
|
|
249
|
+
sys.exit(f"archived file not found on disk at {file_path}")
|
|
250
|
+
entries_by_hash[entry['sha256']] = (entry, leaves, file_path)
|
|
251
|
+
default_hash = next(iter(entries_by_hash)) if len(entries_by_hash) == 1 else None
|
|
252
|
+
|
|
253
|
+
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
254
|
+
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
255
|
+
srv.bind((bind_host, port))
|
|
256
|
+
srv.listen(8)
|
|
257
|
+
if not quiet:
|
|
258
|
+
# a background thread's print() races with cmd.Cmd's input()-driven
|
|
259
|
+
# prompt on the same stdout — see run_relay_server's docstring for
|
|
260
|
+
# why the shell passes quiet=True instead of patching this visually
|
|
261
|
+
if default_hash:
|
|
262
|
+
entry = entries[0]
|
|
263
|
+
print(f"[host:{port}] serving {entry['name']} ({entry['size']:,} bytes, "
|
|
264
|
+
f"{entry['n_chunks']} chunks x {entry['chunk_size']} bytes)")
|
|
265
|
+
print(f"[host:{port}] sha256/merkle root: {entry['sha256']}")
|
|
266
|
+
else:
|
|
267
|
+
total_size = sum(e['size'] for e in entries)
|
|
268
|
+
print(f"[host:{port}] serving {len(entries)} files ({total_size:,} bytes total) "
|
|
269
|
+
f"— clients SELECT which one by content hash")
|
|
270
|
+
|
|
271
|
+
while True:
|
|
272
|
+
conn, _ = srv.accept()
|
|
273
|
+
# a whole download now lives on one connection (see serve_session) —
|
|
274
|
+
# accept() must hand off to a thread per connection, or one session
|
|
275
|
+
# would block every other downloader until it finished
|
|
276
|
+
threading.Thread(target=serve_session, args=(conn, entries_by_hash, default_hash, price),
|
|
277
|
+
daemon=True).start()
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _connect_tunnel_socket(relay_host, relay_port, use_tls):
|
|
281
|
+
"""Plain TCP to the tunnel relay, or TLS-wrapped if the relay
|
|
282
|
+
terminates TLS at the edge (e.g. Fly's `handlers = ["tls"]`) — the
|
|
283
|
+
relay process itself (tunnel_relay.py) never sees or needs to know
|
|
284
|
+
about TLS either way, since edge termination decrypts before
|
|
285
|
+
forwarding to it. Only the two ends actually crossing the public
|
|
286
|
+
internet need this: a host's REGISTER/DATA connections here, and a
|
|
287
|
+
downloader's CONNECT in HostConnection.connect_via_tunnel."""
|
|
288
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
289
|
+
sock.connect((relay_host, relay_port))
|
|
290
|
+
if use_tls:
|
|
291
|
+
ctx = ssl.create_default_context()
|
|
292
|
+
sock = ctx.wrap_socket(sock, server_hostname=relay_host)
|
|
293
|
+
return sock
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def run_host_tunnel(relay_host, relay_port, token, entry, leaves, file_path, price,
|
|
297
|
+
use_tls=False, quiet=False, heartbeat_interval=45):
|
|
298
|
+
"""NAT-traversal path: instead of (or alongside) binding a locally
|
|
299
|
+
reachable port, register with a tunnel_relay.py relay and serve every
|
|
300
|
+
downloader it pairs us with. One persistent CONTROL connection stays
|
|
301
|
+
open for the lifetime of hosting; each real downloader gets its own
|
|
302
|
+
DATA connection, dialed back to the relay on demand (NEWSTREAM), so
|
|
303
|
+
concurrent tunneled downloads don't block each other.
|
|
304
|
+
|
|
305
|
+
The control connection sends nothing at all between REGISTER and the
|
|
306
|
+
first real NEWSTREAM — which can be minutes or hours if no one
|
|
307
|
+
downloads in the meantime. Real-world proxies in the middle (observed:
|
|
308
|
+
Fly's own edge) reset TCP connections that go idle for a few minutes,
|
|
309
|
+
which silently unregisters the host with no error on this end until
|
|
310
|
+
the next download attempt fails. A small periodic heartbeat keeps the
|
|
311
|
+
connection looking active; tunnel_relay.py's REGISTER loop already
|
|
312
|
+
discards anything it receives that isn't relevant to it (it only ever
|
|
313
|
+
checks for EOF), so this needs zero changes on the relay side."""
|
|
314
|
+
entries_by_hash = {entry['sha256']: (entry, leaves, file_path)}
|
|
315
|
+
ctrl = _connect_tunnel_socket(relay_host, relay_port, use_tls)
|
|
316
|
+
ctrl.sendall(f'REGISTER {token}\n'.encode())
|
|
317
|
+
if not quiet:
|
|
318
|
+
tls_note = ' (tls)' if use_tls else ''
|
|
319
|
+
print(f"[tunnel] registered {token[:16]}... with relay {relay_host}:{relay_port}{tls_note}")
|
|
320
|
+
|
|
321
|
+
stop_heartbeat = threading.Event()
|
|
322
|
+
|
|
323
|
+
def send_heartbeats():
|
|
324
|
+
while not stop_heartbeat.wait(heartbeat_interval):
|
|
325
|
+
try:
|
|
326
|
+
ctrl.sendall(b'PING\n')
|
|
327
|
+
except OSError:
|
|
328
|
+
return
|
|
329
|
+
|
|
330
|
+
threading.Thread(target=send_heartbeats, daemon=True).start()
|
|
331
|
+
|
|
332
|
+
try:
|
|
333
|
+
reader = LineReader(ctrl)
|
|
334
|
+
while True:
|
|
335
|
+
line = reader.readline()
|
|
336
|
+
if not line:
|
|
337
|
+
if not quiet:
|
|
338
|
+
print(f"[tunnel] control connection to {relay_host}:{relay_port} closed")
|
|
339
|
+
return
|
|
340
|
+
parts = line.split()
|
|
341
|
+
if parts and parts[0] == 'NEWSTREAM':
|
|
342
|
+
stream_id = parts[1]
|
|
343
|
+
data_conn = _connect_tunnel_socket(relay_host, relay_port, use_tls)
|
|
344
|
+
data_conn.sendall(f'DATA {stream_id}\n'.encode())
|
|
345
|
+
threading.Thread(target=serve_session,
|
|
346
|
+
args=(data_conn, entries_by_hash, entry['sha256'], price),
|
|
347
|
+
daemon=True).start()
|
|
348
|
+
# anything else (notably our own echoed-back nothing -- PING
|
|
349
|
+
# is one-directional, the relay never echoes it) is silently
|
|
350
|
+
# ignored, same as it always was
|
|
351
|
+
finally:
|
|
352
|
+
stop_heartbeat.set()
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# ── client side ──────────────────────────────────────────────────────────
|
|
356
|
+
|
|
357
|
+
class HostConnection:
|
|
358
|
+
"""One persistent socket carrying every command for a session, direct
|
|
359
|
+
or tunneled — see serve_session's docstring for why reconnecting per
|
|
360
|
+
command (the old behavior) is untenable once a tunnel relay is in the
|
|
361
|
+
path."""
|
|
362
|
+
|
|
363
|
+
def __init__(self, sock):
|
|
364
|
+
self.sock = sock
|
|
365
|
+
|
|
366
|
+
@classmethod
|
|
367
|
+
def connect_direct(cls, host, port, timeout=10):
|
|
368
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
369
|
+
sock.settimeout(timeout)
|
|
370
|
+
sock.connect((host, port))
|
|
371
|
+
return cls(sock)
|
|
372
|
+
|
|
373
|
+
@classmethod
|
|
374
|
+
def connect_via_tunnel(cls, relay_host, relay_port, token, use_tls=False, timeout=10):
|
|
375
|
+
"""token is the content_hash the host registered under (see
|
|
376
|
+
run_host_tunnel) — the tunnel relay has zero opinion on content,
|
|
377
|
+
it just pairs this CONNECT with that host's next NEWSTREAM."""
|
|
378
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
379
|
+
sock.settimeout(timeout)
|
|
380
|
+
sock.connect((relay_host, relay_port))
|
|
381
|
+
if use_tls:
|
|
382
|
+
ctx = ssl.create_default_context()
|
|
383
|
+
sock = ctx.wrap_socket(sock, server_hostname=relay_host)
|
|
384
|
+
sock.sendall(f'CONNECT {token}\n'.encode())
|
|
385
|
+
return cls(sock)
|
|
386
|
+
|
|
387
|
+
def request(self, line):
|
|
388
|
+
self.sock.sendall((line + '\n').encode())
|
|
389
|
+
return recv_line(self.sock)
|
|
390
|
+
|
|
391
|
+
def close(self):
|
|
392
|
+
self.sock.close()
|
|
393
|
+
|
|
394
|
+
def __enter__(self):
|
|
395
|
+
return self
|
|
396
|
+
|
|
397
|
+
def __exit__(self, *exc_info):
|
|
398
|
+
self.close()
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _parse_tunnel(tunnel_addr):
|
|
402
|
+
"""'relay_host:relay_port' or 'tls://relay_host:relay_port' ->
|
|
403
|
+
(relay_host, relay_port, use_tls), or None. The tls:// prefix marks a
|
|
404
|
+
relay that terminates TLS at the edge (e.g. Fly's `handlers =
|
|
405
|
+
["tls"]`) — publish() stores this string as given, so a downloader
|
|
406
|
+
who discovers the host gets the same tls:// marker automatically and
|
|
407
|
+
connects the same way the host registered."""
|
|
408
|
+
if not tunnel_addr:
|
|
409
|
+
return None
|
|
410
|
+
use_tls = tunnel_addr.startswith('tls://')
|
|
411
|
+
if use_tls:
|
|
412
|
+
tunnel_addr = tunnel_addr[len('tls://'):]
|
|
413
|
+
if ':' not in tunnel_addr:
|
|
414
|
+
raise ValueError(f"--tunnel {tunnel_addr!r} is missing a port — expected "
|
|
415
|
+
f"[tls://]host:port, e.g. tls://tunnel.hak4.org:9199")
|
|
416
|
+
relay_host, relay_port = tunnel_addr.rsplit(':', 1)
|
|
417
|
+
if not relay_port.isdigit():
|
|
418
|
+
raise ValueError(f"--tunnel port {relay_port!r} isn't a number — expected "
|
|
419
|
+
f"[tls://]host:port, e.g. tls://tunnel.hak4.org:9199")
|
|
420
|
+
return relay_host, int(relay_port), use_tls
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def open_connection(host_addr, tunnel=None, content_hash=None, timeout=10):
|
|
424
|
+
"""host_addr is 'host:port' — used directly unless tunnel is given, in
|
|
425
|
+
which case it's ignored and content_hash is used as the tunnel
|
|
426
|
+
rendezvous token instead (the host isn't reachable at host_addr at
|
|
427
|
+
all in that case). tunnel is a pre-parsed _parse_tunnel() result."""
|
|
428
|
+
if tunnel:
|
|
429
|
+
if not content_hash:
|
|
430
|
+
raise ValueError("tunnel connection requires content_hash as the rendezvous token")
|
|
431
|
+
relay_host, relay_port, use_tls = tunnel
|
|
432
|
+
return HostConnection.connect_via_tunnel(relay_host, relay_port, content_hash,
|
|
433
|
+
use_tls=use_tls, timeout=timeout)
|
|
434
|
+
host, port_s = host_addr.rsplit(':', 1)
|
|
435
|
+
return HostConnection.connect_direct(host, int(port_s), timeout=timeout)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def download(host_addr, out_path, tunnel=None, content_hash=None, on_progress=None):
|
|
439
|
+
from ott import merkle_root # pip install btcvm
|
|
440
|
+
|
|
441
|
+
out_path = os.path.expanduser(out_path)
|
|
442
|
+
via = f"tunnel {tunnel[0]}:{tunnel[1]}" if tunnel else host_addr
|
|
443
|
+
with open_connection(host_addr, tunnel=tunnel, content_hash=content_hash) as conn:
|
|
444
|
+
if content_hash and not tunnel:
|
|
445
|
+
# tunnel connections are already scoped to one file by the relay's
|
|
446
|
+
# rendezvous token (see run_host_tunnel) — SELECT is only needed
|
|
447
|
+
# against a direct multi-file host, which may be serving more than
|
|
448
|
+
# just this content_hash on the same port
|
|
449
|
+
sel = conn.request(f'SELECT {content_hash}')
|
|
450
|
+
if sel != 'OK':
|
|
451
|
+
sys.exit(f"host at {host_addr} rejected SELECT {content_hash[:16]}...: {sel}")
|
|
452
|
+
info = json.loads(conn.request('INFO'))
|
|
453
|
+
print(f"downloading {info['name']} ({info['size']:,} bytes, {info['n_chunks']} chunks) "
|
|
454
|
+
f"from {via}")
|
|
455
|
+
leaves = json.loads(conn.request('LEAVES'))
|
|
456
|
+
if len(leaves) != info['n_chunks']:
|
|
457
|
+
sys.exit(f"host's LEAVES count ({len(leaves)}) doesn't match its own INFO "
|
|
458
|
+
f"({info['n_chunks']}) — refusing to trust an inconsistent host")
|
|
459
|
+
|
|
460
|
+
# ott records a VIDEO's sha256 field as the Merkle root over its chunk
|
|
461
|
+
# hashes, not a linear whole-file hash (see cmd_add: `digest =
|
|
462
|
+
# merkle_root(chunks)`) — verify that BEFORE downloading a single byte,
|
|
463
|
+
# so a host can't serve a self-consistent-but-fake leaves list.
|
|
464
|
+
recomputed_root = merkle_root(leaves)
|
|
465
|
+
if recomputed_root != info['sha256']:
|
|
466
|
+
sys.exit(f"host's LEAVES don't Merkle-root to its own advertised sha256 "
|
|
467
|
+
f"({recomputed_root[:16]}... != {info['sha256'][:16]}...) — "
|
|
468
|
+
f"refusing to download from a host lying about its own archive")
|
|
469
|
+
|
|
470
|
+
t0 = time.time()
|
|
471
|
+
with open(out_path, 'wb') as out:
|
|
472
|
+
for idx in range(info['n_chunks']):
|
|
473
|
+
resp = conn.request(f'FETCH {idx}')
|
|
474
|
+
if not resp.startswith('DATA '):
|
|
475
|
+
sys.exit(f"chunk {idx}: bad response from host: {resp[:80]}")
|
|
476
|
+
data = base64.b64decode(resp[5:])
|
|
477
|
+
leaf = hashlib.sha256(data).hexdigest()
|
|
478
|
+
if leaf != leaves[idx]:
|
|
479
|
+
sys.exit(f"chunk {idx}: hash mismatch — host sent bytes that don't match "
|
|
480
|
+
f"its own committed chunk hash. aborting download, not writing a "
|
|
481
|
+
f"corrupted/tampered file.")
|
|
482
|
+
out.write(data)
|
|
483
|
+
if on_progress:
|
|
484
|
+
on_progress(idx, info['n_chunks'])
|
|
485
|
+
if idx % 200 == 0 or idx == info['n_chunks'] - 1:
|
|
486
|
+
print(f" chunk {idx + 1}/{info['n_chunks']} verified", end='\r', flush=True)
|
|
487
|
+
elapsed = time.time() - t0
|
|
488
|
+
actual_size = os.path.getsize(out_path)
|
|
489
|
+
print(f"\n{info['n_chunks']} chunks downloaded and verified in {elapsed:.1f}s")
|
|
490
|
+
print("Merkle root over received chunks matches host's advertised sha256: True "
|
|
491
|
+
"(checked before downloading, not after)")
|
|
492
|
+
if actual_size != info['size']:
|
|
493
|
+
os.remove(out_path)
|
|
494
|
+
sys.exit(f"size mismatch: wrote {actual_size:,} bytes, host advertised {info['size']:,} "
|
|
495
|
+
f"— deleted the output, do not trust this download")
|
|
496
|
+
return out_path
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
# ── possession challenge + price auction, wired to real discovery ───────
|
|
500
|
+
#
|
|
501
|
+
# Everything below stitches poc_challenge_auction.py (challenge-gate a
|
|
502
|
+
# reverse auction), poc_reputation.py (local trust score), and
|
|
503
|
+
# lightning_settle.py (real HTLC settlement) into the real download path —
|
|
504
|
+
# none of them were reachable from `download()` before this, which meant
|
|
505
|
+
# `discover` -> `download` would silently trust the first host found, for
|
|
506
|
+
# free, with zero possession check.
|
|
507
|
+
#
|
|
508
|
+
# Scoped honestly, not silently: this uses sample-FETCH + Merkle-proof
|
|
509
|
+
# verification (poc_challenge_auction.py Part 1's mechanism) to prove a
|
|
510
|
+
# host holds real chunks, not the nonce-salted timing challenge from Part
|
|
511
|
+
# 2 — that one specifically detects a RELAY masquerading as a holder, but
|
|
512
|
+
# needs ground-truth bytes the verifier already trusts, which a first-time
|
|
513
|
+
# downloader doesn't have yet (that's the whole point of downloading).
|
|
514
|
+
# Sample-verifying a few chunks via FETCH is what a fresh client can
|
|
515
|
+
# actually do independently, since LEAVES is already Merkle-root-checked
|
|
516
|
+
# against the host's own advertised sha256.
|
|
517
|
+
|
|
518
|
+
def sample_challenge(conn, leaves, k=3):
|
|
519
|
+
"""Fetch k random chunks over the given (already-open) connection and
|
|
520
|
+
verify each against the (already Merkle-verified) leaves list —
|
|
521
|
+
proves *this specific host* truly holds real chunks, not just that
|
|
522
|
+
someone somewhere does."""
|
|
523
|
+
n = len(leaves)
|
|
524
|
+
indices = random.sample(range(n), min(k, n))
|
|
525
|
+
latencies = []
|
|
526
|
+
for idx in indices:
|
|
527
|
+
t0 = time.perf_counter()
|
|
528
|
+
try:
|
|
529
|
+
resp = conn.request(f'FETCH {idx}')
|
|
530
|
+
except OSError:
|
|
531
|
+
return False, latencies
|
|
532
|
+
latencies.append((time.perf_counter() - t0) * 1000)
|
|
533
|
+
if not resp.startswith('DATA '):
|
|
534
|
+
return False, latencies
|
|
535
|
+
data = base64.b64decode(resp[5:])
|
|
536
|
+
if hashlib.sha256(data).hexdigest() != leaves[idx]:
|
|
537
|
+
return False, latencies
|
|
538
|
+
return True, latencies
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def get_price(conn):
|
|
542
|
+
try:
|
|
543
|
+
resp = conn.request('PRICE')
|
|
544
|
+
except OSError:
|
|
545
|
+
return 0
|
|
546
|
+
if resp.startswith('PRICE '):
|
|
547
|
+
try:
|
|
548
|
+
return int(resp[6:])
|
|
549
|
+
except ValueError:
|
|
550
|
+
return 0
|
|
551
|
+
return 0 # host doesn't implement PRICE — treat as free rather than failing
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def discover_hosts_for(relay_urls, content_hash):
|
|
555
|
+
"""Every host that published a matching content_hash — real multi-host
|
|
556
|
+
resolution (discover() already dedupes by event, not by content, so
|
|
557
|
+
two publishers of the same file both show up here)."""
|
|
558
|
+
return [p for p in discover(relay_urls) if p['content_hash'].startswith(content_hash)]
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def build_trust_graph(subscribe_events, root_pubkey, max_hops=3, decay=0.5):
|
|
562
|
+
"""Transitive trust, computed here instead of adopting PGP's standard
|
|
563
|
+
to get it: BFS outward from root_pubkey through real signed subscribe
|
|
564
|
+
events (each one a real trust edge — 'I subscribe to X' = 'I trust
|
|
565
|
+
X'). Trust decays per hop, same shape as PGP's marginal-vs-full
|
|
566
|
+
distinction (a direct subscribe counts more than a friend-of-a-
|
|
567
|
+
friend), computed on the Ed25519 events already in place rather than
|
|
568
|
+
needing the OpenPGP format at all.
|
|
569
|
+
|
|
570
|
+
Takes the *shortest* path to each reachable pubkey (first time BFS
|
|
571
|
+
reaches it), not the sum across every path that reaches it — summing
|
|
572
|
+
would let a sybil ring inflate a target's trust just by adding more
|
|
573
|
+
low-value paths to it, which defeats the point of a hop-decayed graph
|
|
574
|
+
in the first place."""
|
|
575
|
+
edges = {}
|
|
576
|
+
for e in subscribe_events:
|
|
577
|
+
p = e['payload']
|
|
578
|
+
edges.setdefault(p['signer_pubkey'], set()).add(p['target_pubkey'])
|
|
579
|
+
|
|
580
|
+
trust = {}
|
|
581
|
+
frontier = [root_pubkey]
|
|
582
|
+
seen = {root_pubkey}
|
|
583
|
+
hop = 0
|
|
584
|
+
while frontier and hop < max_hops:
|
|
585
|
+
hop += 1
|
|
586
|
+
weight = decay ** hop
|
|
587
|
+
next_frontier = []
|
|
588
|
+
for node in frontier:
|
|
589
|
+
for target in edges.get(node, ()):
|
|
590
|
+
if target not in seen:
|
|
591
|
+
seen.add(target)
|
|
592
|
+
trust[target] = weight
|
|
593
|
+
next_frontier.append(target)
|
|
594
|
+
frontier = next_frontier
|
|
595
|
+
return trust
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def select_host(candidates, k=3, reputation=None, trust_in_signers=None):
|
|
599
|
+
"""Gate on real possession, then rank survivors by reputation then
|
|
600
|
+
price — same 'challenge gates the auction' shape as
|
|
601
|
+
poc_challenge_auction.py's naive-vs-gated comparison. trust_in_signers
|
|
602
|
+
(from build_trust_graph) lets a candidate with zero *direct* history
|
|
603
|
+
still score above 0 if someone in the caller's transitive trust graph
|
|
604
|
+
has attested to it."""
|
|
605
|
+
from ott import merkle_root
|
|
606
|
+
|
|
607
|
+
scored = []
|
|
608
|
+
for c in candidates:
|
|
609
|
+
tunnel = _parse_tunnel(c.get('tunnel'))
|
|
610
|
+
try:
|
|
611
|
+
with open_connection(c['host'], tunnel=tunnel, content_hash=c['content_hash']) as conn:
|
|
612
|
+
if not tunnel:
|
|
613
|
+
sel = conn.request(f"SELECT {c['content_hash']}")
|
|
614
|
+
if sel != 'OK':
|
|
615
|
+
print(f" x {c['host']}: SELECT rejected ({sel}) — skipping")
|
|
616
|
+
continue
|
|
617
|
+
leaves = json.loads(conn.request('LEAVES'))
|
|
618
|
+
info = json.loads(conn.request('INFO'))
|
|
619
|
+
if merkle_root(leaves) != info['sha256'] or info['sha256'] != c['content_hash']:
|
|
620
|
+
print(f" x {c['host']}: advertised content doesn't match its own LEAVES/INFO — skipping")
|
|
621
|
+
continue
|
|
622
|
+
passed, latencies = sample_challenge(conn, leaves, k=k)
|
|
623
|
+
if not passed:
|
|
624
|
+
print(f" x {c['host']}: failed possession challenge ({k} chunks sampled) — skipping")
|
|
625
|
+
continue
|
|
626
|
+
price = get_price(conn)
|
|
627
|
+
except OSError as e:
|
|
628
|
+
print(f" x {c['host']}: unreachable ({e})")
|
|
629
|
+
continue
|
|
630
|
+
rep_score, rep_why = (reputation.trust_score(c['signer_pubkey'], trust_in_signers)
|
|
631
|
+
if reputation else (0.5, 'no reputation store'))
|
|
632
|
+
avg_latency = sum(latencies) / len(latencies) if latencies else 0.0
|
|
633
|
+
print(f" + {c['host']}: possession verified ({k}/{k} chunks), price={price} sat, "
|
|
634
|
+
f"reputation={rep_score:.2f} ({rep_why}), avg_latency={avg_latency:.1f}ms")
|
|
635
|
+
scored.append({'candidate': c, 'info': info, 'price': price,
|
|
636
|
+
'reputation': rep_score, 'avg_latency_ms': avg_latency})
|
|
637
|
+
|
|
638
|
+
if not scored:
|
|
639
|
+
return None
|
|
640
|
+
scored.sort(key=lambda s: (-s['reputation'], s['price'])) # highest trust first, then cheapest
|
|
641
|
+
return scored[0]
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def fetch_verified(relay_urls, event_type):
|
|
645
|
+
"""Fetch+verify every event of a type across relays, deduped by event
|
|
646
|
+
id — same merge pattern discover() uses, generalized so subscribe/
|
|
647
|
+
attestation gossip go through the same real signature-checking path."""
|
|
648
|
+
seen = {}
|
|
649
|
+
for relay_url in relay_urls:
|
|
650
|
+
events = fetch_events(relay_url, event_type)
|
|
651
|
+
if events is None:
|
|
652
|
+
continue
|
|
653
|
+
for e in events:
|
|
654
|
+
ok, _ = verify_attestation(e)
|
|
655
|
+
if ok:
|
|
656
|
+
seen[attestation_id(e)] = e
|
|
657
|
+
return list(seen.values())
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def download_with_auction(content_hash, relay_urls, out_path=None, k=3, use_lightning=False,
|
|
661
|
+
trust_hops=3, trust_decay=0.5, on_progress=None):
|
|
662
|
+
"""The real end-to-end path: resolve every host claiming to have this
|
|
663
|
+
content, challenge-gate them, auction among survivors — weighted by
|
|
664
|
+
reputation built from real transitive trust (your own subscribes, plus
|
|
665
|
+
your subscribes' subscribes, decayed per hop) and real attestations
|
|
666
|
+
gossiped from relays, not just your own direct history — optionally
|
|
667
|
+
pay the winner over a real Lightning HTLC, download, record the
|
|
668
|
+
outcome locally AND publish it so others can benefit transitively too."""
|
|
669
|
+
from poc_reputation import ReputationStore
|
|
670
|
+
|
|
671
|
+
identity = load_or_create_identity()
|
|
672
|
+
candidates = discover_hosts_for(relay_urls, content_hash)
|
|
673
|
+
if not candidates:
|
|
674
|
+
sys.exit(f"no hosts found publishing content matching {content_hash}")
|
|
675
|
+
print(f"found {len(candidates)} candidate host(s) for {content_hash[:16]}...")
|
|
676
|
+
|
|
677
|
+
subscribe_events = fetch_verified(relay_urls, 'subscribe')
|
|
678
|
+
trust_graph = build_trust_graph(subscribe_events, identity.pubkey_hex(),
|
|
679
|
+
max_hops=trust_hops, decay=trust_decay)
|
|
680
|
+
print(f"trust graph: {len(trust_graph)} pubkey(s) reachable within {trust_hops} hop(s) "
|
|
681
|
+
f"of your own subscribes")
|
|
682
|
+
|
|
683
|
+
reputation = ReputationStore(os.path.expanduser('~/.weed_reputation.json'))
|
|
684
|
+
attestation_events = fetch_verified(relay_urls, 'attestation')
|
|
685
|
+
added = 0
|
|
686
|
+
for e in attestation_events:
|
|
687
|
+
ok, _ = reputation.add_attestation(e)
|
|
688
|
+
added += ok
|
|
689
|
+
if attestation_events:
|
|
690
|
+
print(f"pulled {added}/{len(attestation_events)} real attestation(s) from relays "
|
|
691
|
+
f"(others' vouches, weighted by your trust in whoever signed them)")
|
|
692
|
+
|
|
693
|
+
winner = select_host(candidates, k=k, reputation=reputation, trust_in_signers=trust_graph)
|
|
694
|
+
if winner is None:
|
|
695
|
+
sys.exit("no candidate host passed the possession challenge — "
|
|
696
|
+
"refusing to download from an unverified source")
|
|
697
|
+
|
|
698
|
+
c = winner['candidate']
|
|
699
|
+
print(f"selected {c['host']} — price {winner['price']} sat, "
|
|
700
|
+
f"reputation {winner['reputation']:.2f}, {winner['avg_latency_ms']:.1f}ms avg")
|
|
701
|
+
|
|
702
|
+
if winner['price'] > 0:
|
|
703
|
+
if use_lightning:
|
|
704
|
+
import lightning_settle
|
|
705
|
+
result = lightning_settle.settle(
|
|
706
|
+
winner['price'], f"download {content_hash[:16]}... from {c['host']}")
|
|
707
|
+
print(f"paid via real Lightning HTLC: {result['amount_sat']} sat, "
|
|
708
|
+
f"preimage {result['preimage'][:12]}... verified against "
|
|
709
|
+
f"payment_hash {result['payment_hash'][:12]}...")
|
|
710
|
+
else:
|
|
711
|
+
print(f" price is {winner['price']} sat but --lightning not given "
|
|
712
|
+
f"— downloading anyway, unpaid (no enforcement in this demo)")
|
|
713
|
+
|
|
714
|
+
path = download(c['host'], out_path or c['title'], tunnel=_parse_tunnel(c.get('tunnel')),
|
|
715
|
+
content_hash=c['content_hash'], on_progress=on_progress)
|
|
716
|
+
|
|
717
|
+
reputation.record_direct(c['signer_pubkey'], passes=1, fails=0,
|
|
718
|
+
avg_latency_ms=winner['avg_latency_ms'])
|
|
719
|
+
reputation.save()
|
|
720
|
+
print(f"recorded this download in local reputation store "
|
|
721
|
+
f"(~/.weed_reputation.json) for {c['signer_pubkey'][:12]}...")
|
|
722
|
+
|
|
723
|
+
attestation = identity.sign_event('attestation', peer_pubkey=c['signer_pubkey'],
|
|
724
|
+
passes=1, fails=0, avg_latency_ms=winner['avg_latency_ms'], k=k)
|
|
725
|
+
for relay_url in relay_urls:
|
|
726
|
+
post_event(relay_url, attestation)
|
|
727
|
+
print(f"published this outcome as a real attestation — anyone who trusts you "
|
|
728
|
+
f"(directly or transitively) now benefits from it too, without dealing with "
|
|
729
|
+
f"{c['signer_pubkey'][:12]}... themselves first")
|
|
730
|
+
return path
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
# ── discovery + social signals ──────────────────────────────────────────
|
|
734
|
+
|
|
735
|
+
def post_event(relay_url, event):
|
|
736
|
+
req = urllib.request.Request(
|
|
737
|
+
f'{relay_url}/event', data=json.dumps(event).encode(),
|
|
738
|
+
headers={'Content-Type': 'application/json'}, method='POST')
|
|
739
|
+
try:
|
|
740
|
+
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
741
|
+
return json.loads(resp.read())
|
|
742
|
+
except urllib.error.HTTPError as e:
|
|
743
|
+
return json.loads(e.read())
|
|
744
|
+
except (urllib.error.URLError, ConnectionRefusedError) as e:
|
|
745
|
+
# download_with_auction posts the post-download attestation to every
|
|
746
|
+
# relay in relay_urls, which by default includes 127.0.0.1:9101
|
|
747
|
+
# whether or not anything is actually listening there (same default
|
|
748
|
+
# fetch_events already tolerates) — an unreachable relay here used to
|
|
749
|
+
# take down the whole download with an uncaught traceback *after* the
|
|
750
|
+
# file was already correctly written and reputation already recorded
|
|
751
|
+
return {'ok': False, 'error': str(e)}
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def fetch_events(relay_url, event_type=None):
|
|
755
|
+
url = f'{relay_url}/events' + (f'?type={event_type}' if event_type else '')
|
|
756
|
+
try:
|
|
757
|
+
with urllib.request.urlopen(url, timeout=5) as resp:
|
|
758
|
+
return json.loads(resp.read())
|
|
759
|
+
except (urllib.error.URLError, ConnectionRefusedError):
|
|
760
|
+
return None
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def publish(identity, relay_url, content_hash, title, host_addr, tunnel=None):
|
|
764
|
+
"""tunnel, if given, is 'relay_host:relay_port' for a tunnel_relay.py
|
|
765
|
+
instance this host registered with — additive and backward compatible,
|
|
766
|
+
same as the optional PRICE wire verb: an event without it just means
|
|
767
|
+
'connect directly to host_addr', same as before this field existed."""
|
|
768
|
+
event = identity.sign_event('publish', content_hash=content_hash, title=title,
|
|
769
|
+
host=host_addr, tunnel=tunnel)
|
|
770
|
+
return post_event(relay_url, event)
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def discover(relay_urls):
|
|
774
|
+
seen = {}
|
|
775
|
+
for relay_url in relay_urls:
|
|
776
|
+
events = fetch_events(relay_url, 'publish')
|
|
777
|
+
if events is None:
|
|
778
|
+
print(f" {relay_url}: unreachable, skipped")
|
|
779
|
+
continue
|
|
780
|
+
for e in events:
|
|
781
|
+
ok, _ = verify_attestation(e)
|
|
782
|
+
if ok:
|
|
783
|
+
seen[attestation_id(e)] = e['payload']
|
|
784
|
+
return list(seen.values())
|