diff --git a/README.md b/README.md index c0466c4..8b757e7 100644 --- a/README.md +++ b/README.md @@ -158,12 +158,13 @@ python3 scripts/resolve_ens.py ens.eth --include-text --pretty ``` -## Local Namecoin (.bit) resolver +## Local Namecoin (.bit) resolver (true on-chain) -`.bit` lookups are routed through a local `namecoindns` service in the stack: +`.bit` lookups are routed through a local blockchain-backed resolver path: -* CoreDNS forwards `bit.:53` to `10.5.0.12:53`. -* `namecoindns` performs DNS-over-HTTPS upstream resolution. +* CoreDNS forwards `bit.:53` to `10.5.0.12:53` (`namecoindns`). +* `namecoindns` queries local `namecoind` JSON-RPC (`10.5.0.13:8336`). +* `namecoind` syncs the Namecoin blockchain and serves on-chain records. Quick test: @@ -173,7 +174,10 @@ docker exec darkpihole dig @10.5.0.4 TXT id.bit ``` If your environment returns `NXDOMAIN` for `.bit`, configure Namecoin-capable -DoH upstreams in `docker-compose.yml` under `namecoindns` (`NAMECOIN_DOH_UPSTREAMS`). +records in Namecoin itself and wait for local `namecoind` sync completion. + +> **Important:** initial blockchain sync can take significant time. During sync, +> `.bit` responses may be empty or incomplete. ## Monitoring the proxy diff --git a/docker-compose.yml b/docker-compose.yml index 07c9721..ef624c6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -239,15 +239,39 @@ services: darkproxy: ipv4_address: 10.5.0.11 + namecoind: + image: ukd1/namecoind:latest + container_name: darknamecoind + restart: unless-stopped + command: + - "-server=1" + - "-rpcbind=0.0.0.0" + - "-rpcallowip=10.5.0.0/16" + - "-rpcuser=namecoinrpc" + - "-rpcpassword=CHANGE_ME_NAMECOIN_RPC_PASSWORD" + - "-printtoconsole" + - "-txindex=1" + volumes: + - namecoin_data:/namecoin + sysctls: + - "net.ipv6.conf.all.disable_ipv6=0" + networks: + darkproxy: + ipv4_address: 10.5.0.13 + namecoindns: build: context: ./namecoindns dockerfile: Dockerfile container_name: darknamecoin restart: unless-stopped + depends_on: + - namecoind environment: - NAMECOIN_DOH_TIMEOUT: 6 - NAMECOIN_DOH_UPSTREAMS: https://dns.google/dns-query,https://cloudflare-dns.com/dns-query + NAMECOIN_RPC_URL: http://darknamecoind:8336/ + NAMECOIN_RPC_USER: namecoinrpc + NAMECOIN_RPC_PASSWORD: CHANGE_ME_NAMECOIN_RPC_PASSWORD + NAMECOIN_RPC_TIMEOUT: 8 sysctls: - "net.ipv6.conf.all.disable_ipv6=0" networks: @@ -320,6 +344,8 @@ volumes: name: darkalfis_data emc_data: name: emc_data + namecoin_data: + name: namecoin_data tor-data: driver: local tailscale_data: diff --git a/namecoindns/server.py b/namecoindns/server.py index 83bd724..071b9a3 100644 --- a/namecoindns/server.py +++ b/namecoindns/server.py @@ -1,26 +1,20 @@ #!/usr/bin/env python3 -import base64 +import json import os import socket import threading -from urllib.parse import urlencode from urllib.request import Request, urlopen -from dnslib import DNSHeader, DNSRecord, QTYPE, RCODE +from dnslib import A, AAAA, CNAME, DNSHeader, DNSRecord, QTYPE, RCODE, RR, TXT LISTEN_HOST = os.getenv("NAMECOIN_DNS_LISTEN_HOST", "0.0.0.0") LISTEN_PORT = int(os.getenv("NAMECOIN_DNS_LISTEN_PORT", "53")) -DOH_TIMEOUT = float(os.getenv("NAMECOIN_DOH_TIMEOUT", "6")) +RPC_TIMEOUT = float(os.getenv("NAMECOIN_RPC_TIMEOUT", "8")) -DOH_UPSTREAMS = [ - u.strip() - for u in os.getenv( - "NAMECOIN_DOH_UPSTREAMS", - "https://dns.google/dns-query,https://cloudflare-dns.com/dns-query", - ).split(",") - if u.strip() -] +RPC_URL = os.getenv("NAMECOIN_RPC_URL", "http://darknamecoind:8336/") +RPC_USER = os.getenv("NAMECOIN_RPC_USER", "namecoinrpc") +RPC_PASSWORD = os.getenv("NAMECOIN_RPC_PASSWORD", "CHANGE_ME_NAMECOIN_RPC_PASSWORD") def is_bit_query(record: DNSRecord) -> bool: @@ -28,23 +22,44 @@ def is_bit_query(record: DNSRecord) -> bool: return qname.endswith(".bit") -def doh_query(payload: bytes) -> bytes | None: - encoded = base64.urlsafe_b64encode(payload).rstrip(b"=").decode("ascii") - query = urlencode({"dns": encoded}) +def rpc_call(method: str, params: list): + payload = json.dumps({"jsonrpc": "1.0", "id": "namecoindns", "method": method, "params": params}).encode("utf-8") + req = Request(RPC_URL, data=payload, headers={"Content-Type": "application/json"}) + token = (f"{RPC_USER}:{RPC_PASSWORD}").encode("utf-8") + import base64 - for upstream in DOH_UPSTREAMS: - url = f"{upstream}?{query}" - req = Request(url, headers={"accept": "application/dns-message", "user-agent": "darkproxy-namecoindns/1.0"}) - try: - with urlopen(req, timeout=DOH_TIMEOUT) as resp: - if resp.status != 200: - continue - body = resp.read() - if body: - return body - except Exception: - continue - return None + req.add_header("Authorization", "Basic " + base64.b64encode(token).decode("ascii")) + with urlopen(req, timeout=RPC_TIMEOUT) as resp: + body = resp.read() + result = json.loads(body.decode("utf-8")) + if result.get("error"): + raise RuntimeError(str(result["error"])) + return result.get("result") + + +def lookup_namecoin_bit(label: str): + key = f"d/{label}" + try: + name_info = rpc_call("name_show", [key]) + except Exception: + return None + + value = name_info.get("value") + if not value: + return None + + try: + parsed = json.loads(value) + except Exception: + return None + + if isinstance(parsed, dict) and "map" in parsed and isinstance(parsed["map"], dict): + parsed = parsed["map"].get("", parsed) + + if not isinstance(parsed, dict): + return None + + return parsed def fail_response(payload: bytes) -> bytes: @@ -63,10 +78,44 @@ def handle_packet(payload: bytes) -> bytes: reply = DNSRecord(DNSHeader(id=req.header.id, qr=1, ra=1, aa=0, rcode=RCODE.NXDOMAIN), q=req.q) return reply.pack() - upstream = doh_query(payload) - if upstream: - return upstream - return fail_response(payload) + qname = str(req.q.qname).rstrip(".").lower() + label = qname[: -len(".bit")].strip(".") + if not label: + return fail_response(payload) + + record = lookup_namecoin_bit(label) + if record is None: + reply = DNSRecord(DNSHeader(id=req.header.id, qr=1, ra=1, aa=1, rcode=RCODE.NXDOMAIN), q=req.q) + return reply.pack() + + reply = DNSRecord(DNSHeader(id=req.header.id, qr=1, ra=1, aa=1, rcode=RCODE.NOERROR), q=req.q) + fqdn = str(req.q.qname) + qtype = QTYPE[req.q.qtype] + ttl = 60 + + ip = record.get("ip") + ips = ip if isinstance(ip, list) else ([ip] if isinstance(ip, str) else []) + for item in ips: + if ":" in item: + if qtype in ("AAAA", "ANY"): + reply.add_answer(RR(fqdn, QTYPE.AAAA, rdata=AAAA(item), ttl=ttl)) + else: + if qtype in ("A", "ANY"): + reply.add_answer(RR(fqdn, QTYPE.A, rdata=A(item), ttl=ttl)) + + alias = record.get("alias") or record.get("translate") + if isinstance(alias, str) and alias: + target = alias if alias.endswith(".") else alias + "." + if qtype in ("CNAME", "A", "AAAA", "ANY"): + reply.add_answer(RR(fqdn, QTYPE.CNAME, rdata=CNAME(target), ttl=ttl)) + + if qtype in ("TXT", "ANY"): + reply.add_answer(RR(fqdn, QTYPE.TXT, rdata=TXT(json.dumps(record, separators=(",", ":"))), ttl=ttl)) + + if len(reply.rr) == 0: + return fail_response(payload) + + return reply.pack() def serve_udp(sock: socket.socket): @@ -100,7 +149,7 @@ def serve_tcp(sock: socket.socket): def main(): print(f"[namecoindns] listening on {LISTEN_HOST}:{LISTEN_PORT}", flush=True) - print(f"[namecoindns] DoH upstreams: {', '.join(DOH_UPSTREAMS)}", flush=True) + print(f"[namecoindns] RPC endpoint: {RPC_URL}", flush=True) udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) udp_sock.bind((LISTEN_HOST, LISTEN_PORT))