From e094720c6886346bc21484f3d24c66bd28572e3d Mon Sep 17 00:00:00 2001 From: randogoth Date: Mon, 8 Jun 2026 16:01:07 +0300 Subject: [PATCH] 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 --- flake.nix | 1 + modules/services/nostr-rs-relay.nix | 2 +- modules/services/torrent-tracker.nix | 338 +++++++++++++++++++++++++++ secrets/bucur.yaml | 5 +- 4 files changed, 343 insertions(+), 3 deletions(-) create mode 100644 modules/services/torrent-tracker.nix diff --git a/flake.nix b/flake.nix index 7fb4452..7afb206 100644 --- a/flake.nix +++ b/flake.nix @@ -57,6 +57,7 @@ ./modules/services/webhook-deploy.nix ./modules/services/jirorian.nix ./modules/services/nip05.nix + ./modules/services/torrent-tracker.nix ./modules/users/tobias.nix ]; }; diff --git a/modules/services/nostr-rs-relay.nix b/modules/services/nostr-rs-relay.nix index 1d15b7b..6e5fcdc 100644 --- a/modules/services/nostr-rs-relay.nix +++ b/modules/services/nostr-rs-relay.nix @@ -16,7 +16,7 @@ let port = 8880 [limits] - event_kind_allowlist = [0, 3, 37515, 37518] + event_kind_allowlist = [0, 3, 5, 2003, 2004, 37515, 37518] ''; in { diff --git a/modules/services/torrent-tracker.nix b/modules/services/torrent-tracker.nix new file mode 100644 index 0000000..9187e67 --- /dev/null +++ b/modules/services/torrent-tracker.nix @@ -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 + "." (129 chars). Clients obtain their passkey via + GET /key with NIP-98 auth. Announce URL: + https://tracker.otherwhere.app//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 //announce | GET //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} + } + ''; +} diff --git a/secrets/bucur.yaml b/secrets/bucur.yaml index bf79c3b..bbc38d6 100644 --- a/secrets/bucur.yaml +++ b/secrets/bucur.yaml @@ -6,6 +6,7 @@ jirorian_r2_access_key_id: ENC[AES256_GCM,data:e20DrP0pvsKiA8Ir5i0Na74TTGwRc8P7x jirorian_r2_secret_access_key: ENC[AES256_GCM,data:ix+y6sdDa+Ze40ty1hQufoi6NCmWL0OrAD2Sk+ljs+cMCTDMdNyLh8YaNfQkUK1I73+csxcyojTMJEA2XSG52A==,iv:sGSPVRq0dMlXX8NnOgirgplprtNgktaxwY6p7ZmIzKw=,tag:E8PAT9t9d1LRoaJw+OTisg==,type:str] jirorian_r2_endpoint: ENC[AES256_GCM,data:9mokSsGMS2dU72sMEpQNbsjysalOlMnHdd/VS2ebaG7ah80e8p7zG9IJxsXx/6jHy9XPPO+1ItsyhyNhyovfGs0=,iv:inCCSHuHI4X/B0jFilT3j/c4CNntOsti4pdsj1Z7xhE=,tag:OJWu/xmT1Y/XxbWG2mqCrA==,type:str] jirorian_app_secrets: ENC[AES256_GCM,data:soUEk9GBrpN03+5rqMd2s9qT6sFGAswZUWjKjAHz0k058AJaNbOVlTW56CUjMG25/noIQzhwgqO7IVOzP1+UcdSOqB/67y7Q2rWG0ua8Nxg+qALV5e4Ub6rKGpgYHUfnwYW2wYi7UyxZQe1/mFaG6zCbahp956q/GHYUbXAPfXJcsFjZb0bkenUbrpxkKGdAadPfjwbdSD8ZXadGdwA=,iv:DXdxNIphpTkfcWLfWQrIG2GPsFp1WDLMp16M1Dbv4T8=,tag:ZuBf0XEPFKYCqvFe5FVvow==,type:str] +tracker_secret: ENC[AES256_GCM,data:gtZnzmOcskS7P/dsq38wZ9Qmrvs7ZgwT/lrelfuMP340dJfBs2iox3r+Vkpl2a4HEaZIRM2hOkY/Y211+RxIsw==,iv:/ljreSQUAPH77K+UgCtfh5N19/yBFTFXF8zYK8rTt2c=,tag:PdBEGoEiEPzyy/cDWcSL/w==,type:str] sops: age: - recipient: age1647c5f46njylggj33h26t6twhq57n5qeaza04l845uy0fksv0ucsq82c3d @@ -17,7 +18,7 @@ sops: U0x1ZmtzLytSL1Y3cVo1ZVJ2Ymtob1EKTpgjSymQx14wLSuUhh5Txq++3DrLS4Bs x8vVtQgcXZgv1q0OMGup6DT4bkY6UiNbn9bAt4rHCaRPeb15x4ZKNw== -----END AGE ENCRYPTED FILE----- - lastmodified: "2026-06-08T07:05:00Z" - mac: ENC[AES256_GCM,data:BomXN9yuhCDbxcWamyScXDDtqUDvG8JuQlkrQzCm6iyvAkXR6QrFbzAP2REoQgEEMKGeAUc0SBZHu3/qgyA9FTHn65Z1zce7sbMu2tRkmb3KKeD/us6gCVRxyEgRB4WLXDaVNAt2aWUC35ek3COt8qQhKEFc05V5WRd6qpaGa6Y=,iv:65lpzgwG+7aNefHc1m3cGKKauE8TMYzAXiTBzUNZJ9I=,tag:cjDB+eO/34YfLd8hJmL/yw==,type:str] + lastmodified: "2026-06-08T13:00:51Z" + mac: ENC[AES256_GCM,data:Kwy8aZ3zAnmMyWpezwQGfs+5tyJSyeqFYW0UYzz0A9sVB0vxn2LcTEYNc6+D/X75Gsaxwz7wlDTuzzvHZqlMAD/c9bZRcKcSZjeME0NXfaCeB8UyazlxELYcttHEUB3IQaL+MOxzWjqTyYxivhPvFaPRYmv8N1m5GMY7LXB79ls=,iv:IX6pj5Kp8GEQb+QrztXm5MRMDlmvDYGXiH6hmmENH2M=,tag:r740JHuEEcFuvhlvFzqqDg==,type:str] unencrypted_suffix: _unencrypted version: 3.11.0