weed-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dht.py +137 -0
- discovery_relay.py +94 -0
- lightning_settle.py +90 -0
- node.py +784 -0
- poc_reputation.py +311 -0
- shell.py +420 -0
- tunnel_relay.py +204 -0
- web_ui.py +444 -0
- weed.py +238 -0
- weed_cli-0.1.0.dist-info/METADATA +870 -0
- weed_cli-0.1.0.dist-info/RECORD +14 -0
- weed_cli-0.1.0.dist-info/WHEEL +5 -0
- weed_cli-0.1.0.dist-info/entry_points.txt +2 -0
- weed_cli-0.1.0.dist-info/top_level.txt +9 -0
dht.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
dht.py — real Kademlia DHT discovery, no relay involved.
|
|
4
|
+
|
|
5
|
+
Uses the real `kademlia` library (pip install kademlia) rather than
|
|
6
|
+
reimplementing Kademlia's node-ID/k-bucket/RPC machinery — that's exactly
|
|
7
|
+
the kind of already-solved distributed-systems problem worth reusing, not
|
|
8
|
+
rebuilding, especially for a PoC.
|
|
9
|
+
|
|
10
|
+
Scoped deliberately: this answers "how do two nodes find each other
|
|
11
|
+
without a shared relay" — announce(content_hash, host_addr) /
|
|
12
|
+
lookup(content_hash) — not the richer signed-event system (publish/like/
|
|
13
|
+
subscribe/attestation) discovery_relay.py already handles. Merging that
|
|
14
|
+
into DHT value storage is a separate, bigger design question (Kademlia's
|
|
15
|
+
plain key→value store isn't naturally an append-only event log); kept out
|
|
16
|
+
of this file on purpose. Every DHT node is a full peer — there's no
|
|
17
|
+
separate "relay" role here, only who happened to join first.
|
|
18
|
+
"""
|
|
19
|
+
import asyncio
|
|
20
|
+
import json
|
|
21
|
+
import threading
|
|
22
|
+
|
|
23
|
+
from kademlia.network import Server
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def _announce(server, content_hash, host_addr, title=None):
|
|
27
|
+
"""Merge this announcer into the existing list under content_hash
|
|
28
|
+
instead of overwriting it — multiple peers can host the same content,
|
|
29
|
+
and a blind set() would silently drop everyone else's announcement.
|
|
30
|
+
Not race-free under concurrent announces to the same key (last write
|
|
31
|
+
still wins on a true conflict) — a real CRDT merge is out of scope."""
|
|
32
|
+
existing_raw = await server.get(content_hash)
|
|
33
|
+
entries = json.loads(existing_raw) if existing_raw else []
|
|
34
|
+
entries = [e for e in entries if e.get('host') != host_addr] # replace any stale self-entry
|
|
35
|
+
entries.append({'host': host_addr, 'title': title})
|
|
36
|
+
await server.set(content_hash, json.dumps(entries))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def _lookup(server, content_hash):
|
|
40
|
+
raw = await server.get(content_hash)
|
|
41
|
+
return json.loads(raw) if raw else []
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class DHTNode:
|
|
45
|
+
"""Runs a real kademlia.network.Server in its own thread with its own
|
|
46
|
+
asyncio event loop, and exposes plain synchronous announce()/lookup()
|
|
47
|
+
methods any other thread (the shell, a CLI command) can call directly
|
|
48
|
+
— kademlia's Server is asyncio-only, but nothing else in this repo's
|
|
49
|
+
background-thread model (relay/host/web UI) is, so this is the
|
|
50
|
+
adapter, not a rewrite of everything else to asyncio."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, port, bootstrap_nodes=None, quiet=False):
|
|
53
|
+
self.port = port
|
|
54
|
+
self.bootstrap_nodes = bootstrap_nodes or []
|
|
55
|
+
self.quiet = quiet
|
|
56
|
+
self.loop = None
|
|
57
|
+
self.server = None
|
|
58
|
+
self._ready = threading.Event()
|
|
59
|
+
self._error = None
|
|
60
|
+
|
|
61
|
+
def start(self):
|
|
62
|
+
t = threading.Thread(target=self._run, daemon=True)
|
|
63
|
+
t.start()
|
|
64
|
+
if not self._ready.wait(timeout=15):
|
|
65
|
+
raise RuntimeError('DHT node did not finish starting within 15s')
|
|
66
|
+
if self._error:
|
|
67
|
+
raise self._error
|
|
68
|
+
return t
|
|
69
|
+
|
|
70
|
+
def _run(self):
|
|
71
|
+
try:
|
|
72
|
+
self.loop = asyncio.new_event_loop()
|
|
73
|
+
asyncio.set_event_loop(self.loop)
|
|
74
|
+
self.server = Server()
|
|
75
|
+
self.loop.run_until_complete(self._setup())
|
|
76
|
+
except Exception as e:
|
|
77
|
+
self._error = e
|
|
78
|
+
self._ready.set()
|
|
79
|
+
return
|
|
80
|
+
self._ready.set()
|
|
81
|
+
self.loop.run_forever()
|
|
82
|
+
|
|
83
|
+
async def _setup(self):
|
|
84
|
+
await self.server.listen(self.port)
|
|
85
|
+
if self.bootstrap_nodes:
|
|
86
|
+
await self.server.bootstrap(self.bootstrap_nodes)
|
|
87
|
+
if not self.quiet:
|
|
88
|
+
joined = f', bootstrapped via {self.bootstrap_nodes}' if self.bootstrap_nodes else \
|
|
89
|
+
' (first node in a new swarm)'
|
|
90
|
+
print(f'[dht:{self.port}] node listening{joined}', flush=True)
|
|
91
|
+
|
|
92
|
+
def announce(self, content_hash, host_addr, title=None, timeout=15):
|
|
93
|
+
fut = asyncio.run_coroutine_threadsafe(
|
|
94
|
+
_announce(self.server, content_hash, host_addr, title), self.loop)
|
|
95
|
+
return fut.result(timeout=timeout)
|
|
96
|
+
|
|
97
|
+
def lookup(self, content_hash, timeout=15):
|
|
98
|
+
fut = asyncio.run_coroutine_threadsafe(_lookup(self.server, content_hash), self.loop)
|
|
99
|
+
return fut.result(timeout=timeout)
|
|
100
|
+
|
|
101
|
+
def stop(self):
|
|
102
|
+
if self.loop and self.server:
|
|
103
|
+
self.loop.call_soon_threadsafe(self.server.stop)
|
|
104
|
+
self.loop.call_soon_threadsafe(self.loop.stop)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def run_dht_node(port, bootstrap_nodes=None, quiet=False):
|
|
108
|
+
"""Blocking entry point, same contract as run_relay_server/
|
|
109
|
+
run_host_server — call this directly for a foreground CLI process, or
|
|
110
|
+
hand it to threading.Thread(target=...) to run in the background."""
|
|
111
|
+
node = DHTNode(port, bootstrap_nodes=bootstrap_nodes, quiet=quiet)
|
|
112
|
+
t = threading.Thread(target=node._run, daemon=True)
|
|
113
|
+
t.start()
|
|
114
|
+
t.join()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _parse_bootstrap(spec):
|
|
118
|
+
"""'host:port' or 'host:port,host:port' -> [(host, port), ...], or
|
|
119
|
+
None if spec is falsy."""
|
|
120
|
+
if not spec:
|
|
121
|
+
return None
|
|
122
|
+
out = []
|
|
123
|
+
for part in spec.split(','):
|
|
124
|
+
host, port_s = part.rsplit(':', 1)
|
|
125
|
+
out.append((host, int(port_s)))
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def main():
|
|
130
|
+
import sys
|
|
131
|
+
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8468
|
|
132
|
+
bootstrap = _parse_bootstrap(sys.argv[2]) if len(sys.argv) > 2 else None
|
|
133
|
+
run_dht_node(port, bootstrap_nodes=bootstrap)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
if __name__ == '__main__':
|
|
137
|
+
main()
|
discovery_relay.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Roadmap item 8: discovery. The design from the brainstorm was "no single
|
|
4
|
+
canonical index — gossiped signed events, any number of independent
|
|
5
|
+
replaceable relays, personalized ranking client-side," Nostr-style. This is
|
|
6
|
+
that, actually built and running, not just described.
|
|
7
|
+
|
|
8
|
+
A relay here is deliberately dumb: real stdlib HTTP server, verifies a
|
|
9
|
+
posted event's signature (cheap, uncontroversial — garbage in doesn't get
|
|
10
|
+
stored) but has NO opinion on content quality, no ranking, no single
|
|
11
|
+
"trending" list. It just stores what it's given and serves it back on
|
|
12
|
+
request. That's the whole point: any number of these can run independently,
|
|
13
|
+
none of them are load-bearing on their own, and a client is expected to
|
|
14
|
+
query several and merge — see poc_discovery.py for the client side.
|
|
15
|
+
|
|
16
|
+
Event shapes (all just signed JSON blobs, reusing poc_reputation.py's
|
|
17
|
+
Ed25519 signing):
|
|
18
|
+
publish {content_hash, title} — a creator announces content
|
|
19
|
+
like {content_hash} — a viewer signals approval
|
|
20
|
+
subscribe {target_pubkey} — a viewer follows a creator/signer
|
|
21
|
+
"""
|
|
22
|
+
import json
|
|
23
|
+
import sys
|
|
24
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
25
|
+
from urllib.parse import urlparse, parse_qs
|
|
26
|
+
|
|
27
|
+
sys.path.insert(0, __import__('os').path.dirname(__import__('os').path.abspath(__file__)))
|
|
28
|
+
from poc_reputation import verify_attestation, attestation_id
|
|
29
|
+
|
|
30
|
+
_events = [] # in-memory store — a real relay would use a real DB; irrelevant to the design
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RelayHandler(BaseHTTPRequestHandler):
|
|
34
|
+
def log_message(self, fmt, *args):
|
|
35
|
+
pass # quiet — poc_discovery.py prints what matters
|
|
36
|
+
|
|
37
|
+
def do_POST(self):
|
|
38
|
+
if self.path != '/event':
|
|
39
|
+
self.send_response(404); self.end_headers(); return
|
|
40
|
+
length = int(self.headers.get('Content-Length', 0))
|
|
41
|
+
try:
|
|
42
|
+
event = json.loads(self.rfile.read(length))
|
|
43
|
+
ok, reason = verify_attestation(event) # generic: works on any {payload, signature} blob
|
|
44
|
+
except Exception as e:
|
|
45
|
+
ok, reason = False, f'malformed request: {e}'
|
|
46
|
+
if not ok:
|
|
47
|
+
self.send_response(400)
|
|
48
|
+
self.send_header('Content-Type', 'application/json')
|
|
49
|
+
self.end_headers()
|
|
50
|
+
self.wfile.write(json.dumps({'ok': False, 'reason': reason}).encode())
|
|
51
|
+
return
|
|
52
|
+
eid = attestation_id(event)
|
|
53
|
+
if not any(attestation_id(e) == eid for e in _events):
|
|
54
|
+
_events.append(event)
|
|
55
|
+
self.send_response(200)
|
|
56
|
+
self.send_header('Content-Type', 'application/json')
|
|
57
|
+
self.end_headers()
|
|
58
|
+
self.wfile.write(json.dumps({'ok': True, 'event_id': eid}).encode())
|
|
59
|
+
|
|
60
|
+
def do_GET(self):
|
|
61
|
+
if self.path.split('?')[0] != '/events':
|
|
62
|
+
self.send_response(404); self.end_headers(); return
|
|
63
|
+
qs = parse_qs(urlparse(self.path).query)
|
|
64
|
+
out = _events
|
|
65
|
+
if 'type' in qs:
|
|
66
|
+
out = [e for e in out if e['payload'].get('type') == qs['type'][0]]
|
|
67
|
+
self.send_response(200)
|
|
68
|
+
self.send_header('Content-Type', 'application/json')
|
|
69
|
+
self.end_headers()
|
|
70
|
+
self.wfile.write(json.dumps(out).encode())
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def run_relay_server(port, quiet=False):
|
|
74
|
+
"""Split out from main() so shell.py can run a relay in a background
|
|
75
|
+
thread — same pattern as node.run_host_server. quiet=True for the shell:
|
|
76
|
+
a background thread's print() races with cmd.Cmd's input()-driven
|
|
77
|
+
prompt on the same stdout with no coordination between them — readline
|
|
78
|
+
doesn't know to redraw the prompt when unrelated output shows up mid-
|
|
79
|
+
read, so the two interleave and the prompt looks like it "disappeared."
|
|
80
|
+
The shell already prints its own equivalent confirmation line, so this
|
|
81
|
+
fixes it at the source instead of patching the visual symptom."""
|
|
82
|
+
srv = ThreadingHTTPServer(('0.0.0.0', port), RelayHandler)
|
|
83
|
+
if not quiet:
|
|
84
|
+
print(f"[relay:{port}] up, no opinion on content, just store-and-forward", flush=True)
|
|
85
|
+
srv.serve_forever()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def main():
|
|
89
|
+
port = int(sys.argv[1]) if len(sys.argv) > 1 else 9101
|
|
90
|
+
run_relay_server(port)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
if __name__ == '__main__':
|
|
94
|
+
main()
|
lightning_settle.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Real Lightning HTLC settlement, replacing poc_challenge_auction.py's mock
|
|
4
|
+
"settlement: mock Lightning HTLC held until delivery confirms" print.
|
|
5
|
+
|
|
6
|
+
Talks to two real LND nodes (lnd-alice, lnd-bob — see lightning/docker-compose.yml)
|
|
7
|
+
over a real channel on regtest via `docker exec ... lncli`. Not simulated:
|
|
8
|
+
real BOLT11 invoices, real onion-routed HTLCs, real preimage reveal on
|
|
9
|
+
settlement — same protocol code LND runs on mainnet, just against a private
|
|
10
|
+
regtest chain instead of waiting on public testnet sync/faucets (same
|
|
11
|
+
reasoning as using a real remote box over SSH for the WAN latency test
|
|
12
|
+
rather than a fabricated one).
|
|
13
|
+
|
|
14
|
+
Requires the compose stack in lightning/ to be up with a funded, active
|
|
15
|
+
channel between alice and bob (see lightning/README.md for the one-time
|
|
16
|
+
setup: fund alice on-chain, open channel, confirm).
|
|
17
|
+
"""
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import subprocess
|
|
21
|
+
|
|
22
|
+
ALICE_CONTAINER = 'lightning-lnd-alice-1' # payer — stands in for the requester/viewer
|
|
23
|
+
BOB_CONTAINER = 'lightning-lnd-bob-1' # payee — stands in for the auction winner
|
|
24
|
+
LNDDIR = '/home/lnd/.lnd'
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SettlementError(RuntimeError):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _lncli(container, *args):
|
|
32
|
+
cmd = ['docker', 'exec', container, 'lncli', '--network=regtest', f'--lnddir={LNDDIR}', *args]
|
|
33
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
34
|
+
if result.returncode != 0:
|
|
35
|
+
raise SettlementError(f'lncli {args[0]} failed: {result.stderr.strip()}')
|
|
36
|
+
return json.loads(result.stdout)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def channel_active():
|
|
40
|
+
try:
|
|
41
|
+
chans = _lncli(ALICE_CONTAINER, 'listchannels')
|
|
42
|
+
except (SettlementError, FileNotFoundError):
|
|
43
|
+
return False
|
|
44
|
+
return any(c.get('active') for c in chans.get('channels', []))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def settle(amount_sat, memo):
|
|
48
|
+
"""Real HTLC settlement for one auction round's winning bid.
|
|
49
|
+
|
|
50
|
+
Bob (payee/winner) creates a real BOLT11 invoice; Alice (payer/
|
|
51
|
+
requester) pays it over the real channel. Returns a dict with the real
|
|
52
|
+
payment_hash, the real revealed preimage, and independently re-verifies
|
|
53
|
+
sha256(preimage) == payment_hash locally rather than trusting LND's own
|
|
54
|
+
claim of success — same "verify, don't just trust the tool's own report"
|
|
55
|
+
standard the rest of this project has used throughout.
|
|
56
|
+
"""
|
|
57
|
+
amount_sat = max(1, int(round(amount_sat)))
|
|
58
|
+
invoice = _lncli(BOB_CONTAINER, 'addinvoice', f'--amt={amount_sat}', f'--memo={memo}')
|
|
59
|
+
payment_request = invoice['payment_request']
|
|
60
|
+
expected_hash = invoice['r_hash']
|
|
61
|
+
|
|
62
|
+
payment = _lncli(ALICE_CONTAINER, 'payinvoice', '--force', '--json', payment_request)
|
|
63
|
+
if payment.get('status') != 'SUCCEEDED':
|
|
64
|
+
raise SettlementError(f'payment did not succeed: {payment.get("status")}')
|
|
65
|
+
|
|
66
|
+
preimage = payment['payment_preimage']
|
|
67
|
+
recomputed_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest()
|
|
68
|
+
if recomputed_hash != expected_hash:
|
|
69
|
+
raise SettlementError(
|
|
70
|
+
f'preimage does not match invoice payment_hash — '
|
|
71
|
+
f'got {recomputed_hash}, expected {expected_hash}')
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
'amount_sat': amount_sat,
|
|
75
|
+
'payment_hash': expected_hash,
|
|
76
|
+
'preimage': preimage,
|
|
77
|
+
'fee_sat': int(payment.get('fee_sat', 0)),
|
|
78
|
+
'verified_locally': True,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == '__main__':
|
|
83
|
+
# smoke test — run directly to confirm the channel is up before wiring
|
|
84
|
+
# it into the auction
|
|
85
|
+
if not channel_active():
|
|
86
|
+
print('no active channel found — bring up lightning/docker-compose.yml first')
|
|
87
|
+
raise SystemExit(1)
|
|
88
|
+
result = settle(1234, 'lightning_settle.py smoke test')
|
|
89
|
+
print(json.dumps(result, indent=2))
|
|
90
|
+
print('\npreimage independently re-hashed and matched the invoice payment_hash — real HTLC, verified.')
|