weed-cli 0.1.0__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-0.1.0/PKG-INFO +870 -0
- weed_cli-0.1.0/README.md +842 -0
- weed_cli-0.1.0/dht.py +137 -0
- weed_cli-0.1.0/discovery_relay.py +94 -0
- weed_cli-0.1.0/lightning_settle.py +90 -0
- weed_cli-0.1.0/node.py +784 -0
- weed_cli-0.1.0/poc_reputation.py +311 -0
- weed_cli-0.1.0/pyproject.toml +75 -0
- weed_cli-0.1.0/setup.cfg +4 -0
- weed_cli-0.1.0/shell.py +420 -0
- weed_cli-0.1.0/tunnel_relay.py +204 -0
- weed_cli-0.1.0/web_ui.py +444 -0
- weed_cli-0.1.0/weed.py +238 -0
- weed_cli-0.1.0/weed_cli.egg-info/PKG-INFO +870 -0
- weed_cli-0.1.0/weed_cli.egg-info/SOURCES.txt +17 -0
- weed_cli-0.1.0/weed_cli.egg-info/dependency_links.txt +1 -0
- weed_cli-0.1.0/weed_cli.egg-info/entry_points.txt +2 -0
- weed_cli-0.1.0/weed_cli.egg-info/requires.txt +16 -0
- weed_cli-0.1.0/weed_cli.egg-info/top_level.txt +9 -0
weed_cli-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: weed-cli
|
|
3
|
+
Version: 0.1.0
|
|
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
|
+
License: MIT
|
|
6
|
+
Keywords: p2p,video,censorship-resistant,discovery,dht,kademlia,nat-traversal
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Topic :: Communications :: File Sharing
|
|
13
|
+
Classifier: Topic :: Internet
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: btcvm>=1.3.7
|
|
17
|
+
Requires-Dist: cryptography>=41.0
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
21
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
22
|
+
Provides-Extra: dht
|
|
23
|
+
Requires-Dist: kademlia>=2.2; extra == "dht"
|
|
24
|
+
Provides-Extra: qr
|
|
25
|
+
Requires-Dist: qrcode>=7.4; extra == "qr"
|
|
26
|
+
Provides-Extra: viz
|
|
27
|
+
Requires-Dist: matplotlib>=3.8; extra == "viz"
|
|
28
|
+
|
|
29
|
+
# Censorship-resistant video platform — PoC notes
|
|
30
|
+
|
|
31
|
+
Brainstormed in #all-pdx 2026-08-22: a YouTube replacement indexed on Bitcoin,
|
|
32
|
+
distributed over BitTorrent-style magnet links. This tracks what got built and
|
|
33
|
+
what was actually learned, not just the idea.
|
|
34
|
+
|
|
35
|
+
## The core design problem
|
|
36
|
+
|
|
37
|
+
Storage and delivery over BitTorrent-plus-a-chain-timestamp is the easy part —
|
|
38
|
+
solved plumbing. The two things that actually kill projects like this:
|
|
39
|
+
|
|
40
|
+
- **Incentives.** LBRY minted a token and got sued as an unregistered security.
|
|
41
|
+
BitTube minted a token and died to the standard watch-to-earn Ponzi spiral.
|
|
42
|
+
PeerTube minted nothing and stayed permanently niche — no incentive layer at
|
|
43
|
+
all, purely volunteer-hosted. Anchoring to *existing* BTC (no new token) and
|
|
44
|
+
paying for service directly over Lightning avoids both failure modes at once.
|
|
45
|
+
- **Discovery.** A single global index or "most popular" list is exactly as
|
|
46
|
+
seizable as YouTube's own trending page. The answer that doesn't reintroduce
|
|
47
|
+
a chokepoint: no canonical list at all — gossiped signals (payments, in this
|
|
48
|
+
case), any number of independent, replaceable indexer apps computing their
|
|
49
|
+
own view, Nostr-style. Not built yet, just designed.
|
|
50
|
+
|
|
51
|
+
## What's built
|
|
52
|
+
|
|
53
|
+
### `poc_challenge_auction.py` — possession-gated reverse auction
|
|
54
|
+
|
|
55
|
+
`merkle_root`/`merkle_proof`/`verify_proof` are imported from the real,
|
|
56
|
+
[published](https://pypi.org/project/btcvm/) `btcvm` package (`pip install
|
|
57
|
+
btcvm`) — same functions `ott verify-chunk` runs locally. Used to be
|
|
58
|
+
vendored copies (kept this repo dependency-free before btcvm was on PyPI);
|
|
59
|
+
now that it's real and installable, this repo depends on it properly
|
|
60
|
+
instead. `ott` is the storage/archive layer — what you have and can prove
|
|
61
|
+
you have; this repo is the distribution/incentive layer built on top of
|
|
62
|
+
it. Deliberately kept as separate packages rather than merged: `ott` stays
|
|
63
|
+
the stable, already-published tool with real users, this stays free to be
|
|
64
|
+
a rougher-edged PoC without dragging Lightning/Docker into `ott`'s
|
|
65
|
+
dependency surface.
|
|
66
|
+
|
|
67
|
+
In-process simulation, five peers, one 8-chunk file.
|
|
68
|
+
|
|
69
|
+
**Part 1 — chunk-index challenge + auction.** A naive price-only auction picks
|
|
70
|
+
the cheapest bidder regardless of whether they can deliver — and does, in the
|
|
71
|
+
run captured here (a peer holding zero chunks wins on price alone). Gating bid
|
|
72
|
+
eligibility on passing a random chunk-index + Merkle-proof challenge fixes it:
|
|
73
|
+
a peer with nothing gets caught every round; a peer that tampers its response
|
|
74
|
+
~70% of the time gets caught most rounds and wins once by pure luck — real
|
|
75
|
+
evidence that a single spot-check isn't airtight, only a statistical one is.
|
|
76
|
+
A legitimately partial holder (only half the chunks) correctly sits out
|
|
77
|
+
rounds for chunks it doesn't have, without being flagged dishonest.
|
|
78
|
+
|
|
79
|
+
**Part 2 — nonce-salted challenge + timing bound.** Knowing a file's public
|
|
80
|
+
SHA256 gives an attacker nothing — SHA256 preimage resistance means you can't
|
|
81
|
+
derive `hash(chunk||nonce)` from `hash(chunk)` alone, so a peer with only the
|
|
82
|
+
published hash can't even attempt a response. A peer that *does* have the
|
|
83
|
+
real bytes but fetches them from someone else in real time (relaying) answers
|
|
84
|
+
with a cryptographically **correct** hash every time — the nonce alone can't
|
|
85
|
+
catch that. Only an added timing bound can, because the relay hop costs real
|
|
86
|
+
latency a local holder never pays.
|
|
87
|
+
|
|
88
|
+
### `poc_network_challenge.py` — the same mechanism over real sockets
|
|
89
|
+
|
|
90
|
+
Takes the timing-bound claim off paper: real TCP, real OS subprocesses, real
|
|
91
|
+
`time.perf_counter()`, loopback (127.0.0.1). Three modes:
|
|
92
|
+
|
|
93
|
+
- `holder <port>` — actually stores chunks, answers directly
|
|
94
|
+
- `relay <port> <holder_host> <holder_port>` — stores nothing, chains a
|
|
95
|
+
second real TCP connection to the holder to fetch real bytes before
|
|
96
|
+
answering
|
|
97
|
+
- `verify-remote <h_host> <h_port> <r_host> <r_port> [b_host] [b_port]` —
|
|
98
|
+
client mode: connects to already-running holder/relay (e.g. other
|
|
99
|
+
containers) and runs the separation analysis against them, with an
|
|
100
|
+
optional second honest holder as a same-vs-same jitter baseline
|
|
101
|
+
- (no args) — local convenience: single-shot challenge rounds on loopback, narrated
|
|
102
|
+
- `stats` — local convenience: spawns holder+relay as subprocesses on
|
|
103
|
+
loopback, bulk collection (80 real samples per role) + bootstrap analysis
|
|
104
|
+
of how many repeated challenges it takes for the *session mean* to
|
|
105
|
+
reliably separate holder from relay
|
|
106
|
+
|
|
107
|
+
**Real finding, not the expected one:** on loopback, single-shot timing does
|
|
108
|
+
*not* reliably separate a holder from a relay — the honest holder's worst
|
|
109
|
+
recorded round was slower than the relay's best. Averaging repeated
|
|
110
|
+
challenges does separate them (typically somewhere in the k=3–20 range across
|
|
111
|
+
several runs — see below); trust a session average, not any one round.
|
|
112
|
+
|
|
113
|
+
RunPod was down the first time this was tried, so real WAN latency got
|
|
114
|
+
measured against public hosts as a substitute (1.1.1.1, 8.8.8.8,
|
|
115
|
+
api.github.com: 5-30ms real TCP-connect RTT) — suggestive, not conclusive.
|
|
116
|
+
**Update: ran it for real once a RunPod box came back up**, tunneled over a
|
|
117
|
+
real SSH connection (`ssh -L`, since the pod only exposes its SSH port, not
|
|
118
|
+
arbitrary TCP) — local honest holder vs. a local relay that secretly fetches
|
|
119
|
+
from that real remote box for every challenge:
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
holder: mean 0.254ms min 0.186ms max 1.250ms
|
|
123
|
+
relay: mean 432.648ms min 395.583ms max 638.218ms
|
|
124
|
+
|
|
125
|
+
session size k worst honest mean best cheater mean separated?
|
|
126
|
+
1 1.250ms 395.583ms YES
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
~1700x gap, separates cleanly at k=1 — single-shot is all you need once real
|
|
130
|
+
geographic distance is involved. Confirms the substitute-host hypothesis:
|
|
131
|
+
real WAN distance makes this *easy*; the hard case this PoC actually
|
|
132
|
+
stress-tests is two peers that are genuinely close together, which is
|
|
133
|
+
exactly when a nearby relay is hardest to catch on timing
|
|
134
|
+
alone.
|
|
135
|
+
|
|
136
|
+

|
|
137
|
+
|
|
138
|
+
Chart from `viz_challenge_separation.py` — regenerates real measurements each
|
|
139
|
+
run rather than plotting a fixed snapshot. **The exact crossover k is not a
|
|
140
|
+
fixed constant** — it moved between k=3, k=8, and k=20 across different runs
|
|
141
|
+
of this same script on the same machine, purely from real system jitter. That
|
|
142
|
+
instability is itself the finding: don't hardcode a specific k, measure it
|
|
143
|
+
live and adapt, and prefer statistical separation over any fixed threshold.
|
|
144
|
+
|
|
145
|
+
### `docker-compose.yml` — the same test over real container networking
|
|
146
|
+
|
|
147
|
+
Four services: `holder1` and `holder2` (two independent honest peers),
|
|
148
|
+
`relay` (holds nothing, relays from `holder1` over the compose network),
|
|
149
|
+
`verifier` (runs the same repeated-challenge analysis against all three,
|
|
150
|
+
using DNS service names instead of loopback).
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
docker compose up --build --abort-on-container-exit verifier
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Real run, over podman's docker-compose shim, actual separate containers on
|
|
157
|
+
the compose bridge network:
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
holder: mean 0.246ms min 0.188ms max 0.953ms
|
|
161
|
+
relay: mean 0.483ms min 0.414ms max 1.134ms
|
|
162
|
+
holder2 (2nd honest holder, same-vs-same jitter baseline): mean 0.236ms min 0.209ms max 0.311ms
|
|
163
|
+
|
|
164
|
+
session size k worst honest mean best cheater mean separated?
|
|
165
|
+
1 0.953ms 0.414ms no
|
|
166
|
+
2 0.626ms 0.414ms no
|
|
167
|
+
3 0.712ms 0.421ms no
|
|
168
|
+
5 0.541ms 0.432ms no
|
|
169
|
+
8 0.430ms 0.437ms YES
|
|
170
|
+
12 0.425ms 0.445ms YES
|
|
171
|
+
20 0.346ms 0.448ms YES
|
|
172
|
+
30 0.314ms 0.451ms YES
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Same shape as loopback (single-shot doesn't separate, k≈8 does), plus one
|
|
176
|
+
useful sanity check the loopback version can't give: holder2's baseline mean
|
|
177
|
+
(0.236ms) sits right next to holder1's (0.246ms) — two equally honest,
|
|
178
|
+
unrelated containers naturally land close together, while the relay
|
|
179
|
+
(0.483ms, roughly double) is a real structural gap, not just inter-container
|
|
180
|
+
noise.
|
|
181
|
+
|
|
182
|
+
### `poc_reputation.py` — persistent local reputation + signed portable attestations
|
|
183
|
+
|
|
184
|
+
Two mechanisms, both real (Ed25519 via the `cryptography` package, actual
|
|
185
|
+
signing and verification, not simulated):
|
|
186
|
+
|
|
187
|
+
1. **Local reputation store** (`ReputationStore`, persisted to JSON) — a
|
|
188
|
+
client's own record of direct experience with a peer (passes/fails/avg
|
|
189
|
+
latency), so a known-good peer doesn't need to re-earn trust from zero on
|
|
190
|
+
every interaction.
|
|
191
|
+
2. **Signed attestations** — a client signs its own verification outcome for
|
|
192
|
+
a peer and hands the signed blob to another client, who didn't do the
|
|
193
|
+
verification but can check the signature and decide how much to trust it.
|
|
194
|
+
Same shape as PGP's Web of Trust, applied to possession-verification
|
|
195
|
+
outcomes instead of key identity — including PGP's actual historical
|
|
196
|
+
weak point: the crypto is the easy part, "how much do I trust this
|
|
197
|
+
signer" is the unsolved UX problem, not a technical one.
|
|
198
|
+
3. **Revocation** — a signer can kill their own earlier vouch (`sign_revocation`,
|
|
199
|
+
keyed to the attestation's content-hash `attestation_id`). Only accepted
|
|
200
|
+
if the revocation's signer matches the original attestation's signer;
|
|
201
|
+
the revoked attestation stays on record rather than being deleted, so
|
|
202
|
+
"X vouched for Y, then revoked it" stays an honest, auditable fact
|
|
203
|
+
instead of quietly disappearing.
|
|
204
|
+
|
|
205
|
+
Demonstrated for real in one run: a fresh client with zero direct history
|
|
206
|
+
bootstraps a trust score for an unknown peer purely from another client's
|
|
207
|
+
signed vouch; a vouch from a signer you don't trust at all is cryptographically
|
|
208
|
+
valid but contributes zero weight; mutating a signed payload after the fact
|
|
209
|
+
(`passes: 8 → 800`) is caught by signature verification; a 90-day-old
|
|
210
|
+
attestation is worth 0.125x a fresh one under a 30-day trust half-life;
|
|
211
|
+
alice revoking her own vouch drops bob's trust score for that peer without
|
|
212
|
+
bob ever re-verifying it himself; mallory forging a revocation of *alice's*
|
|
213
|
+
vouch (valid signature, wrong signer) is correctly rejected; a revocation
|
|
214
|
+
referencing an attestation nobody's ever seen is rejected too.
|
|
215
|
+
|
|
216
|
+
### `lightning_settle.py` + `lightning/` — real Lightning HTLC settlement
|
|
217
|
+
|
|
218
|
+
Replaces `poc_challenge_auction.py`'s mock "settlement" print with a real
|
|
219
|
+
one: two real LND nodes (Lightning Labs' production node software) on
|
|
220
|
+
regtest, real bitcoind backing them, a real funded channel between them.
|
|
221
|
+
`poc_challenge_auction.py --lightning` settles every auction round's winner
|
|
222
|
+
with a genuine BOLT11 invoice + HTLC — not simulated, and not just trusting
|
|
223
|
+
LND's own "SUCCEEDED" status: `lightning_settle.py` independently re-hashes
|
|
224
|
+
the revealed preimage and checks it against the invoice's payment_hash
|
|
225
|
+
locally before calling it settled.
|
|
226
|
+
|
|
227
|
+
Real run, 5 winning rounds, real preimages each verified against their own
|
|
228
|
+
payment hash:
|
|
229
|
+
|
|
230
|
+
```
|
|
231
|
+
WINNER: bob 9 sat preimage 4ac71143706b... payment_hash d1be1130c553...
|
|
232
|
+
WINNER: bob 5 sat preimage eea88d802d1a... payment_hash 8acabea4ddf7...
|
|
233
|
+
WINNER: bob 6 sat preimage 76f1ade0f9be... payment_hash f9c9bda28be0...
|
|
234
|
+
WINNER: bob 10 sat preimage 96e696c1cde9... payment_hash f8eeb66d1878...
|
|
235
|
+
WINNER: mallory 1 sat preimage 508385841cb3... payment_hash 1c2872b6018f...
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Bob's cumulative channel balance after the run matched the sum of every
|
|
239
|
+
settled payment exactly, checked directly against LND rather than assumed.
|
|
240
|
+
Full setup steps in `lightning/README.md` — real bitcoind + LND takes a
|
|
241
|
+
one-time channel-funding setup regtest can't skip (mine to coinbase
|
|
242
|
+
maturity, open a channel, mine confirmations) before it's usable.
|
|
243
|
+
|
|
244
|
+
### `poc_real_archive_challenge.py` — real `.ott` archive, real video, real scale
|
|
245
|
+
|
|
246
|
+
Every other PoC file here used `os.urandom` fake chunks (8 of them).
|
|
247
|
+
This one points the same mechanism at a real 217MB video, archived with the
|
|
248
|
+
real `ott` CLI at a real 64KB chunk size:
|
|
249
|
+
|
|
250
|
+
```
|
|
251
|
+
real archive: real_video.mp4, 217,831,234 bytes, 3324 real chunks x 65536 bytes
|
|
252
|
+
recomputed Merkle root matches ott's own commit: True
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
The thing this was actually checking — proof size at real scale:
|
|
256
|
+
|
|
257
|
+
```
|
|
258
|
+
chunk 0: 12 steps, 396B raw, 1176B as JSON
|
|
259
|
+
chunk 1662: 12 steps, 396B raw, 1168B as JSON
|
|
260
|
+
chunk 3323: 12 steps, 396B raw, 1167B as JSON
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
12 proof steps at 3324 real chunks vs. 3 steps at the toy 8-chunk scale —
|
|
264
|
+
exactly log2(N), not linear, confirmed with real numbers instead of just
|
|
265
|
+
trusting the math. Even a 2-hour movie at these settings (~10GB, ~163,840
|
|
266
|
+
64KB chunks) would only need ~17 steps, still under 1KB. Then ran the same
|
|
267
|
+
nonce-salted-challenge logic from `poc_challenge_auction.py` Part 2 against
|
|
268
|
+
real bytes read straight off disk at real offsets — all 5 real rounds
|
|
269
|
+
checked out: hash matches ott's own committed leaf, Merkle proof verifies,
|
|
270
|
+
nonce response is internally consistent.
|
|
271
|
+
|
|
272
|
+
`real_archive/real_video.mp4` isn't committed to this repo (208MB, and it's
|
|
273
|
+
not this repo's to redistribute) — `real_archive/.ott/`'s metadata is
|
|
274
|
+
tracked, so the chunk list and commitment are there for inspection even
|
|
275
|
+
without the video itself. Reproduce with any file:
|
|
276
|
+
|
|
277
|
+
```bash
|
|
278
|
+
cd real_archive
|
|
279
|
+
python3 /path/to/btcvm/ott.py init
|
|
280
|
+
# edit .ott/config, set "chunk_size" to whatever you want (65536 used here)
|
|
281
|
+
python3 /path/to/btcvm/ott.py add your_video.mp4
|
|
282
|
+
python3 /path/to/btcvm/ott.py commit
|
|
283
|
+
cd ..
|
|
284
|
+
python3 poc_real_archive_challenge.py
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
### `discovery_relay.py` + `poc_discovery.py` — discovery, no canonical index
|
|
288
|
+
|
|
289
|
+
The last unsolved piece from the original brainstorm, actually built: no
|
|
290
|
+
single "trending" list, no server whose seizure kills discoverability.
|
|
291
|
+
Three independent relay processes (`discovery_relay.py` — real stdlib
|
|
292
|
+
`http.server`, no deps), each deliberately dumb: verifies a posted event's
|
|
293
|
+
signature (a relay won't store garbage) but has zero opinion on content
|
|
294
|
+
quality, zero ranking logic. A creator (carol) publishes a real event
|
|
295
|
+
pointing at the real video's real Merkle root from item 6. Viewers like it
|
|
296
|
+
and subscribe to each other, spread across the three relays — nobody posts
|
|
297
|
+
to all three, on purpose.
|
|
298
|
+
|
|
299
|
+
Two clients, `bob` (subscribes to dan + erin) and `mallory` (subscribes to
|
|
300
|
+
frank only), each query all three relays, verify every event's signature
|
|
301
|
+
themselves (never trust a relay's word for it), and compute their own
|
|
302
|
+
ranking from their own subscribe graph — subscriptions *are* the trust
|
|
303
|
+
graph, not a separate feature, same insight from the Slack thread now
|
|
304
|
+
actually running as code:
|
|
305
|
+
|
|
306
|
+
```
|
|
307
|
+
same 27 gossiped events, both clients saw all 23 likes (3 honest + 20 sybil),
|
|
308
|
+
but scored the content differently — 2.0 (bob) vs 1.0 (mallory) — because
|
|
309
|
+
ranking runs on each client's own trust graph, not vote count.
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
A 20-identity sybil swarm likes the same content — every signature is
|
|
313
|
+
real and individually valid, a relay has no basis to reject any of them —
|
|
314
|
+
and moves neither client's score, because neither bob nor mallory
|
|
315
|
+
subscribes to any of the sybils. Sybil resistance from the trust graph,
|
|
316
|
+
not from relay-side moderation.
|
|
317
|
+
|
|
318
|
+
Then relay:9101 — the one carol's publish event and dan's like both
|
|
319
|
+
happened to live on — gets killed outright. Real result, not a clean win:
|
|
320
|
+
the content stays discoverable and rankable (erin's like survived on a
|
|
321
|
+
different relay), but the human-readable title and dan's like are gone for
|
|
322
|
+
good, since neither was posted anywhere else. Redundancy has to be
|
|
323
|
+
deliberate — post to more than one relay — it isn't automatic just because
|
|
324
|
+
relays are plural. Same limitation a real Nostr relay dying would have.
|
|
325
|
+
|
|
326
|
+
```bash
|
|
327
|
+
python3 poc_discovery.py
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
### `node.py` — the integration piece: host, discover, download, for real
|
|
331
|
+
|
|
332
|
+
Everything above is a demo of one mechanism at a time. `node.py` (via
|
|
333
|
+
`weed.py host/discover/download/like/subscribe/whoami`) is the actual
|
|
334
|
+
integration: a real node that hosts a real archived file over the real
|
|
335
|
+
wire protocol from `poc_network_challenge.py` (extended with `INFO` and
|
|
336
|
+
`LEAVES` so a downloader can learn the archive's shape first), announces
|
|
337
|
+
itself on a real relay, and — new, not just wired from existing pieces —
|
|
338
|
+
actually downloads a file from a peer and reassembles it on disk, which
|
|
339
|
+
nothing before this verified chunk-by-chunk *and* wrote a real file.
|
|
340
|
+
|
|
341
|
+
A persistent identity now lives at `~/.weed_identity.key` — every other
|
|
342
|
+
script tonight generated a fresh Ed25519 keypair per run, which is fine for
|
|
343
|
+
a demo but means nobody could ever accumulate reputation or be subscribed
|
|
344
|
+
to across invocations. A real node needs a stable pubkey.
|
|
345
|
+
|
|
346
|
+
Real end-to-end run: hosted the real 217MB video, discovered it from a
|
|
347
|
+
separate process, downloaded it to a new path, and diffed the result
|
|
348
|
+
against the original with `cmp` (not just checking the tool's own claim of
|
|
349
|
+
success) — byte-for-byte identical, matching SHA256 on both sides, 3324
|
|
350
|
+
chunks downloaded and verified in 1.3s.
|
|
351
|
+
|
|
352
|
+
Caught a real bug doing this, not a clean pass on the first try: `ott`
|
|
353
|
+
records a video's `sha256` manifest field as the **Merkle root** over its
|
|
354
|
+
chunk hashes (`digest = merkle_root(chunks)` in `ott.py`'s `cmd_add`), not
|
|
355
|
+
a linear whole-file hash — my first version streamed a plain
|
|
356
|
+
`hashlib.sha256()` over the received bytes and compared that, which does
|
|
357
|
+
not and structurally cannot equal a Merkle root. Every individual chunk
|
|
358
|
+
was verifying correctly the whole time; only the final whole-file check
|
|
359
|
+
was comparing the wrong thing. Fixed by recomputing the Merkle root over
|
|
360
|
+
the received leaves and checking it against the host's advertised
|
|
361
|
+
`sha256` — done *before* downloading any chunk, not after, so a host lying
|
|
362
|
+
about its own archive gets caught immediately instead of after wasting
|
|
363
|
+
bandwidth on it.
|
|
364
|
+
|
|
365
|
+
```bash
|
|
366
|
+
# terminal 1
|
|
367
|
+
python3 discovery_relay.py 9101
|
|
368
|
+
|
|
369
|
+
# terminal 2 — host the video from item 6
|
|
370
|
+
python3 weed.py host real_archive --port 9201 --relay http://127.0.0.1:9101
|
|
371
|
+
|
|
372
|
+
# terminal 3
|
|
373
|
+
python3 weed.py whoami
|
|
374
|
+
python3 weed.py discover --relay http://127.0.0.1:9101
|
|
375
|
+
python3 weed.py download <content_hash_prefix> --relay http://127.0.0.1:9101 --out downloaded.mp4
|
|
376
|
+
python3 weed.py like <content_hash> --relay http://127.0.0.1:9101
|
|
377
|
+
python3 weed.py subscribe <target_pubkey> --relay http://127.0.0.1:9101
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
`--advertise-host` on `host` matters if you're not on localhost — no NAT
|
|
381
|
+
traversal here, it just tells the relay what address to hand out, real
|
|
382
|
+
reachability is on you. Same point-to-point-known-address limitation
|
|
383
|
+
named earlier in this README, now visible as an actual CLI flag instead of
|
|
384
|
+
just a caveat in prose.
|
|
385
|
+
|
|
386
|
+
### `download` now runs the actual stack, not just a direct fetch
|
|
387
|
+
|
|
388
|
+
Until this point, `download` trusted whichever host `discover` found
|
|
389
|
+
first, for free, with no possession check and no reputation. That was the
|
|
390
|
+
real gap flagged after the last round of shell bug-fixes: the auction
|
|
391
|
+
(`poc_challenge_auction.py`), the reputation/trust-graph layer
|
|
392
|
+
(`poc_reputation.py`), and Lightning settlement (`lightning_settle.py`)
|
|
393
|
+
were all built and validated standalone, but none of them were reachable
|
|
394
|
+
from a real download. Now they are:
|
|
395
|
+
|
|
396
|
+
1. **Resolve every host** claiming to have the content (`discover` already
|
|
397
|
+
deduped by event, not by content — `download_with_auction` groups by
|
|
398
|
+
content_hash so a second host publishing the same file actually gets
|
|
399
|
+
considered, not silently dropped).
|
|
400
|
+
2. **Possession-challenge each one** — sample-FETCH `k` random chunks
|
|
401
|
+
(default 3) and verify against the already Merkle-root-checked LEAVES.
|
|
402
|
+
Scoped deliberately to poc_challenge_auction.py's **Part 1** mechanism
|
|
403
|
+
(chunk-index challenge), not Part 2's nonce/timing relay-detection —
|
|
404
|
+
that one needs ground-truth bytes the verifier already trusts, which a
|
|
405
|
+
first-time downloader doesn't have until *after* this same sampling
|
|
406
|
+
step. Noted here rather than silently narrowed.
|
|
407
|
+
3. **Auction survivors** by local reputation first, then price — same
|
|
408
|
+
"challenge gates the auction" shape as the original PoC's naive-vs-
|
|
409
|
+
gated comparison. A host with no history starts at 0.0, same as
|
|
410
|
+
everyone; reputation only pulls ahead of price once there's real
|
|
411
|
+
experience behind it (verified — see below).
|
|
412
|
+
4. **Pay the winner** over a real Lightning HTLC if `--lightning` is given
|
|
413
|
+
and the price is nonzero — same `lightning_settle.py` regtest demo path
|
|
414
|
+
as before. Honest limitation, not glossed over: this settles with the
|
|
415
|
+
fixed alice/bob demo nodes, not a general "pay this specific host's own
|
|
416
|
+
Lightning node" protocol — that would need hosts to serve their own
|
|
417
|
+
real BOLT11 invoices, which isn't built.
|
|
418
|
+
5. **Download and record** — same chunk-verified `download()` as before,
|
|
419
|
+
then the outcome (pass/fail, latency) gets written to
|
|
420
|
+
`~/.weed_reputation.json` via `ReputationStore.record_direct`, so the
|
|
421
|
+
next auction for this host starts from real history instead of 0.0.
|
|
422
|
+
|
|
423
|
+
Real test, two independent hosts (separate identities, separate `HOME`s so
|
|
424
|
+
podman's rootless state didn't collide) serving the same real video —
|
|
425
|
+
one free, one priced at 500 sat:
|
|
426
|
+
|
|
427
|
+
```
|
|
428
|
+
found 2 candidate host(s) for 7f2477c7ea675004...
|
|
429
|
+
+ 127.0.0.1:9202: possession verified (3/3 chunks), price=0 sat, reputation=0.00, avg_latency=3.4ms
|
|
430
|
+
+ 127.0.0.1:9203: possession verified (3/3 chunks), price=500 sat, reputation=0.00, avg_latency=0.7ms
|
|
431
|
+
selected 127.0.0.1:9202 — price 0 sat, reputation 0.00, 3.4ms avg
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
Ties on reputation, cheapest wins — correct. Then manually seeded
|
|
435
|
+
contrasting reputations (hostA bad, hostB good) to prove reputation
|
|
436
|
+
actually *overrides* price rather than the selection just always
|
|
437
|
+
defaulting to cheapest:
|
|
438
|
+
|
|
439
|
+
```
|
|
440
|
+
+ 127.0.0.1:9202: ... price=0 sat, reputation=0.10 ...
|
|
441
|
+
+ 127.0.0.1:9203: ... price=500 sat, reputation=1.00 ...
|
|
442
|
+
selected 127.0.0.1:9203 — price 500 sat, reputation 1.00, 0.4ms avg
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
Picked the pricier, more trusted host. Then killed the free host outright
|
|
446
|
+
and re-ran with `--lightning`: real HTLC settled (500 sat, preimage
|
|
447
|
+
independently re-verified against the invoice's own `r_hash` via
|
|
448
|
+
`lncli listinvoices`, not just trusting the printed claim), download
|
|
449
|
+
proceeded, byte-identical against the source via `cmp`.
|
|
450
|
+
|
|
451
|
+
```bash
|
|
452
|
+
python3 weed.py download <content_hash> --relay http://127.0.0.1:9101 \
|
|
453
|
+
--rounds 5 --lightning --out downloaded.mp4
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
`host --price N` sets what a host charges (sats, default free — `PRICE` is
|
|
457
|
+
a new wire-protocol verb, backward compatible: a host that doesn't
|
|
458
|
+
implement it just gets treated as free by an older/newer client either way).
|
|
459
|
+
|
|
460
|
+
### Transitive trust — real attestations flow through the subscribe graph
|
|
461
|
+
|
|
462
|
+
The gap named right after the auction landed: `select_host` was only ever
|
|
463
|
+
passed *your own* direct history, never other people's signed vouches, so
|
|
464
|
+
a host you'd genuinely never dealt with always scored a flat 0.0 no matter
|
|
465
|
+
who else had already verified it. `poc_reputation.py`'s attestation/
|
|
466
|
+
revocation machinery could support exactly this — it just was never fed
|
|
467
|
+
anything, since attestations were never gossiped through a relay the way
|
|
468
|
+
publish/like/subscribe already were.
|
|
469
|
+
|
|
470
|
+
Considered adopting real PGP for this (Ryan asked) — decided against it:
|
|
471
|
+
PGP's actual trust-level/path-counting *idea* (marginal vs full trust,
|
|
472
|
+
computed transitively) is worth borrowing, but the OpenPGP *format* is
|
|
473
|
+
built for signing emails, not cheaply gossiping dozens of small JSON
|
|
474
|
+
events, and its classic path to real interoperability — public keyservers
|
|
475
|
+
— reintroduces exactly the single-point-of-failure problem discovery.py's
|
|
476
|
+
relay design exists to avoid. Built the trust-level idea on the Ed25519
|
|
477
|
+
signing already in place instead of adopting the standard.
|
|
478
|
+
|
|
479
|
+
`build_trust_graph()`: real BFS outward from your own pubkey through real
|
|
480
|
+
signed `subscribe` events pulled from a relay (a subscribe *is* a trust
|
|
481
|
+
edge — same insight from the original discovery-layer design, now actually
|
|
482
|
+
computed transitively instead of 1-hop-only). Trust decays per hop
|
|
483
|
+
(default 0.5×) — a friend counts fully, a friend-of-a-friend counts less.
|
|
484
|
+
Takes the shortest path to each reachable pubkey, not the sum across every
|
|
485
|
+
path — summing would let a sybil ring inflate a target's trust just by
|
|
486
|
+
adding more low-value paths to it.
|
|
487
|
+
|
|
488
|
+
`download_with_auction` now also pulls real `attestation` events from
|
|
489
|
+
relays (new event type — no relay code changes needed, `discovery_relay.py`
|
|
490
|
+
already verifies and filters by type generically), verifies each one, and
|
|
491
|
+
feeds them into `select_host` alongside the trust graph. And after every
|
|
492
|
+
successful download it publishes its own outcome as a real signed
|
|
493
|
+
attestation, not just recording it locally — so the next person who trusts
|
|
494
|
+
*you*, even transitively, benefits without ever dealing with that host
|
|
495
|
+
first.
|
|
496
|
+
|
|
497
|
+
Real end-to-end proof, not just the math: identity A downloaded from a
|
|
498
|
+
host directly (0.00 reputation, no history, same as before this change),
|
|
499
|
+
which auto-published a real attestation. A second identity, ROOT, who had
|
|
500
|
+
**never talked to that host**, subscribed to A (one real signed edge), then
|
|
501
|
+
ran `download`:
|
|
502
|
+
|
|
503
|
+
```
|
|
504
|
+
trust graph: 1 pubkey(s) reachable within 3 hop(s) of your own subscribes
|
|
505
|
+
pulled 1/1 real attestation(s) from relays (others' vouches, weighted by your trust in whoever signed them)
|
|
506
|
+
+ 127.0.0.1:9204: possession verified (3/3 chunks), price=0 sat, reputation=1.00 (1 attestation(s), weighted by signer trust + age), avg_latency=0.4ms
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
Would've been a flat `reputation=0.00` before this change — ROOT had zero
|
|
510
|
+
direct history with the host. Instead it inherited A's real, already-
|
|
511
|
+
verified experience through one real hop of trust. Byte-identical download
|
|
512
|
+
confirmed via `cmp`, same as every other download in this repo.
|
|
513
|
+
|
|
514
|
+
Separately verified the decay math itself against a real 3-hop chain
|
|
515
|
+
(root→A→B→C) plus a disconnected stranger: `A=0.5, B=0.25, C=0.125`,
|
|
516
|
+
stranger absent from the graph entirely, `max_hops` correctly bounding how
|
|
517
|
+
far it searches — exact, not approximate.
|
|
518
|
+
|
|
519
|
+
### `tunnel_relay.py` + persistent sessions in `node.py` — NAT traversal
|
|
520
|
+
|
|
521
|
+
`host --advertise-host`'s own help text used to say it outright: "no NAT
|
|
522
|
+
traversal here." Real gap — almost nobody has a directly reachable
|
|
523
|
+
inbound port. Fixed with a relay-mediated tunnel rather than real
|
|
524
|
+
STUN/ICE hole-punching: works behind *any* NAT including CGNAT (both
|
|
525
|
+
sides only ever make outbound connections, so there's nothing for a
|
|
526
|
+
firewall to block), at the honest cost of relay bandwidth/latency and
|
|
527
|
+
someone having to run `tunnel_relay.py` somewhere reachable — same
|
|
528
|
+
operational shape as already running a discovery relay, not a new kind of
|
|
529
|
+
problem.
|
|
530
|
+
|
|
531
|
+
Rendezvous protocol (hand-rolled to match this repo's own line-based wire
|
|
532
|
+
protocol rather than pulling in an external tunnel tool like `bore`):
|
|
533
|
+
a NAT'd host opens one persistent outbound `REGISTER <token>` control
|
|
534
|
+
connection; a downloader connects and sends `CONNECT <token>`; the relay
|
|
535
|
+
asks the host to dial back (`NEWSTREAM <stream_id>` on the control
|
|
536
|
+
channel, `DATA <stream_id>` as the reply) and, once paired, does nothing
|
|
537
|
+
but shovel raw bytes between the two sockets — same "dumb relay, no
|
|
538
|
+
opinion on the payload" design `discovery_relay.py` already uses, just
|
|
539
|
+
for bytes instead of signed JSON events. `token` is the archive's
|
|
540
|
+
`content_hash`; the relay has no idea what it means, same as everywhere
|
|
541
|
+
else in this repo that keys off it.
|
|
542
|
+
|
|
543
|
+
Had to fix a real prerequisite bug first, not just add the relay: every
|
|
544
|
+
wire-protocol command (`INFO`, `LEAVES`, one `FETCH` per chunk — 3324 of
|
|
545
|
+
them for the real archive) used to open a brand-new TCP connection.
|
|
546
|
+
Cheap directly, fatal through a relay — every single chunk would pay a
|
|
547
|
+
full rendezvous round-trip before any bytes moved. Fixed by giving both
|
|
548
|
+
sides a persistent session (`HostConnection` client-side, `serve_session`
|
|
549
|
+
host-side, looping over many commands per connection instead of one) —
|
|
550
|
+
a net win for direct connections too, not just a tunnel workaround.
|
|
551
|
+
|
|
552
|
+
Second real bug, caught while verifying this against real sockets, not
|
|
553
|
+
just in review: Python's `socketserver.ThreadingMixIn` closes a
|
|
554
|
+
connection's socket the instant its handler function *returns* — a
|
|
555
|
+
handler that spawns a pipe thread and returns immediately gets its own
|
|
556
|
+
socket killed out from under that thread mid-transfer. Fixed by having
|
|
557
|
+
each handler thread block for the tunneled session's full lifetime
|
|
558
|
+
(`_Pairing`'s `ready`/`done` events in `tunnel_relay.py`) instead of
|
|
559
|
+
firing off detached threads and returning early. First attempt failed
|
|
560
|
+
with `json.decoder.JSONDecodeError: Expecting value` — an empty response,
|
|
561
|
+
not a corrupted one, which is exactly what a socket closed mid-read looks
|
|
562
|
+
like.
|
|
563
|
+
|
|
564
|
+
Real end-to-end proof: hosted the real 217MB/3324-chunk archive,
|
|
565
|
+
advertised at a deliberately unreachable address (`10.255.255.1`, not
|
|
566
|
+
localhost) so there was no possibility of a direct connection carrying
|
|
567
|
+
the download, `--tunnel 127.0.0.1:9199`. Downloaded entirely through the
|
|
568
|
+
tunnel, `cmp`-verified byte-identical against the source, 3324 chunks in
|
|
569
|
+
1.4s — comparable to a direct download, not a meaningfully slower path.
|
|
570
|
+
|
|
571
|
+
```bash
|
|
572
|
+
# terminal 1
|
|
573
|
+
python3 discovery_relay.py 9101
|
|
574
|
+
# terminal 2
|
|
575
|
+
python3 tunnel_relay.py 9199
|
|
576
|
+
# terminal 3 — no reachable --advertise-host at all
|
|
577
|
+
python3 weed.py host real_archive --port 9201 --tunnel 127.0.0.1:9199 \
|
|
578
|
+
--relay http://127.0.0.1:9101 --advertise-host 10.255.255.1
|
|
579
|
+
# terminal 4
|
|
580
|
+
python3 weed.py download <content_hash> --relay http://127.0.0.1:9101 --out downloaded.mp4
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
Additive, backward-compatible protocol change, same shape as the
|
|
584
|
+
optional `PRICE` wire verb: `publish()` gained an optional `tunnel`
|
|
585
|
+
field (`'relay_host:relay_port'` or absent); a candidate without it is
|
|
586
|
+
just connected to directly, exactly as before this existed.
|
|
587
|
+
|
|
588
|
+
**TLS**, for tunnel relays that terminate it at the edge instead of
|
|
589
|
+
speaking it themselves (deployed one on Fly with `handlers = ["tls"]` —
|
|
590
|
+
`fly.tunnel-relay.toml` + `Dockerfile.tunnel-relay` in this repo): prefix
|
|
591
|
+
the address with `tls://` — `--tunnel tls://tunnel.example.com:9199`.
|
|
592
|
+
`tunnel_relay.py` itself never changes; edge termination decrypts before
|
|
593
|
+
the bytes ever reach it, so only the two ends that actually cross the
|
|
594
|
+
public internet (the host's `REGISTER`/`DATA` connections, a
|
|
595
|
+
downloader's `CONNECT`) wrap the socket in
|
|
596
|
+
`ssl.create_default_context()` — real CA validation by default, not a
|
|
597
|
+
weakened check. Verified against a real self-signed-cert TLS-terminating
|
|
598
|
+
proxy built specifically to test this (mimicking exactly what Fly's edge
|
|
599
|
+
does): confirmed the client correctly *rejects* an untrusted cert before
|
|
600
|
+
trusting it, then a full host→tunnel→download round-trip over the
|
|
601
|
+
encrypted path, byte-identical result.
|
|
602
|
+
|
|
603
|
+
**Heartbeat**, for the same idle-connection problem real deployments
|
|
604
|
+
actually hit: the `REGISTER` control connection sends nothing between
|
|
605
|
+
registering and the first real download, sometimes for a long time.
|
|
606
|
+
Fly's own edge (confirmed from real production logs, not a guess) resets
|
|
607
|
+
TCP connections idle more than a few minutes, which silently
|
|
608
|
+
unregistered the host with no error until the next download failed.
|
|
609
|
+
Fixed with a small periodic `PING` on the control connection —
|
|
610
|
+
`tunnel_relay.py` needed zero changes, its `REGISTER` loop already
|
|
611
|
+
discards anything it receives that isn't relevant. Proved it against a
|
|
612
|
+
real idle-enforcing test server (3-second idle limit): without the
|
|
613
|
+
heartbeat, killed at exactly 3.0s; with it, survived 8 full seconds with
|
|
614
|
+
no kill event at all.
|
|
615
|
+
|
|
616
|
+
### `dht.py` — real Kademlia DHT discovery, no relay required
|
|
617
|
+
|
|
618
|
+
Every other discovery path in this repo needs a relay URL, told to you
|
|
619
|
+
out of band — real, and a real limitation, until now: `dht.py` answers
|
|
620
|
+
"how do two nodes find each other with no shared server" using an actual
|
|
621
|
+
Kademlia DHT, via the real `kademlia` PyPI library rather than
|
|
622
|
+
reimplementing node-IDs/k-buckets/RPC routing from scratch. Scoped
|
|
623
|
+
honestly: this covers `announce(content_hash, host_addr)` /
|
|
624
|
+
`lookup(content_hash)` — not the richer signed-event system
|
|
625
|
+
(publish/like/subscribe/attestation/trust-graph) `discovery_relay.py`
|
|
626
|
+
already handles, since Kademlia's plain key→value store isn't a natural
|
|
627
|
+
fit for an append-only event log. That richer system staying on relays
|
|
628
|
+
is a deliberate scope boundary, not an oversight.
|
|
629
|
+
|
|
630
|
+
Multiple announcers of the same content are merged, not overwritten —
|
|
631
|
+
`kademlia`'s `set()` is single-value-per-key, so a naive announce would
|
|
632
|
+
silently drop everyone else's listing; `_announce()` fetches, merges,
|
|
633
|
+
re-announcing from the same host again correctly doesn't duplicate.
|
|
634
|
+
|
|
635
|
+
Proved with three separate, escalating real tests: three chained nodes
|
|
636
|
+
(C only ever bootstrapped through B, never spoke to A directly) — content
|
|
637
|
+
announced on A was found from C, real Kademlia routing, not a shared-
|
|
638
|
+
memory illusion. Two different hosts announcing the same content — both
|
|
639
|
+
preserved. And the strongest one: a node announced content, then that
|
|
640
|
+
node's *entire process exited* — a completely independent fourth process,
|
|
641
|
+
knowing only the original bootstrap node, still found what was announced.
|
|
642
|
+
Real value replication surviving the announcer going offline, which is
|
|
643
|
+
the actual point of a DHT over a relay.
|
|
644
|
+
|
|
645
|
+
```bash
|
|
646
|
+
python3 dht.py 8468 # first node, new swarm
|
|
647
|
+
python3 dht.py 8469 127.0.0.1:8468 # second node, joins the first
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
Or from the shell: `dht start [port] [bootstrap_host:port]`,
|
|
651
|
+
`dht announce <content_hash> <host:port> [title]`, `dht lookup <content_hash>`.
|
|
652
|
+
|
|
653
|
+
### `web_ui.py` — local web UI
|
|
654
|
+
|
|
655
|
+
Hosting/discovering/downloading/liking/subscribing all required
|
|
656
|
+
memorizing `weed.py`'s CLI flags — real friction for anyone who isn't
|
|
657
|
+
already comfortable with argparse. `web_ui.py` is a small stdlib
|
|
658
|
+
`http.server`/`ThreadingHTTPServer` JSON API — same tool
|
|
659
|
+
`discovery_relay.py` already uses, no new dependency, still just the
|
|
660
|
+
three packages in `requirements.txt` — wrapping the exact same
|
|
661
|
+
`node.py` functions the CLI calls, no reimplemented protocol logic. The
|
|
662
|
+
frontend (`web/`) is a single static page, vanilla JS, no build step —
|
|
663
|
+
matches the rest of this repo's no-toolchain style.
|
|
664
|
+
|
|
665
|
+
Binds `127.0.0.1` by default on purpose: a local control surface, not
|
|
666
|
+
something meant to face the internet, and there's no auth built — same
|
|
667
|
+
"reachability is on you" honesty `--advertise-host`'s docs already apply
|
|
668
|
+
elsewhere. `--bind` widens it at your own risk.
|
|
669
|
+
|
|
670
|
+
Endpoints, all thin wrappers: `GET /api/whoami`, `GET /api/discover`,
|
|
671
|
+
`POST /api/host` (backgrounds `run_host_server`/`run_host_tunnel` the
|
|
672
|
+
same way `shell.py`'s `do_host` already does) + `GET /api/hosts`,
|
|
673
|
+
`POST /api/download` + `GET /api/download/<job_id>` for polling progress
|
|
674
|
+
(`download()` gained an optional `on_progress(idx, n_chunks)` callback,
|
|
675
|
+
default no-op, so CLI output is unaffected), `POST /api/like`,
|
|
676
|
+
`POST /api/subscribe`, `GET /api/reputation/<pubkey>`,
|
|
677
|
+
`GET /api/stream/<job_id>`, `GET /api/qr?data=...`, `GET /api/lan-url`.
|
|
678
|
+
|
|
679
|
+
Real end-to-end proof, not just the API responding: hosted a file
|
|
680
|
+
through the **Host** form, confirmed a second terminal's `weed discover`
|
|
681
|
+
actually saw it (proves the API called the real `node.publish`, not a
|
|
682
|
+
mock); downloaded through the **Downloads** form with a live-polling
|
|
683
|
+
progress bar, `cmp`-verified byte-identical; liked and subscribed
|
|
684
|
+
through the UI, confirmed the real signed events landed on the relay by
|
|
685
|
+
querying it directly.
|
|
686
|
+
|
|
687
|
+
**Streaming** (`/api/stream/<job_id>`) — the actual gap behind "play
|
|
688
|
+
media on my phone": the UI could already trigger a download, but the
|
|
689
|
+
bytes only ever landed on this server's disk, never reached the browser.
|
|
690
|
+
Real HTTP range support (`Accept-Ranges`, `206 Partial Content`, `416`
|
|
691
|
+
for out-of-range), so a `<video>` tag can seek instead of downloading
|
|
692
|
+
blind. Verified with real range requests against a real completed
|
|
693
|
+
download — full fetch, a mid-file range, and an open-ended range, all
|
|
694
|
+
byte-exact against the source; error paths (out-of-range, unknown job)
|
|
695
|
+
checked too.
|
|
696
|
+
|
|
697
|
+
**QR codes** (`/api/qr`, terminal QR on startup) — same `qrcode` package
|
|
698
|
+
`ott`'s own `ott qr` already uses. `--bind 0.0.0.0` auto-detects the real
|
|
699
|
+
LAN IP (a UDP-route trick, no packet actually sent) instead of printing
|
|
700
|
+
the useless literal `0.0.0.0`, so the printed/scanned URL is one a phone
|
|
701
|
+
can actually reach — and it's computed server-side and exposed via
|
|
702
|
+
`/api/lan-url` specifically because the browser's own
|
|
703
|
+
`location.origin` lies the instant you load the page via `localhost`
|
|
704
|
+
instead of the LAN address; a real bug this caused (the header's "open
|
|
705
|
+
on phone" QR pointing at `127.0.0.1`) was reproduced and fixed by having
|
|
706
|
+
the client fetch the server's own answer instead of trusting
|
|
707
|
+
`location.origin`.
|
|
708
|
+
|
|
709
|
+
```bash
|
|
710
|
+
python3 weed.py serve # alias for `web`, positional args: serve [bind] [port]
|
|
711
|
+
python3 weed.py serve 0.0.0.0 8080 # reachable from your phone; prints a scan-to-open QR
|
|
712
|
+
# or, from the shell: `serve [bind] [port]`
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
### `shell.py` — interactive, tab-completing, same pattern as `ott`'s shell
|
|
716
|
+
|
|
717
|
+
`python3 weed.py` with no arguments (or `weed.py shell`) drops into an
|
|
718
|
+
interactive shell — same `cmd.Cmd` + readline pattern as `ott`'s own shell,
|
|
719
|
+
same conventions: short aliases (`w`/`h`/`r`/`disc`/`dl`/`l`/`sub`), `help`
|
|
720
|
+
or `?` for commands, `Ctrl-D` or `q` to exit, tab completes.
|
|
721
|
+
|
|
722
|
+
`relay` runs a real discovery relay in the background too, same pattern as
|
|
723
|
+
`host` — the whole flow (relay, host, discover, download, like, subscribe)
|
|
724
|
+
runs from one shell session, no second terminal required. Discovered
|
|
725
|
+
running it that way for real (`host` with no relay running produces
|
|
726
|
+
"unreachable, skipped, nothing found," `discover` alone can't conjure a
|
|
727
|
+
relay that isn't there — real friction that surfaced from actually using
|
|
728
|
+
it, not a hypothetical). `relay` also sets itself as the session's default
|
|
729
|
+
relay, so `discover`/`download`/`like`/`subscribe` don't need `--relay`
|
|
730
|
+
repeated every time. `host` also takes `--tunnel [tls://]RELAY_HOST:PORT`
|
|
731
|
+
now, same as the CLI — see `tunnel_relay.py` above. `serve [bind] [port]`
|
|
732
|
+
runs the web UI in the background without a second terminal — see
|
|
733
|
+
`web_ui.py` above. `dht start/announce/lookup` runs a real Kademlia node
|
|
734
|
+
in the background — see `dht.py` above. Every background command
|
|
735
|
+
(`host`, `relay`, `serve`, `dht start`) goes through a shared `_bg()`
|
|
736
|
+
wrapper now: a real failure inside one of those threads used to dump a
|
|
737
|
+
raw Python traceback into the middle of the prompt (an uncaught
|
|
738
|
+
exception in a background thread was never covered by `onecmd()`'s own
|
|
739
|
+
try/except, which only wraps the synchronous part of a command) — caught
|
|
740
|
+
live from a real `--tunnel ~/share` typo, fixed, reproduced the same
|
|
741
|
+
crash again afterward to confirm it now prints a clean `✗ ...` line
|
|
742
|
+
instead and the shell stays fully usable.
|
|
743
|
+
|
|
744
|
+
Completion resolves against real state, not a fixed list — same idea as
|
|
745
|
+
`ott`'s completions (which complete against the real archive). `download`
|
|
746
|
+
and `like` tab-complete against content hashes actually seen in the last
|
|
747
|
+
`discover`; `subscribe` completes against pubkeys actually seen:
|
|
748
|
+
|
|
749
|
+
```
|
|
750
|
+
weed> relay
|
|
751
|
+
relay running on port 9101 in the background — set as your default relay
|
|
752
|
+
weed> host real_archive --relay http://127.0.0.1:9101
|
|
753
|
+
hosting real_video.mp4 on port 9201 in the background — shell still usable
|
|
754
|
+
weed> discover
|
|
755
|
+
'real_video.mp4' hash=7f2477c7ea675004... host=127.0.0.1:9201 by=409a15dcfc59...
|
|
756
|
+
weed> download 7f24<TAB>
|
|
757
|
+
7f2477c7ea675004ad5dbab6dc7c44327c724b880cc389807df1965b77966acc
|
|
758
|
+
weed> download 7f2477c7ea675004ad5dbab6dc7c44327c724b880cc389807df1965b77966acc
|
|
759
|
+
3324 chunks downloaded and verified in 1.4s
|
|
760
|
+
```
|
|
761
|
+
|
|
762
|
+
`host` runs the server in a background thread instead of blocking the
|
|
763
|
+
shell — genuinely new, not copied from `ott`, since nothing in `ott`
|
|
764
|
+
blocks forever the way a hosting server does. Ran this exact sequence for
|
|
765
|
+
real (scripted, not just described): host → discover → download →
|
|
766
|
+
`cmp`-verified byte-identical → `like`, all in one shell session, download
|
|
767
|
+
still finishing in 1.4s with the server running in the background thread
|
|
768
|
+
the whole time.
|
|
769
|
+
|
|
770
|
+
## Running it
|
|
771
|
+
|
|
772
|
+
`./weed.py --help` (or `weed.py lightning --help` for the nested ones) is
|
|
773
|
+
the friendliest entry point — it's a thin argparse wrapper over the
|
|
774
|
+
Makefile, same targets, real subcommands and `--help` text instead of
|
|
775
|
+
needing to remember `make` target names. `make help` lists the same
|
|
776
|
+
targets directly. Or run any command below on its own:
|
|
777
|
+
|
|
778
|
+
```bash
|
|
779
|
+
python3 poc_challenge_auction.py # in-process, Parts 1 + 2, narrated
|
|
780
|
+
python3 poc_network_challenge.py # real sockets, single-shot rounds, loopback
|
|
781
|
+
python3 poc_network_challenge.py stats # real sockets, repeated-challenge separation, loopback
|
|
782
|
+
python3 poc_reputation.py # real Ed25519 signing/verification demo
|
|
783
|
+
python3 viz_challenge_separation.py # regenerates the chart above from fresh data
|
|
784
|
+
docker compose up --build --abort-on-container-exit verifier # same test, real containers
|
|
785
|
+
cd lightning && docker compose up -d && cd .. # real bitcoind + 2 LND nodes (see lightning/README.md for setup)
|
|
786
|
+
python3 poc_challenge_auction.py --lightning # same auction, real HTLC settlement
|
|
787
|
+
python3 poc_real_archive_challenge.py # same challenge mechanism, real 3324-chunk video
|
|
788
|
+
python3 poc_discovery.py # 3 real relays, personalized ranking, sybil test
|
|
789
|
+
python3 tunnel_relay.py 9199 # NAT-traversal relay — see host --tunnel below
|
|
790
|
+
python3 weed.py serve --bind 0.0.0.0 --port 8080 # local web UI, reachable from your phone
|
|
791
|
+
python3 dht.py 8468 # real Kademlia DHT node — see dht.py above
|
|
792
|
+
```
|
|
793
|
+
|
|
794
|
+
`pip install -r requirements.txt` gets everything (`btcvm`, `cryptography`,
|
|
795
|
+
`matplotlib`, `kademlia`). Broken down: `poc_challenge_auction.py` (and
|
|
796
|
+
`poc_real_archive_challenge.py`, which imports from it) needs `btcvm`;
|
|
797
|
+
`poc_reputation.py` and `poc_discovery.py` (which imports from it) need
|
|
798
|
+
`cryptography`; `viz_challenge_separation.py` needs `matplotlib`;
|
|
799
|
+
`dht.py` needs `kademlia`. `poc_network_challenge.py` and
|
|
800
|
+
`discovery_relay.py` are pure stdlib, no install needed. `qrcode` is
|
|
801
|
+
optional (`pip install qrcode`) — `web_ui.py`'s QR endpoints degrade to a
|
|
802
|
+
plain URL, printed instead of rendered, if it's missing. Docker/Compose
|
|
803
|
+
needed for the container-network test and for `--lightning` (real
|
|
804
|
+
bitcoind + LND, see `lightning/README.md`).
|
|
805
|
+
|
|
806
|
+
Or, packaged: `pip install -e .` (see `pyproject.toml`) installs a real
|
|
807
|
+
`weed` command on your `PATH` instead of `python3 weed.py`.
|
|
808
|
+
|
|
809
|
+
## Next steps
|
|
810
|
+
|
|
811
|
+
1. ~~Nonce-salted challenge + timing bound~~ — done, `poc_challenge_auction.py` Part 2
|
|
812
|
+
2. ~~Real network round-trip instead of in-process~~ — done, `poc_network_challenge.py`
|
|
813
|
+
3. ~~Local reputation + signed portable attestations~~ — done, `poc_reputation.py`
|
|
814
|
+
4. ~~Real WAN calibration against an actual second machine~~ — done, real
|
|
815
|
+
RunPod box over an SSH tunnel: ~1700x gap, separates at k=1
|
|
816
|
+
5. ~~Real Lightning HTLC settlement~~ — done, `lightning_settle.py` +
|
|
817
|
+
`lightning/` (real bitcoind + 2 LND nodes, real BOLT11 invoices, real
|
|
818
|
+
preimage reveal independently re-verified). Regtest, not public testnet —
|
|
819
|
+
same reasoning as #4: real protocol code, skip the wait on chain
|
|
820
|
+
sync/faucets.
|
|
821
|
+
6. ~~Point the mechanism at a real `.ott` archive~~ — done,
|
|
822
|
+
`poc_real_archive_challenge.py`: real 217MB video, 3324 real chunks,
|
|
823
|
+
12-step proofs (~400B), confirmed O(log N) not linear.
|
|
824
|
+
7. ~~Attestation revocation~~ — done, `poc_reputation.py`: signer-only
|
|
825
|
+
revocation keyed to `attestation_id`, forged revocation from a different
|
|
826
|
+
signer correctly rejected, revoked attestation kept on record not deleted.
|
|
827
|
+
8. ~~Discovery layer~~ — done, `discovery_relay.py` + `poc_discovery.py`:
|
|
828
|
+
3 independent dumb relays, personalized client-side ranking from each
|
|
829
|
+
client's own subscribe graph, sybil-resistant (20 fake identities move
|
|
830
|
+
neither client's score), real relay-death test (content survives,
|
|
831
|
+
anything posted only to the dead relay doesn't — redundancy isn't free).
|
|
832
|
+
9. ~~NAT traversal~~ — done, `tunnel_relay.py`: relay-mediated rendezvous
|
|
833
|
+
(not real STUN/ICE hole-punching), real 217MB/3324-chunk archive
|
|
834
|
+
downloaded end-to-end through the tunnel with the host advertised at
|
|
835
|
+
an unreachable address, byte-identical. Required a persistent-session
|
|
836
|
+
refactor of `node.py`'s wire protocol first — see above.
|
|
837
|
+
10. ~~Local UI~~ — done, `web_ui.py` + `web/`: stdlib JSON API wrapping
|
|
838
|
+
the same `node.py` functions the CLI calls, static vanilla-JS
|
|
839
|
+
frontend, no new dependency. Host/discover/download/like/subscribe
|
|
840
|
+
all verified working from the browser, not just the API responding.
|
|
841
|
+
11. ~~Real P2P/DHT discovery~~ — done, `dht.py`: real Kademlia via the
|
|
842
|
+
`kademlia` library, not reimplemented. Content survived the
|
|
843
|
+
announcing node's process exiting entirely, found by a fourth,
|
|
844
|
+
independent process that only knew the original bootstrap node — real
|
|
845
|
+
value replication, not a two-party memory trick. Scoped honestly:
|
|
846
|
+
covers host-discovery only, not the richer event system.
|
|
847
|
+
12. ~~TLS for tunnel relays~~ — done, `tls://` prefix on `--tunnel`,
|
|
848
|
+
verified against a real self-signed-cert TLS-terminating proxy built
|
|
849
|
+
specifically to test it, full host→tunnel→download round-trip over
|
|
850
|
+
the encrypted path.
|
|
851
|
+
13. ~~Play media from the web UI~~ — done, `/api/stream` with real HTTP
|
|
852
|
+
range support, verified byte-exact against full/mid-file/open-ended
|
|
853
|
+
range requests.
|
|
854
|
+
14. ~~Multi-file hosting~~ — done, `host <dir>` (no `--file`) serves every
|
|
855
|
+
archived file over one port, downloader `SELECT`s by content hash.
|
|
856
|
+
Fixes the original bug: a 45-file archive silently collapsed to
|
|
857
|
+
whichever file happened to be last in the manifest.
|
|
858
|
+
|
|
859
|
+
Every item on the original roadmap is now built and verified against real
|
|
860
|
+
output, not just designed — and `node.py` (below) wires host/discover/
|
|
861
|
+
download/like/subscribe into one real tool instead of six disconnected
|
|
862
|
+
demos. What's left is scaling and hardening this, not proving the
|
|
863
|
+
mechanisms work — see each section above for the honest edges that are
|
|
864
|
+
still real constraints even though the core ideas held up: loopback
|
|
865
|
+
timing separation isn't airtight without averaging, relay death loses
|
|
866
|
+
non-redundant data, RunPod flakiness, regtest-only Lightning, the DHT
|
|
867
|
+
covers host-discovery but not the richer signed-event system, the tunnel
|
|
868
|
+
relay (even with TLS) is still a single point of failure/bandwidth cost
|
|
869
|
+
with no redundancy story the way discovery relays have, and the web UI
|
|
870
|
+
has no auth, local-only by design.
|