Add private BitTorrent tracker for Jirorian-whitelisted users
Pure Python HTTP tracker with NIP-98 passkey retrieval, HMAC passkeys, and info_hash validation against NIP-35 kind:2003 relay events. Relay allowlist extended with kinds 5, 2003, 2004. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c71f02a7c7
commit
e094720c68
4 changed files with 343 additions and 3 deletions
338
modules/services/torrent-tracker.nix
Normal file
338
modules/services/torrent-tracker.nix
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
{ pkgs, lib, config, ... }:
|
||||
let
|
||||
domain = "tracker.otherwhere.app";
|
||||
port = 8893;
|
||||
|
||||
python = pkgs.python3.withPackages (ps: [ ps.coincurve ]);
|
||||
|
||||
script = pkgs.writeText "torrent-tracker.py" ''
|
||||
"""
|
||||
Private BitTorrent HTTP tracker for otherwhere.app.
|
||||
|
||||
Passkey = HMAC-SHA256(tracker_secret, pubkey_hex), formatted as
|
||||
"<pubkey_hex>.<mac_hex>" (129 chars). Clients obtain their passkey via
|
||||
GET /key with NIP-98 auth. Announce URL:
|
||||
https://tracker.otherwhere.app/<passkey>/announce
|
||||
|
||||
Every announced info_hash must exist as a NIP-35 kind:2003 event in the
|
||||
relay DB, authored by a Jirorian-whitelisted pubkey.
|
||||
"""
|
||||
import base64, hashlib, hmac, json, os, socket, sqlite3
|
||||
import struct, threading, time
|
||||
from collections import defaultdict
|
||||
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
|
||||
from urllib.parse import urlparse, unquote_to_bytes
|
||||
import coincurve
|
||||
|
||||
TRACKER_SECRET = bytes.fromhex(os.environ["TRACKER_SECRET"])
|
||||
RELAY_DB = "/var/lib/nostr-rs-relay/nostr.db"
|
||||
JIRORIAN_DB = "/var/lib/jirorian/jirorian.db"
|
||||
PORT = ${toString port}
|
||||
DOMAIN = "${domain}"
|
||||
INTERVAL = 1800 # seconds between announces
|
||||
PEER_TTL = 3600 # drop peers silent for this long
|
||||
|
||||
|
||||
# ── Bencoding ──────────────────────────────────────────────────────────
|
||||
|
||||
def bencode(v) -> bytes:
|
||||
if isinstance(v, int):
|
||||
return f"i{v}e".encode()
|
||||
if isinstance(v, (bytes, bytearray)):
|
||||
return f"{len(v)}:".encode() + bytes(v)
|
||||
if isinstance(v, str):
|
||||
b = v.encode()
|
||||
return f"{len(b)}:".encode() + b
|
||||
if isinstance(v, list):
|
||||
return b"l" + b"".join(bencode(i) for i in v) + b"e"
|
||||
if isinstance(v, dict):
|
||||
return b"d" + b"".join(bencode(k) + bencode(v[k]) for k in sorted(v)) + b"e"
|
||||
raise TypeError(type(v))
|
||||
|
||||
def failure(reason: str) -> bytes:
|
||||
return bencode({"failure reason": reason})
|
||||
|
||||
|
||||
# ── Passkey ────────────────────────────────────────────────────────────
|
||||
|
||||
def make_passkey(pubkey_hex: str) -> str:
|
||||
mac = hmac.new(TRACKER_SECRET, pubkey_hex.encode(), hashlib.sha256).hexdigest()
|
||||
return f"{pubkey_hex}.{mac}"
|
||||
|
||||
def validate_passkey(token: str):
|
||||
if "." not in token:
|
||||
return None
|
||||
pubkey_hex, mac = token.rsplit(".", 1)
|
||||
if len(pubkey_hex) != 64 or len(mac) != 64:
|
||||
return None
|
||||
expected = hmac.new(TRACKER_SECRET, pubkey_hex.encode(), hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(mac, expected):
|
||||
return None
|
||||
return pubkey_hex
|
||||
|
||||
|
||||
# ── NIP-98 ─────────────────────────────────────────────────────────────
|
||||
|
||||
def verify_nip98(header: str, url: str, method: str):
|
||||
if not header.startswith("Nostr "):
|
||||
return None
|
||||
try:
|
||||
event = json.loads(base64.b64decode(header[6:]))
|
||||
except Exception:
|
||||
return None
|
||||
if event.get("kind") != 27235:
|
||||
return None
|
||||
if abs(time.time() - event.get("created_at", 0)) > 60:
|
||||
return None
|
||||
tags = {t[0]: t[1] for t in event.get("tags", []) if len(t) >= 2}
|
||||
if tags.get("u") != url or tags.get("method", "").upper() != method.upper():
|
||||
return None
|
||||
pubkey = event.get("pubkey", "")
|
||||
serial = json.dumps(
|
||||
[0, pubkey, event["created_at"], event["kind"],
|
||||
event["tags"], event["content"]],
|
||||
separators=(",", ":"), ensure_ascii=False
|
||||
)
|
||||
if hashlib.sha256(serial.encode()).hexdigest() != event.get("id"):
|
||||
return None
|
||||
try:
|
||||
pub = coincurve.PublicKeyXOnly(bytes.fromhex(pubkey))
|
||||
if not pub.verify(bytes.fromhex(event["sig"]),
|
||||
bytes.fromhex(event["id"]), hasher=None):
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
return pubkey
|
||||
|
||||
|
||||
# ── DB queries ─────────────────────────────────────────────────────────
|
||||
|
||||
def is_whitelisted(pubkey_hex: str) -> bool:
|
||||
try:
|
||||
con = sqlite3.connect(f"file:{JIRORIAN_DB}?mode=ro", uri=True)
|
||||
row = con.execute(
|
||||
"SELECT 1 FROM pubkeys WHERE pubkey = ? AND status = 'whitelisted'",
|
||||
(pubkey_hex,)
|
||||
).fetchone()
|
||||
con.close()
|
||||
return row is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def is_registered(info_hash_hex: str) -> bool:
|
||||
"""True if a kind:2003 event with x=info_hash exists from a whitelisted pubkey."""
|
||||
try:
|
||||
relay = sqlite3.connect(f"file:{RELAY_DB}?mode=ro", uri=True)
|
||||
rows = relay.execute("""
|
||||
SELECT e.author FROM event e
|
||||
JOIN tag t ON t.event_id = e.id
|
||||
WHERE e.kind = 2003
|
||||
AND t.name = 'x'
|
||||
AND t.value = ?
|
||||
AND (e.hidden IS NULL OR e.hidden = 0)
|
||||
""", (info_hash_hex,)).fetchall()
|
||||
relay.close()
|
||||
except Exception:
|
||||
return False
|
||||
if not rows:
|
||||
return False
|
||||
try:
|
||||
jiro = sqlite3.connect(f"file:{JIRORIAN_DB}?mode=ro", uri=True)
|
||||
for (author_blob,) in rows:
|
||||
row = jiro.execute(
|
||||
"SELECT 1 FROM pubkeys WHERE pubkey = ? AND status = 'whitelisted'",
|
||||
(author_blob.hex(),)
|
||||
).fetchone()
|
||||
if row:
|
||||
jiro.close()
|
||||
return True
|
||||
jiro.close()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
# ── Peer store ─────────────────────────────────────────────────────────
|
||||
|
||||
# {info_hash_hex: {(peer_id_bytes, ip, port): {last_seen, left}}}
|
||||
_peers: dict = defaultdict(dict)
|
||||
_lock = threading.Lock()
|
||||
|
||||
def _evict():
|
||||
cutoff = time.time() - PEER_TTL
|
||||
with _lock:
|
||||
for ih in list(_peers):
|
||||
_peers[ih] = {k: v for k, v in _peers[ih].items()
|
||||
if v["last_seen"] > cutoff}
|
||||
if not _peers[ih]:
|
||||
del _peers[ih]
|
||||
|
||||
def do_announce(ih_hex, peer_id, ip, port, event, left, numwant) -> bytes:
|
||||
_evict()
|
||||
key = (peer_id, ip, port)
|
||||
with _lock:
|
||||
if event == "stopped":
|
||||
_peers[ih_hex].pop(key, None)
|
||||
else:
|
||||
_peers[ih_hex][key] = {"last_seen": time.time(),
|
||||
"left": left, "ip": ip, "port": port}
|
||||
snapshot = dict(_peers[ih_hex])
|
||||
|
||||
compact = b""
|
||||
count = 0
|
||||
for (pid, pip, pport), meta in snapshot.items():
|
||||
if (pid, pip, pport) == key or count >= numwant:
|
||||
continue
|
||||
try:
|
||||
compact += socket.inet_aton(pip) + struct.pack("!H", pport)
|
||||
count += 1
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
seeders = sum(1 for v in snapshot.values() if v["left"] == 0)
|
||||
leechers = len(snapshot) - seeders
|
||||
return bencode({
|
||||
"interval": INTERVAL,
|
||||
"min interval": INTERVAL // 2,
|
||||
"complete": seeders,
|
||||
"incomplete": leechers,
|
||||
"peers": compact,
|
||||
})
|
||||
|
||||
def do_scrape(ih_hexes: list) -> bytes:
|
||||
_evict()
|
||||
files = {}
|
||||
with _lock:
|
||||
for ih_hex in ih_hexes:
|
||||
p = _peers.get(ih_hex, {})
|
||||
seeders = sum(1 for v in p.values() if v["left"] == 0)
|
||||
leechers = len(p) - seeders
|
||||
files[bytes.fromhex(ih_hex)] = {
|
||||
"complete": seeders, "incomplete": leechers, "downloaded": 0
|
||||
}
|
||||
return bencode({"files": files})
|
||||
|
||||
|
||||
# ── HTTP handler ───────────────────────────────────────────────────────
|
||||
|
||||
def _raw_param(query: str, name: str):
|
||||
for part in query.split("&"):
|
||||
if "=" in part:
|
||||
k, _, v = part.partition("=")
|
||||
if k == name:
|
||||
return unquote_to_bytes(v)
|
||||
return None
|
||||
|
||||
def _str_param(query: str, name: str, default: str = "") -> str:
|
||||
raw = _raw_param(query, name)
|
||||
return raw.decode("latin-1") if raw is not None else default
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
parsed = urlparse(self.path)
|
||||
path = parsed.path.rstrip("/")
|
||||
query = parsed.query
|
||||
|
||||
# GET /key
|
||||
if path == "/key":
|
||||
auth = self.headers.get("Authorization", "")
|
||||
pubkey = verify_nip98(auth, f"https://{DOMAIN}/key", "GET")
|
||||
if not pubkey:
|
||||
return self._send(401, b"Unauthorized")
|
||||
if not is_whitelisted(pubkey):
|
||||
return self._send(403, b"Not whitelisted")
|
||||
body = json.dumps({"passkey": make_passkey(pubkey)}).encode()
|
||||
return self._send(200, body, "application/json")
|
||||
|
||||
# GET /<passkey>/announce | GET /<passkey>/scrape
|
||||
parts = path.lstrip("/").split("/")
|
||||
if len(parts) == 2 and parts[1] in ("announce", "scrape"):
|
||||
token, endpoint = parts
|
||||
pubkey = validate_passkey(token)
|
||||
if not pubkey:
|
||||
return self._send(200, failure("Invalid passkey"))
|
||||
|
||||
client_ip = (
|
||||
self.headers.get("X-Forwarded-For", "")
|
||||
.split(",")[0].strip()
|
||||
or self.client_address[0]
|
||||
)
|
||||
|
||||
if endpoint == "announce":
|
||||
ih_raw = _raw_param(query, "info_hash")
|
||||
if not ih_raw or len(ih_raw) != 20:
|
||||
return self._send(200, failure("Invalid info_hash"))
|
||||
ih_hex = ih_raw.hex()
|
||||
if not is_registered(ih_hex):
|
||||
return self._send(200, failure("Torrent not registered"))
|
||||
ip_override = _str_param(query, "ip") or client_ip
|
||||
body = do_announce(
|
||||
ih_hex,
|
||||
_raw_param(query, "peer_id") or b"\x00" * 20,
|
||||
ip_override,
|
||||
int(_str_param(query, "port", "0")),
|
||||
_str_param(query, "event"),
|
||||
int(_str_param(query, "left", "0")),
|
||||
min(int(_str_param(query, "numwant", "50")), 100),
|
||||
)
|
||||
return self._send(200, body)
|
||||
|
||||
if endpoint == "scrape":
|
||||
ih_hexes = []
|
||||
for part in query.split("&"):
|
||||
if part.startswith("info_hash="):
|
||||
raw = unquote_to_bytes(part[10:])
|
||||
if len(raw) == 20:
|
||||
ih_hexes.append(raw.hex())
|
||||
return self._send(200, do_scrape(ih_hexes))
|
||||
|
||||
self._send(404, b"Not Found")
|
||||
|
||||
def _send(self, status, body, ct="text/plain"):
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", ct)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
pass
|
||||
|
||||
ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever()
|
||||
'';
|
||||
|
||||
startScript = pkgs.writeShellScript "torrent-tracker-start" ''
|
||||
set -euo pipefail
|
||||
export TRACKER_SECRET=$(cat ${config.sops.secrets.tracker_secret.path})
|
||||
exec ${python}/bin/python3 ${script}
|
||||
'';
|
||||
in
|
||||
{
|
||||
sops.secrets.tracker_secret = { owner = "torrent-tracker"; };
|
||||
|
||||
users.users.torrent-tracker = {
|
||||
isSystemUser = true;
|
||||
group = "torrent-tracker";
|
||||
};
|
||||
users.groups.torrent-tracker = {};
|
||||
|
||||
systemd.services.torrent-tracker = {
|
||||
description = "Private BitTorrent tracker";
|
||||
after = [ "network.target" "sops-nix.service" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
User = "torrent-tracker";
|
||||
Group = "torrent-tracker";
|
||||
ExecStart = startScript;
|
||||
Restart = "on-failure";
|
||||
RestartSec = 5;
|
||||
};
|
||||
};
|
||||
|
||||
environment.etc."caddy/Caddyfile.d/torrent-tracker.caddyfile".text = ''
|
||||
${domain} {
|
||||
reverse_proxy localhost:${toString port}
|
||||
}
|
||||
'';
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue