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:
randogoth 2026-06-08 16:01:07 +03:00
parent c71f02a7c7
commit e094720c68
4 changed files with 343 additions and 3 deletions

View file

@ -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
];
};

View file

@ -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
{

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

View file

@ -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