-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathnode.py
More file actions
1810 lines (1617 loc) · 87.8 KB
/
Copy pathnode.py
File metadata and controls
1810 lines (1617 loc) · 87.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
The missing integration piece: a real node that hosts, discovers, and
downloads — not another isolated demo. Wires together pieces already built
tonight rather than reinventing them:
- real chunk-serve protocol, extended from poc_network_challenge.py's
holder (adds INFO/LEAVES so a downloader can learn the archive's shape
before fetching)
- real ott archives (same .ott/ format poc_real_archive_challenge.py
read from, via `pip install btcvm`)
- real signed events (Identity/sign_event from poc_reputation.py), now
persisted to disk instead of regenerated fresh every run — a real node
needs a stable identity across invocations
- the same discovery relay protocol from discovery_relay.py
What's actually new here, not just wired: a real client-driven download —
every previous script verified chunks locally or over a network, none of
them reassembled a full file from a remote peer onto disk before this.
"""
import base64
import concurrent.futures
import hashlib
import json
import os
import random
import socket
import ssl
import subprocess
import sys
import threading
import time
import urllib.request
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from poc_reputation import Identity, verify_attestation, attestation_id
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives import serialization
IDENTITY_PATH = os.path.expanduser('~/.weed_identity.key')
IDENTITY_ARMOR_HEADER = '-----BEGIN WEED IDENTITY KEY-----'
IDENTITY_ARMOR_FOOTER = '-----END WEED IDENTITY KEY-----'
TAGLINE = 'we do in 4 what others do in 5'
def weed_version():
"""Installed package version (pyproject.toml's source of truth), with
fallbacks for the two other real ways this runs: straight from a git
checkout without `pip install -e .` (read pyproject.toml directly —
Dockerfile.node copies it in for exactly this, since it copies loose
.py files rather than pip-installing the package), or neither file
present at all (genuinely no version info available)."""
try:
from importlib.metadata import version, PackageNotFoundError
try:
return version('weed-cli')
except PackageNotFoundError:
pass
except ImportError:
pass
pyproject_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'pyproject.toml')
try:
with open(pyproject_path) as f:
for line in f:
line = line.strip()
if line.startswith('version'):
return line.split('=', 1)[1].strip().strip('"\'')
except (OSError, IndexError):
pass
return '0.0.0-dev'
def _git_commit_hash():
"""Short commit hash of whatever checkout this node.py is actually
running from, if any. A bare version number only changes on a
deliberate release/bump — it says nothing about which commit's fixes
are actually loaded between releases, which is exactly the ambiguity
behind a real debugging session: two checkouts reporting the same
version, one of them missing a fix the other had. None for anything
that isn't a git checkout at all (installed from a built wheel/sdist,
no .git present) — a version number is all there is to go on there.
$WEED_GIT_COMMIT checked first — Dockerfile.node has no .git directory
at all (never copied in, on purpose: bloats the image and ships the
full history for no reason), so a container build has no way to
answer this live. docker-compose.node.yml passes the *host's* commit
hash in as a build arg at image-build time instead, baked in as this
env var, same intent as the live git lookup below just computed once,
earlier, somewhere that actually has the repo.
`git rev-parse` walks *up* parent directories looking for a .git —
a real `pip install .` (not `-e`) copies this file into site-packages,
which usually isn't a checkout at all, but if it happens to be nested
anywhere under some unrelated git-tracked ancestor directory (a
dotfiles repo, a pyenv install tracked in git, anything above it),
this would otherwise silently report *that* repo's own unrelated
commit — worse than showing nothing, since it looks plausible. Only
trust the hash once --show-toplevel confirms this file's own
directory really is that repo's root."""
env_commit = os.environ.get('WEED_GIT_COMMIT')
if env_commit:
return env_commit
repo_dir = os.path.dirname(os.path.abspath(__file__))
try:
toplevel = subprocess.run(['git', 'rev-parse', '--show-toplevel'], cwd=repo_dir,
capture_output=True, text=True, timeout=2)
if toplevel.returncode != 0:
return None
if os.path.realpath(toplevel.stdout.strip()) != os.path.realpath(repo_dir):
return None
result = subprocess.run(['git', 'rev-parse', '--short', 'HEAD'], cwd=repo_dir,
capture_output=True, text=True, timeout=2)
if result.returncode == 0:
return result.stdout.strip()
except (OSError, subprocess.TimeoutExpired):
pass
return None
def weed_banner():
commit = _git_commit_hash()
commit_note = f' ({commit})' if commit else ''
return f'weed v{weed_version()}{commit_note} — {TAGLINE}'
def _armor_identity(raw_bytes):
"""Base64 text with a header/footer — NOT real OpenPGP armor (no
CRC24, no packet framing), and not encryption: this only changes how
the same private key bytes are encoded on disk, from opaque binary
(`file` calls it "data") to something readable/diffable/copy-
pasteable. A weed-specific label on purpose, not a PGP one — this
repo already decided against adopting the real OpenPGP format (see
README's "Transitive trust" section), so nothing here should look
like it's actually PGP-compatible."""
b64 = base64.b64encode(raw_bytes).decode()
lines = [b64[i:i + 64] for i in range(0, len(b64), 64)]
return IDENTITY_ARMOR_HEADER + '\n' + '\n'.join(lines) + '\n' + IDENTITY_ARMOR_FOOTER + '\n'
def _dearmor_identity(text):
lines = text.strip().splitlines()
if len(lines) < 2 or lines[0].strip() != IDENTITY_ARMOR_HEADER \
or lines[-1].strip() != IDENTITY_ARMOR_FOOTER:
raise ValueError('not a weed-armored identity key')
return base64.b64decode(''.join(lines[1:-1]))
def load_or_create_identity():
"""A real node needs a stable pubkey across runs — regenerating fresh
every invocation (like every other script tonight) would mean nobody
could ever build reputation or subscribe to a host's key for real."""
identity = Identity('local')
if os.path.exists(IDENTITY_PATH):
with open(IDENTITY_PATH, 'rb') as f:
file_bytes = f.read()
try:
key_bytes = _dearmor_identity(file_bytes.decode())
except (UnicodeDecodeError, ValueError):
# pre-armor identity file (raw 32 bytes, no wrapper) — same key,
# just not encoded yet. Re-armor it in place so this only ever
# has to happen once; the underlying private key bytes (and
# therefore the pubkey) are untouched.
key_bytes = file_bytes
with open(IDENTITY_PATH, 'w') as f:
f.write(_armor_identity(key_bytes))
os.chmod(IDENTITY_PATH, 0o600)
identity._priv = Ed25519PrivateKey.from_private_bytes(key_bytes)
identity.pub = identity._priv.public_key()
else:
key_bytes = identity._priv.private_bytes(
serialization.Encoding.Raw, serialization.PrivateFormat.Raw,
serialization.NoEncryption())
with open(IDENTITY_PATH, 'w') as f:
f.write(_armor_identity(key_bytes))
os.chmod(IDENTITY_PATH, 0o600)
return identity
# ── wire protocol — text command line, JSON/base64 bodies where needed ─────
def recv_line(sock):
buf = b''
while not buf.endswith(b'\n'):
chunk = sock.recv(65536)
if not chunk:
break
buf += chunk
return buf.decode().strip()
class LineReader:
"""Buffered line reader for a connection that can receive more than
one newline-terminated message per recv() — recv_line above assumes
exactly one line arrives per call, true everywhere else in this
protocol (strict request/response, never pipelined) but not true for
the tunnel control channel: if two downloaders CONNECT to
tunnel_relay.py around the same time, both NEWSTREAM messages can
legitimately land in a single recv(), and recv_line would silently
fold both into one malformed line and drop the second one."""
def __init__(self, sock):
self.sock = sock
self.buf = b''
def readline(self):
while b'\n' not in self.buf:
chunk = self.sock.recv(4096)
if not chunk:
line, self.buf = self.buf, b''
return line.decode().strip()
self.buf += chunk
line, self.buf = self.buf.split(b'\n', 1)
return line.decode().strip()
def entry_rel_path(entry):
"""Where the file sits inside its archive: orig_path (ott records it
relative to the archive root, so a file added from a subdirectory
keeps that subdirectory) when it's a plain relative path, else the
bare name. Anything that could climb out of the archive (absolute,
or a '..' segment, which ott's own older entries could carry before
orig_path was anchored to the root) falls back to the name -- this
is joined onto archive_dir by resolve_file_path."""
raw = (entry.get('orig_path') or '').replace('\\', '/')
rel = raw.strip('/')
if not rel or raw.startswith('/') or os.path.isabs(raw) or '..' in rel.split('/'):
return entry['name']
return rel
def _entry_matches(entry, file_name):
"""--file / file_name matches the bare name or the archive-relative
path, so `--file Live/set.mkv` picks one of two files that share a
name in different subdirectories."""
wanted = file_name.replace('\\', '/').strip('/')
return entry['name'] == wanted or entry_rel_path(entry) == wanted
def find_manifest_entry(archive_dir, file_name=None):
archive_dir = os.path.expanduser(archive_dir) # os.path.join never expands ~, it stays literal
manifest_path = os.path.join(archive_dir, '.ott', 'manifest.jsonl')
if not os.path.exists(manifest_path):
sys.exit(f"no .ott/manifest.jsonl in {archive_dir} — archive a file with ott first")
with open(manifest_path) as f:
entries = [json.loads(line) for line in f if line.strip()]
if file_name:
entries = [e for e in entries if _entry_matches(e, file_name)]
if not entries:
sys.exit(f"no archived file found in {archive_dir}" + (f" matching {file_name}" if file_name else ""))
return entries[-1] # last-write-wins, same convention ott itself uses
def load_manifest_entries(archive_dir, file_name=None):
"""Every distinct file in the archive, not just one — find_manifest_entry
collapses to a single entries[-1], which is exactly why `host <dir>` with
no --file only ever served the single most-recently-added file out of a
45-video archive. Dedupes by archive-relative path (last-write-wins,
same convention), so subdirectories are fine and a name can repeat
across them.
Only 'video' and 'audio' entries are returned. Hosting depends on
chunk data (load_leaves) and per-chunk byte math (entry['chunk_size']),
and ott only ever writes either for video/audio-type entries (see its
own cmd_add) — everything else (photos, or any file whose extension
ott's is_video()/is_audio() don't recognize) has chunk_size: None and
no .ott/chunks/<hash>.json at all. Filtering here, the one function
every hosting path (weed.py, shell.py, web_ui.py) goes through, means
one non-chunked file sitting in an archive_dir no longer poison-pills
hosting everything else in it with 'no chunks file at ...'."""
archive_dir = os.path.expanduser(archive_dir)
manifest_path = os.path.join(archive_dir, '.ott', 'manifest.jsonl')
if not os.path.exists(manifest_path):
sys.exit(f"no .ott/manifest.jsonl in {archive_dir} — archive a file with ott first")
with open(manifest_path) as f:
raw = [json.loads(line) for line in f if line.strip()]
# Deduplicate by sha256 first (last-write-wins), matching ott's own
# load_manifest convention. A rename appends a new entry with an
# updated name/last_path under the same sha256; without this step the
# old entry (wrong name, missing path) survives as a "different file"
# and causes a spurious "not found on disk" error at startup.
by_hash: dict = {}
for e in raw:
by_hash[e['sha256']] = e
deduped = list(by_hash.values())
if file_name:
deduped = [e for e in deduped if _entry_matches(e, file_name)]
# then by where the file sits in the archive, not its bare name: two
# files called the same thing in different subdirectories are two
# files (Ryan: "can we support subdirectories?")
by_path = {}
for e in deduped:
by_path[entry_rel_path(e)] = e
all_entries = list(by_path.values())
entries = [e for e in all_entries if e.get('type') in ('video', 'audio')]
if not entries:
if all_entries:
n = len(all_entries)
sys.exit(f"no hostable video/audio file found in {archive_dir}" +
(f" matching {file_name}" if file_name else "") +
f" — found {n} other entr{'y' if n == 1 else 'ies'} "
f"(only video/audio files can be hosted; see ott's is_video()/is_audio())")
sys.exit(f"no archived file found in {archive_dir}" + (f" matching {file_name}" if file_name else ""))
return entries
def load_leaves(archive_dir, root_hash):
archive_dir = os.path.expanduser(archive_dir)
chunks_path = os.path.join(archive_dir, '.ott', 'chunks', f'{root_hash}.json')
if not os.path.exists(chunks_path):
sys.exit(f"no chunks file at {chunks_path} — the manifest entry for this hash has no "
f"matching chunk data in this archive_dir (stale/mismatched .ott/ state, or "
f"this isn't the archive_dir that file was actually added from)")
with open(chunks_path) as f:
return json.load(f)
def resolve_file_path(entry, archive_dir):
"""last_path is recorded at archive time (see ott's own manifest
writer) and is an absolute path on whatever machine ran `ott add` —
trusting it unconditionally breaks the moment archive_dir is the same
content mounted somewhere else (a Docker bind mount at /share instead
of the original /home/user/share it was archived from, a synced
folder on another machine, ...), since it's still non-empty and short-
circuits the `or` before archive_dir is ever considered. Falling back
whenever last_path doesn't actually exist trusts the directory the
caller explicitly told us to look in over a possibly-stale hint."""
last_path = entry.get('last_path')
if last_path and os.path.exists(last_path):
return last_path
# the file's place inside the archive first -- a file archived from
# a subdirectory lives there, not at the archive root -- then the
# bare name at the root for entries that predate orig_path
rel = entry_rel_path(entry)
candidate = os.path.join(archive_dir, *rel.split('/'))
if os.path.exists(candidate):
return candidate
# A file moved into a subfolder and fixed up with `ott fix-renames`
# has a fresh last_path but its old orig_path (only `ott reindex`
# re-anchors that), and from inside a container that last_path is
# the host machine's. Its tail is still where the file sits under
# the archive, so try the trailing segments, longest first (Ryan:
# "I ran `ott fix-renames` but it still shows them all as skipped").
if last_path:
parts = last_path.replace('\\', '/').strip('/').split('/')
for k in range(len(parts) - 1, 0, -1):
tail = os.path.join(archive_dir, *parts[-k:])
if os.path.exists(tail):
return tail
if rel != entry['name'] and os.path.exists(os.path.join(archive_dir, entry['name'])):
return os.path.join(archive_dir, entry['name'])
# Last resort: the file was moved inside the archive and nothing in
# its manifest entry says where (fix-renames wasn't run, or ran
# against another copy). Find it by name anywhere under archive_dir,
# the same size when there's a choice (Ryan: "I have files in
# ./share/folder, but they're not showing up anymore").
found = _archive_file_index(archive_dir).get(entry['name']) or []
if found:
same_size = [p for p in found if entry.get('size') is None or _size_of(p) == entry.get('size')]
pick = same_size or found
if len(pick) == 1 or same_size:
return pick[0]
return candidate
def _size_of(path):
try:
return os.path.getsize(path)
except OSError:
return None
_archive_index_cache = {} # abspath(archive_dir) -> (built_at, {name: [paths]})
ARCHIVE_INDEX_TTL = 30
def _archive_file_index(archive_dir):
"""Every file under archive_dir by bare name (the archive's own .ott/
and hidden directories skipped), rebuilt at most every
ARCHIVE_INDEX_TTL seconds, so resolving a whole archive of moved files
costs one walk rather than one per file."""
key = os.path.abspath(archive_dir)
hit = _archive_index_cache.get(key)
if hit and time.time() - hit[0] < ARCHIVE_INDEX_TTL:
return hit[1]
index = {}
for dirpath, dirs, files in os.walk(key):
dirs[:] = sorted(d for d in dirs if not d.startswith('.'))
for name in files:
index.setdefault(name, []).append(os.path.join(dirpath, name))
_archive_index_cache[key] = (time.time(), index)
return index
def entry_archive_rel(entry, archive_dir):
"""Where the file actually sits under archive_dir, as a '/'-joined
relative path ('Live/Paris 1993/set.mkv') -- what gets announced as
the folder and listed on a host. From the resolved file when it's
inside archive_dir (so a moved file reports its new folder even while
its manifest entry still says the old one), else the entry's own."""
archive_dir = os.path.expanduser(archive_dir)
path = resolve_file_path(entry, archive_dir)
try:
rel = os.path.relpath(os.path.abspath(path), os.path.abspath(archive_dir))
except ValueError: # a different drive on Windows
return entry_rel_path(entry)
if rel.startswith('..'):
return entry_rel_path(entry)
return rel.replace(os.sep, '/')
def _graceful_close(sock):
"""Plain sock.close() on an SSL-wrapped socket tears down the TCP
connection without ever sending a TLS close_notify -- fine for the
plaintext direct-connect path, but every tunneled connection here is
TLS all the way to Fly's edge (fly.tunnel-relay.toml terminates TLS
there, handlers = ["tls"]), and Fly's proxy logs that abrupt cutoff as
'fly-proxy-p2p/tls/tcp-backhaul: unexpected end of file' even though
the app-level protocol already got every byte it needed by then.
unwrap() sends the close_notify so the edge sees a clean shutdown
instead of a truncation."""
if isinstance(sock, ssl.SSLSocket):
try:
# unwrap() blocks waiting for the peer's own close_notify --
# cap that wait so a peer that's already gone can't leak this
# thread forever, same reasoning as the timeouts already used
# for connect_via_tunnel/_connect_tunnel_socket
sock.settimeout(5)
sock = sock.unwrap()
except (OSError, ssl.SSLError, ValueError):
pass
sock.close()
def serve_session(conn, entries_by_hash, default_hash, price, ln_node=None):
"""Handle every command on one connection, not just one — a download
needs INFO + LEAVES + one FETCH per chunk (thousands, for a real
archive), and reconnecting per command is what makes a tunneled
connection (see tunnel_relay.py) pay a full relay rendezvous per
chunk instead of once per session. Shared by the direct accept() loop
below and by run_host_tunnel's per-stream data connections.
entries_by_hash lets one server (one port) hold more than one file —
a downloader picks which via SELECT <content_hash_or_prefix> before
anything else. default_hash (set only when the host has exactly one
file) means a single-file host never needs SELECT at all, so the
`download --from host:port` discovery-skipping escape hatch and the
tunnel path (already scoped to one file before this function runs)
keep working unchanged.
ln_node names which demo LND identity (see lightning_settle.NODES) this
host settles through — None means this host just never answers INVOICE
with anything payable, same graceful-degradation shape PRICE already
has for a host/client that doesn't know about it."""
selected = default_hash
try:
while True:
line = recv_line(conn)
if not line:
break
parts = line.split()
if not parts:
continue
if parts[0] == 'SELECT':
match = next((h for h in entries_by_hash if len(parts) > 1 and h.startswith(parts[1])), None)
if match:
selected = match
conn.sendall(b'OK\n')
else:
conn.sendall(b'ERR unknown content hash\n')
continue
if selected is None:
conn.sendall(b'ERR this host serves more than one file '
b'- send SELECT <content_hash> first\n')
continue
entry, leaves, file_path = entries_by_hash[selected]
if parts[0] == 'INFO':
conn.sendall((json.dumps({
'name': entry['name'], 'sha256': entry['sha256'], 'size': entry['size'],
'n_chunks': entry['n_chunks'], 'chunk_size': entry['chunk_size'],
}) + '\n').encode())
elif parts[0] == 'LEAVES':
conn.sendall((json.dumps(leaves) + '\n').encode())
elif parts[0] == 'CHALLENGE':
idx, nonce_hex = int(parts[1]), parts[2]
with open(file_path, 'rb') as f:
f.seek(idx * entry['chunk_size'])
data = f.read(entry['chunk_size'])
h = hashlib.sha256(data + bytes.fromhex(nonce_hex)).hexdigest()
conn.sendall(f'HASH {h}\n'.encode())
elif parts[0] == 'FETCH':
idx = int(parts[1])
with open(file_path, 'rb') as f:
f.seek(idx * entry['chunk_size'])
data = f.read(entry['chunk_size'])
conn.sendall((f'DATA {base64.b64encode(data).decode()}\n').encode())
elif parts[0] == 'PRICE':
conn.sendall(f'PRICE {price}\n'.encode())
elif parts[0] == 'INVOICE':
if not ln_node or price <= 0:
conn.sendall(b'ERR no payable invoice for this host/price\n')
else:
import lightning_settle
try:
invoice = lightning_settle.create_invoice(ln_node, price, entry['sha256'][:16])
conn.sendall((json.dumps(invoice) + '\n').encode())
except lightning_settle.SettlementError as e:
conn.sendall(f'ERR invoice creation failed: {e}\n'.encode())
finally:
_graceful_close(conn)
def bind_host_port(port, bind_host='0.0.0.0'):
"""Split out of run_host_server so a caller (see web_ui.py's
_run_host_job) can bind the real, permanent listening socket early —
right where a port-collision needs to fail fast, before announcing
anything — and hand that exact socket to run_host_server later
instead of it binding a second, separate one. A bind-then-close probe
followed by a *second*, later bind of the same port isn't atomic
across two hosts starting concurrently: both can pass the probe
before either does the real bind. Binding once, early, and reusing
the same socket removes that race entirely instead of narrowing it."""
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind((bind_host, port))
return srv
def _manifest_mtime(archive_dir):
manifest_path = os.path.join(archive_dir, '.ott', 'manifest.jsonl')
try:
return os.path.getmtime(manifest_path)
except OSError:
return None
def _load_hostable_entries(archive_dir, file_name):
"""Every entry that can actually be served: manifest entry, its chunk
leaves, and the file on disk. Hosting a whole directory skips an
entry whose file has since been deleted or moved (with a warning on
stderr) rather than refusing to host anything -- one missing video
used to poison-pill the other forty-four beside it, and the web UI
then forgot the host config as unrecoverable. Asking for one file by
name that isn't there is still an error: that's the one thing the
caller explicitly wanted."""
entries = load_manifest_entries(archive_dir, file_name)
entries_by_hash = {}
kept = []
for entry in entries:
file_path = resolve_file_path(entry, archive_dir)
if not os.path.exists(file_path):
if file_name is not None or len(entries) == 1:
sys.exit(f"archived file not found on disk at {file_path}")
print(f"[host] skipping {entry.get('name')!r}: archived file not found on disk at {file_path} "
f"(nor anywhere under {archive_dir} by that name)", file=sys.stderr)
continue
leaves = load_leaves(archive_dir, entry['sha256'])
entries_by_hash[entry['sha256']] = (entry, leaves, file_path)
kept.append(entry)
if not entries_by_hash:
sys.exit(f"no archived file found in {archive_dir} is still on disk -- nothing to host")
return kept, entries_by_hash
def run_host_server(archive_dir, file_name, port, bind_host='0.0.0.0', quiet=False, price=0,
ln_node=None, sock=None, stop_event=None):
"""stop_event (optional) is how a caller like web_ui.py's
_handle_forget_host actually stops an already-running host. The
obvious approach -- close srv from another thread to make the
blocked accept() below raise -- turns out not to be reliable at all:
verified directly that a thread blocked in accept() on Linux/CPython
can just stay blocked indefinitely after another thread closes the
same socket, with no exception ever raised (this is a known rough
edge of mixing blocking syscalls with cross-thread close(), not
something the rest of this codebase happened to hit before). A
socket timeout is the actually-reliable mechanism: accept() itself
raises socket.timeout on its own schedule, which this loop polls
stop_event against -- no dependency on some other thread's close()
ever actually reaching the kernel's blocked syscall. Only applied
when stop_event is given at all, so every other caller (the CLI's
`host` command, e2e's golden_path_server, ...) keeps the exact old
zero-overhead fully-blocking behavior.
Without this, the only way to free a port a host was already bound
to (e.g. to replace a single-file host with one covering the whole
archive) was killing the entire process -- a real report, not a
hypothetical: a stale single-file host surviving a restart via
_resume_persisted_hosts permanently squatted on the default port,
and every later attempt to host the whole archive_dir on that same
port failed with "Address already in use" with no way to clear it
short of restarting Docker itself."""
archive_dir = os.path.expanduser(archive_dir)
entries, entries_by_hash = _load_hostable_entries(archive_dir, file_name)
default_hash = next(iter(entries_by_hash)) if len(entries_by_hash) == 1 else None
manifest_mtime = _manifest_mtime(archive_dir)
srv = sock if sock is not None else bind_host_port(port, bind_host)
srv.listen(8)
if stop_event is not None:
srv.settimeout(1.0)
if not quiet:
# a background thread's print() races with cmd.Cmd's input()-driven
# prompt on the same stdout — see run_relay_server's docstring for
# why the shell passes quiet=True instead of patching this visually
if default_hash:
entry = entries[0]
print(f"[host:{port}] serving {entry['name']} ({entry['size']:,} bytes, "
f"{entry['n_chunks']} chunks x {entry['chunk_size']} bytes)")
print(f"[host:{port}] sha256/merkle root: {entry['sha256']}")
else:
total_size = sum(e['size'] for e in entries)
print(f"[host:{port}] serving {len(entries)} files ({total_size:,} bytes total) "
f"— clients SELECT which one by content hash")
while True:
try:
conn, _ = srv.accept()
except socket.timeout:
# only possible when stop_event is not None (that's the only
# path that ever calls settimeout above) -- just means no
# connection arrived within the last second; check whether
# this was actually asked to stop, and if not, go right back
# to waiting.
if stop_event is not None and stop_event.is_set():
return
continue
except OSError:
if stop_event is not None and stop_event.is_set():
return
raise
# Real report: a file dropped into this same archive_dir via the
# web UI's upload feature (or `ott add` from another terminal)
# never showed up for anyone connecting to an *already-running*
# host -- entries_by_hash used to be built once, at startup, and
# never looked at the manifest again for the rest of this
# process's life. The only fix was restarting the whole host
# (`make node` / docker restart), which also meant freeing the
# port first since nothing could stop the stale one gracefully
# (see web_ui.py's _handle_forget_host docstring on why there's
# no cancellation path into this accept() loop from outside).
#
# A cheap mtime check keeps the common case (nothing changed
# between connections) to a single stat() instead of re-parsing
# the manifest and every chunk-hash file on every single accept.
# Reassigning entries_by_hash/default_hash here (not mutating in
# place) means any download already in flight, which captured
# the previous dict by reference in its own thread's args, keeps
# using a perfectly consistent snapshot for its whole lifetime --
# only *new* connections from this point on see the reload.
current_mtime = _manifest_mtime(archive_dir)
if current_mtime != manifest_mtime:
try:
entries, entries_by_hash = _load_hostable_entries(archive_dir, file_name)
default_hash = next(iter(entries_by_hash)) if len(entries_by_hash) == 1 else None
manifest_mtime = current_mtime
except (SystemExit, Exception):
# a transient/partial write (e.g. an upload still mid-
# flight) shouldn't take down an otherwise-healthy host
# that was serving everything else just fine -- keep
# going with whatever the last good load was and try
# again on the next connection.
pass
# a whole download now lives on one connection (see serve_session) —
# accept() must hand off to a thread per connection, or one session
# would block every other downloader until it finished
threading.Thread(target=serve_session, args=(conn, entries_by_hash, default_hash, price, ln_node),
daemon=True).start()
def _connect_tunnel_socket(relay_host, relay_port, use_tls):
"""Plain TCP to the tunnel relay, or TLS-wrapped if the relay
terminates TLS at the edge (e.g. Fly's `handlers = ["tls"]`) — the
relay process itself (tunnel_relay.py) never sees or needs to know
about TLS either way, since edge termination decrypts before
forwarding to it. Only the two ends actually crossing the public
internet need this: a host's REGISTER/DATA connections here, and a
downloader's CONNECT in HostConnection.connect_via_tunnel."""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((relay_host, relay_port))
if use_tls:
ctx = ssl.create_default_context()
sock = ctx.wrap_socket(sock, server_hostname=relay_host)
return sock
def run_host_tunnel(relay_host, relay_port, token, entry, leaves, file_path, price,
use_tls=False, quiet=False, heartbeat_interval=45, ln_node=None,
max_backoff=60):
"""NAT-traversal path: instead of (or alongside) binding a locally
reachable port, register with a tunnel_relay.py relay and serve every
downloader it pairs us with. One persistent CONTROL connection stays
open for the lifetime of hosting; each real downloader gets its own
DATA connection, dialed back to the relay on demand (NEWSTREAM), so
concurrent tunneled downloads don't block each other.
REGISTER gets one immediate OK/ERR reply (see below), then the
control connection sends nothing at all until the first real
NEWSTREAM — which can be minutes or hours if no one downloads in the
meantime. Real-world proxies in the middle (observed: Fly's own
edge) reset TCP connections that go idle for a few minutes, which
silently unregisters the host with no error on this end until the
next download attempt fails. A small periodic heartbeat keeps the
connection looking active; tunnel_relay.py's REGISTER loop already
discards anything it receives that isn't relevant to it (it only ever
checks for EOF), so this needs zero changes on the relay side.
The OK/ERR reply exists so a REGISTER for a token someone else
already holds an active registration for can be refused instead of
silently overwriting it — content_hash tokens are public (announced
via discover), so without this anyone could squat/hijack another
host's rendezvous slot for content they didn't actually publish.
Runs forever, reconnecting with exponential backoff (1s, 2s, 4s, ...,
capped at max_backoff) on any connection failure, rejected REGISTER,
or the control connection just closing — this used to be one-shot
(sys.exit on a rejected REGISTER, plain return once the connection
dropped), which meant a tunnel relay restart for *any* reason (deploy,
crash, Fly host migration) silently and permanently dropped every
currently-hosting process until a human noticed and manually re-ran
`host --tunnel`. Not hypothetical: a real Fly Machine restart took
down every active registration on this relay at once in production
(see the tunnel_relay.py fly logs from 2026-08-25), and every one of
them stayed dead until manually restarted. This is the fix — the
relay coming back is enough, no human required."""
entries_by_hash = {entry['sha256']: (entry, leaves, file_path)}
backoff = 1
while True:
rejected, reject_reason = False, None
try:
ctrl = _connect_tunnel_socket(relay_host, relay_port, use_tls)
ctrl.sendall(f'REGISTER {token}\n'.encode())
# one LineReader for the whole connection, created before the
# first read and reused for the NEWSTREAM loop below --
# creating a second one later would lose whatever extra bytes
# this first recv() also happened to pick up (LineReader
# buffers internally; a fresh instance starts with an empty
# buffer, discarding anything already read into the old one)
reader = LineReader(ctrl)
ack = reader.readline()
if ack != 'OK':
rejected, reject_reason = True, ack or '(connection closed)'
ctrl.close()
else:
if not quiet:
tls_note = ' (tls)' if use_tls else ''
print(f"[tunnel] registered {token[:16]}... with relay "
f"{relay_host}:{relay_port}{tls_note}")
backoff = 1 # a real registration succeeded — forget any earlier backoff
stop_heartbeat = threading.Event()
def send_heartbeats():
while not stop_heartbeat.wait(heartbeat_interval):
try:
ctrl.sendall(b'PING\n')
except OSError:
return
threading.Thread(target=send_heartbeats, daemon=True).start()
try:
while True:
line = reader.readline()
if not line:
if not quiet:
print(f"[tunnel] control connection to {relay_host}:{relay_port} "
f"closed for {token[:16]}... — reconnecting")
break
parts = line.split()
if parts and parts[0] == 'NEWSTREAM':
stream_id = parts[1]
data_conn = _connect_tunnel_socket(relay_host, relay_port, use_tls)
data_conn.sendall(f'DATA {stream_id}\n'.encode())
threading.Thread(target=serve_session,
args=(data_conn, entries_by_hash, entry['sha256'],
price, ln_node),
daemon=True).start()
# anything else (notably our own echoed-back nothing --
# PING is one-directional, the relay never echoes it)
# is silently ignored, same as it always was
finally:
stop_heartbeat.set()
except OSError as e:
if not quiet:
print(f"[tunnel] connection to relay {relay_host}:{relay_port} for "
f"{token[:16]}... failed ({e}) — retrying in {backoff}s")
time.sleep(backoff)
backoff = min(backoff * 2, max_backoff)
continue
if rejected:
if not quiet:
print(f"[tunnel] relay at {relay_host}:{relay_port} rejected REGISTER for "
f"{token[:16]}...: {reject_reason} — retrying in {backoff}s")
time.sleep(backoff)
backoff = min(backoff * 2, max_backoff)
continue
# control connection closed on its own after a previously-successful
# registration (relay restart, idle proxy reset, ...) -- reconnect
# right away rather than backing off; if the relay's actually still
# down this just falls into the OSError branch above on the very
# next iteration and starts backing off normally from there
backoff = 1
# ── client side ──────────────────────────────────────────────────────────
class HostConnection:
"""One persistent socket carrying every command for a session, direct
or tunneled — see serve_session's docstring for why reconnecting per
command (the old behavior) is untenable once a tunnel relay is in the
path."""
def __init__(self, sock, via=None):
self.sock = sock
# human-readable "where this session actually goes" -- 'host:port'
# or 'tunnel relay:port' -- for download()'s own progress lines,
# now that a tunneled download may have failed over between relays
# before landing on the one that answered
self.via = via or 'host'
@classmethod
def connect_direct(cls, host, port, timeout=10):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(timeout)
sock.connect((host, port))
return cls(sock, via=f'{host}:{port}')
@classmethod
def connect_via_tunnel(cls, relay_host, relay_port, token, use_tls=False, timeout=10):
"""token is the content_hash the host registered under (see
run_host_tunnel) — the tunnel relay has zero opinion on content,
it just pairs this CONNECT with that host's next NEWSTREAM."""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(timeout)
sock.connect((relay_host, relay_port))
if use_tls:
ctx = ssl.create_default_context()
sock = ctx.wrap_socket(sock, server_hostname=relay_host)
sock.sendall(f'CONNECT {token}\n'.encode())
return cls(sock, via=f'tunnel {relay_host}:{relay_port}')
def request(self, line):
self.sock.sendall((line + '\n').encode())
return recv_line(self.sock)
def close(self):
_graceful_close(self.sock)
def __enter__(self):
return self
def __exit__(self, *exc_info):
self.close()
def _parse_tunnel(tunnel_addr):
"""'relay_host:relay_port' or 'tls://relay_host:relay_port' ->
(relay_host, relay_port, use_tls), or None. The tls:// prefix marks a
relay that terminates TLS at the edge (e.g. Fly's `handlers =
["tls"]`) — publish() stores this string as given, so a downloader
who discovers the host gets the same tls:// marker automatically and
connects the same way the host registered."""
if not tunnel_addr:
return None
use_tls = tunnel_addr.startswith('tls://')
if use_tls:
tunnel_addr = tunnel_addr[len('tls://'):]
if ':' not in tunnel_addr:
raise ValueError(f"--tunnel {tunnel_addr!r} is missing a port — expected "
f"[tls://]host:port, e.g. tls://tunnel.hak4.org:9199")
relay_host, relay_port = tunnel_addr.rsplit(':', 1)
if not relay_port.isdigit():
raise ValueError(f"--tunnel port {relay_port!r} isn't a number — expected "
f"[tls://]host:port, e.g. tls://tunnel.hak4.org:9199")
return relay_host, int(relay_port), use_tls
def _split_tunnel_spec(spec):
"""One or more tunnel relay addresses -> list of address strings.
Accepts None, one '[tls://]host:port', a comma-separated string of
them, or a list/tuple of either. Blank entries dropped, duplicates
collapsed, order kept -- order is the failover order on the client
side (see open_connection)."""
if not spec:
return []
parts = [spec] if isinstance(spec, str) else list(spec)
out = []
for p in parts:
for piece in (p or '').split(','):
piece = piece.strip()
if piece and piece not in out:
out.append(piece)
return out
def _parse_tunnels(spec):
"""_parse_tunnel over every entry of _split_tunnel_spec: a list of
(relay_host, relay_port, use_tls)."""
return [_parse_tunnel(s) for s in _split_tunnel_spec(spec)]
def candidate_tunnels(candidate):
"""The tunnel relays a discovered publish event names, parsed, in the
host's own order: 'tunnels' (a list, on events from hosts that
registered with more than one relay) falling back to 'tunnel' (the
single field every tunneled event has always carried, and the only
one an older client reads). [] means the host is directly
reachable at its advertised address."""
return _parse_tunnels(candidate.get('tunnels') or candidate.get('tunnel'))
def open_connection(host_addr, tunnel=None, content_hash=None, timeout=10):
"""host_addr is 'host:port' — used directly unless tunnel is given, in
which case it's ignored and content_hash is used as the tunnel
rendezvous token instead (the host isn't reachable at host_addr at
all in that case). tunnel is one pre-parsed _parse_tunnel() result or
a list of them (candidate_tunnels): tried in order, the first that
actually reaches the host wins.
"Connected to the relay" proves nothing on its own: a tunnel relay
accepts any CONNECT, and only afterwards either answers 'ERR no such
host' or, if the host is registered but never dials back, closes
after its own pairing timeout. So every tunnel gets probed with one
real INFO round trip before being handed back -- that's what makes
failing over to the next relay possible at all, treating a relay
that's up but doesn't know this host the same as one that's down.
INFO is idempotent; the caller's own INFO afterward is unaffected."""
if isinstance(tunnel, tuple):
tunnels = [tunnel]
else:
tunnels = list(tunnel) if tunnel else []
if tunnels:
if not content_hash:
raise ValueError("tunnel connection requires content_hash as the rendezvous token")
last_err = None
for relay_host, relay_port, use_tls in tunnels:
try:
conn = HostConnection.connect_via_tunnel(relay_host, relay_port, content_hash,
use_tls=use_tls, timeout=timeout)
except OSError as e:
last_err = e
continue
try:
probe = conn.request('INFO')
except OSError as e:
conn.close()
last_err = e
continue
if not probe.startswith('{'):
conn.close()
last_err = OSError(f"tunnel {relay_host}:{relay_port}: "
f"{probe or 'closed the connection (host not registered there?)'}")
continue
return conn
raise last_err
host, port_s = host_addr.rsplit(':', 1)
return HostConnection.connect_direct(host, int(port_s), timeout=timeout)
def download(host_addr, out_path, tunnel=None, content_hash=None, on_progress=None,
price=0, use_lightning=False, lightning_node=None):
from ott import merkle_root # pip install btcvm
out_path = os.path.expanduser(out_path)
with open_connection(host_addr, tunnel=tunnel, content_hash=content_hash) as conn:
via = conn.via
if content_hash and not tunnel:
# tunnel connections are already scoped to one file by the relay's
# rendezvous token (see run_host_tunnel) — SELECT is only needed
# against a direct multi-file host, which may be serving more than
# just this content_hash on the same port
sel = conn.request(f'SELECT {content_hash}')
if sel != 'OK':
sys.exit(f"host at {host_addr} rejected SELECT {content_hash[:16]}...: {sel}")
if use_lightning and price > 0:
# pay this specific host as itself, on this same session, before
# trusting it with a single byte -- INVOICE returns a real BOLT11
# from the host's own LND (see serve_session), not a fixed demo
# pair settled through a side channel regardless of who won
import lightning_settle
inv_resp = conn.request('INVOICE')
if inv_resp.startswith('ERR') or not inv_resp:
sys.exit(f"host at {via} can't produce a real Lightning invoice "
f"({inv_resp or 'no response'}) — rerun without --lightning to "
f"download unpaid, or ask the host to set --lightning-node")
invoice = json.loads(inv_resp)
payment = lightning_settle.pay_invoice(lightning_node, invoice['payment_request'],
invoice['payment_hash'])
print(f"paid {via}'s own real Lightning invoice: {invoice['amount_sat']} sat, "
f"preimage {payment['preimage'][:12]}... verified against "
f"payment_hash {payment['payment_hash'][:12]}...")
info = json.loads(conn.request('INFO'))
print(f"downloading {info['name']} ({info['size']:,} bytes, {info['n_chunks']} chunks) "
f"from {via}")
leaves = json.loads(conn.request('LEAVES'))
if len(leaves) != info['n_chunks']:
sys.exit(f"host's LEAVES count ({len(leaves)}) doesn't match its own INFO "
f"({info['n_chunks']}) — refusing to trust an inconsistent host")