{ 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} } ''; }