diff --git a/README.md b/README.md index f382b02..9be10c0 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,12 @@ docker run --rm --network darkproxy infoblox/dig dig @10.5.0.4 facebookcorewwwi. # I2P .i2p domains docker run --rm --network darkproxy infoblox/dig dig @10.5.0.4 stats.i2p -# Ethereum Name Service (.eth) via Cloudflare fallback +# Ethereum Name Service (.eth) via local ensdns service docker run --rm --network darkproxy infoblox/dig dig @10.5.0.4 vitalik.eth +# ENS address/content hash records (TXT) +docker run --rm --network darkproxy infoblox/dig dig @10.5.0.4 TXT vitalik.eth + # Other supported TLDs: .bit, .alt, .loki, .zil, .web3, .exit, .onion4, .onion6 docker run --rm --network darkproxy infoblox/dig dig @10.5.0.4 example.bit ``` @@ -45,6 +48,21 @@ docker run --rm --network darkproxy infoblox/dig dig @10.5.0.4 example.bit > Queries from your host should now work without the “refused to do a recursive > query” error. If you later reintroduce an ACL, update this note accordingly. +### Host-side quick tests (no extra images) + +Run DNS queries from your host by executing `dig` in the existing `darkpihole` +container: + +```sh +docker exec darkpihole dig @10.5.0.4 TXT vitalik.eth +short +docker exec darkpihole dig @10.5.0.4 A vitalik.eth +short +``` + +Expected behavior: + +* `TXT` includes `address=` and (if present) `contenthash=`. +* `A` returns a CNAME fallback like `.eth.limo.`. + ## Supported TLDs and resolvers @@ -54,7 +72,7 @@ The stack now provides DNS resolution for a comprehensive set of alternative TLD |-----|----------|---------| | `.onion`, `.exit`, `.onion4`, `.onion6` | Tor DNSPort (10.5.0.7:9053) | Tor hidden services | | `.i2p` | i2pd DNS (10.5.0.2:53) | I2P eepsite names | -| `.eth` | Cloudflare 1.1.1.1 | Ethereum Name Service | +| `.eth` | ensdns (10.5.0.11) via CoreDNS | Ethereum Name Service (local resolver) | | `.bit` | Google 8.8.8.8 | Namecoin names | | `.alt` | alt-root 185.121.177.177 | Alternative DNS root | | `.loki` | Cloudflare 1.1.1.1 | Lokinet privacy network | @@ -97,6 +115,30 @@ These steps ensure that configuration drift is caught early and reviewers can see what has changed. +## Local ENS (.eth) client + +If you want to resolve Ethereum ENS names locally from the host, use the +standalone client script: + +```sh +python3 -m venv .venv-ens +source .venv-ens/bin/activate +pip install -r scripts/requirements-ens.txt +python3 scripts/resolve_ens.py vitalik.eth --pretty +``` + +Options: + +* `--rpc ` to set a custom Ethereum JSON-RPC endpoint. +* `--include-text` to include common ENS text records. + +Example: + +```sh +python3 scripts/resolve_ens.py ens.eth --include-text --pretty +``` + + ## Monitoring the proxy A lightweight Prometheus/Grafana stack is included to expose 3proxy metrics diff --git a/docker-compose.yml b/docker-compose.yml index e93fb25..c6e4f6c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -220,6 +220,21 @@ services: restart: unless-stopped stop_grace_period: 30s + ensdns: + build: + context: ./ensdns + dockerfile: Dockerfile + container_name: darkens + restart: unless-stopped + environment: + ENS_RPC_URL: https://ethereum-rpc.publicnode.com + ENSDNS_TTL: 60 + sysctls: + - "net.ipv6.conf.all.disable_ipv6=0" + networks: + darkproxy: + ipv4_address: 10.5.0.11 + tailscale: build: context: ./tailscale diff --git a/ensdns/Dockerfile b/ensdns/Dockerfile new file mode 100644 index 0000000..de5224b --- /dev/null +++ b/ensdns/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.11-alpine + +WORKDIR /app + +RUN pip install --no-cache-dir "web3>=7,<8" "dnslib>=0.9.24" + +COPY server.py /app/server.py + +EXPOSE 53/tcp +EXPOSE 53/udp + +CMD ["python", "/app/server.py"] diff --git a/ensdns/server.py b/ensdns/server.py new file mode 100644 index 0000000..4c93a2f --- /dev/null +++ b/ensdns/server.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +import os +import socket +import threading + +from dnslib import A, AAAA, CNAME, QTYPE, RR, TXT, DNSHeader, DNSRecord, RCODE +from web3 import HTTPProvider, Web3 + + +RPC_URL = os.getenv("ENS_RPC_URL", "https://ethereum-rpc.publicnode.com") +LISTEN_HOST = os.getenv("ENSDNS_LISTEN_HOST", "0.0.0.0") +LISTEN_PORT = int(os.getenv("ENSDNS_LISTEN_PORT", "53")) +DEFAULT_TTL = int(os.getenv("ENSDNS_TTL", "60")) + +ENS_REGISTRY_ADDRESS = Web3.to_checksum_address("0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e") + +ENS_REGISTRY_ABI = [ + { + "constant": True, + "inputs": [{"name": "node", "type": "bytes32"}], + "name": "resolver", + "outputs": [{"name": "", "type": "address"}], + "type": "function", + } +] + +PUBLIC_RESOLVER_ABI = [ + { + "constant": True, + "inputs": [{"name": "node", "type": "bytes32"}], + "name": "addr", + "outputs": [{"name": "", "type": "address"}], + "type": "function", + }, + { + "constant": True, + "inputs": [{"name": "node", "type": "bytes32"}], + "name": "contenthash", + "outputs": [{"name": "", "type": "bytes"}], + "type": "function", + }, +] + +EMPTY_ADDRESS = "0x0000000000000000000000000000000000000000" + + +def namehash(name: str) -> bytes: + node = b"\x00" * 32 + labels = [label for label in name.strip().lower().split(".") if label] + for label in reversed(labels): + label_hash = Web3.keccak(text=label) + node = Web3.keccak(node + label_hash) + return node + + +def normalize_qname(qname: str) -> str: + return qname.rstrip(".").lower() + + +class ENSResolver: + def __init__(self, rpc_url: str): + self.web3 = Web3(HTTPProvider(rpc_url, request_kwargs={"timeout": 10})) + if not self.web3.is_connected(): + raise RuntimeError(f"Could not connect to Ethereum RPC: {rpc_url}") + self.registry = self.web3.eth.contract(address=ENS_REGISTRY_ADDRESS, abi=ENS_REGISTRY_ABI) + + def resolve(self, name: str): + node = namehash(name) + resolver_addr = self.registry.functions.resolver(node).call() + if not resolver_addr or resolver_addr == EMPTY_ADDRESS: + return None + + resolver = self.web3.eth.contract(address=resolver_addr, abi=PUBLIC_RESOLVER_ABI) + + addr = None + contenthash = None + + try: + raw_addr = resolver.functions.addr(node).call() + if raw_addr and raw_addr != EMPTY_ADDRESS: + addr = Web3.to_checksum_address(raw_addr) + except Exception: + addr = None + + try: + raw_ch = resolver.functions.contenthash(node).call() + if raw_ch: + contenthash = "0x" + raw_ch.hex() + except Exception: + contenthash = None + + return { + "address": addr, + "contenthash": contenthash, + } + + +def answer_query(record: DNSRecord, ens: ENSResolver) -> DNSRecord: + reply = DNSRecord(DNSHeader(id=record.header.id, qr=1, aa=1, ra=1), q=record.q) + + qname = normalize_qname(str(record.q.qname)) + qtype = QTYPE[record.q.qtype] + + if not qname.endswith(".eth"): + reply.header.rcode = RCODE.NXDOMAIN + return reply + + resolved = ens.resolve(qname) + if resolved is None: + reply.header.rcode = RCODE.NXDOMAIN + return reply + + fqdn = str(record.q.qname) + + if qtype in ("TXT", "ANY"): + if resolved.get("address"): + reply.add_answer(RR(fqdn, QTYPE.TXT, rdata=TXT(f"address={resolved['address']}"), ttl=DEFAULT_TTL)) + if resolved.get("contenthash"): + reply.add_answer(RR(fqdn, QTYPE.TXT, rdata=TXT(f"contenthash={resolved['contenthash']}"), ttl=DEFAULT_TTL)) + + if qtype in ("A", "AAAA", "CNAME", "ANY"): + # Provide a browser-friendly fallback target for .eth domains. + # e.g. vitalik.eth -> vitalik.eth.limo + cname_target = qname + ".limo." + reply.add_answer(RR(fqdn, QTYPE.CNAME, rdata=CNAME(cname_target), ttl=DEFAULT_TTL)) + + return reply + + +def serve_udp(sock: socket.socket, ens: ENSResolver): + while True: + data, addr = sock.recvfrom(4096) + try: + request = DNSRecord.parse(data) + response = answer_query(request, ens) + sock.sendto(response.pack(), addr) + except Exception: + pass + + +def handle_tcp_conn(conn: socket.socket, ens: ENSResolver): + try: + length_data = conn.recv(2) + if len(length_data) != 2: + return + length = int.from_bytes(length_data, "big") + payload = conn.recv(length) + if len(payload) != length: + return + + request = DNSRecord.parse(payload) + response = answer_query(request, ens).pack() + conn.sendall(len(response).to_bytes(2, "big") + response) + finally: + conn.close() + + +def serve_tcp(sock: socket.socket, ens: ENSResolver): + while True: + conn, _ = sock.accept() + threading.Thread(target=handle_tcp_conn, args=(conn, ens), daemon=True).start() + + +def main(): + ens = ENSResolver(RPC_URL) + print(f"[ensdns] connected to {RPC_URL}", flush=True) + print(f"[ensdns] listening on {LISTEN_HOST}:{LISTEN_PORT} (udp/tcp)", flush=True) + + udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + udp_sock.bind((LISTEN_HOST, LISTEN_PORT)) + + tcp_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + tcp_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + tcp_sock.bind((LISTEN_HOST, LISTEN_PORT)) + tcp_sock.listen(128) + + threading.Thread(target=serve_udp, args=(udp_sock, ens), daemon=True).start() + serve_tcp(tcp_sock, ens) + + +if __name__ == "__main__": + main() diff --git a/scripts/requirements-ens.txt b/scripts/requirements-ens.txt new file mode 100644 index 0000000..6459443 --- /dev/null +++ b/scripts/requirements-ens.txt @@ -0,0 +1 @@ +web3>=7,<8 diff --git a/scripts/resolve_ens.py b/scripts/resolve_ens.py new file mode 100644 index 0000000..8a28914 --- /dev/null +++ b/scripts/resolve_ens.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +import argparse +import json +import sys + +from web3 import HTTPProvider, Web3 + + +ENS_REGISTRY_ADDRESS = Web3.to_checksum_address("0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e") + +ENS_REGISTRY_ABI = [ + { + "constant": True, + "inputs": [{"name": "node", "type": "bytes32"}], + "name": "resolver", + "outputs": [{"name": "", "type": "address"}], + "type": "function", + } +] + +PUBLIC_RESOLVER_ABI = [ + { + "constant": True, + "inputs": [{"name": "node", "type": "bytes32"}], + "name": "addr", + "outputs": [{"name": "", "type": "address"}], + "type": "function", + }, + { + "constant": True, + "inputs": [{"name": "node", "type": "bytes32"}], + "name": "contenthash", + "outputs": [{"name": "", "type": "bytes"}], + "type": "function", + }, + { + "constant": True, + "inputs": [ + {"name": "node", "type": "bytes32"}, + {"name": "key", "type": "string"}, + ], + "name": "text", + "outputs": [{"name": "", "type": "string"}], + "type": "function", + }, +] + + +def namehash(name: str) -> bytes: + node = b"\x00" * 32 + labels = [label for label in name.strip().lower().split(".") if label] + for label in reversed(labels): + label_hash = Web3.keccak(text=label) + node = Web3.keccak(node + label_hash) + return node + + +def build_client(rpc_url: str) -> Web3: + web3 = Web3(HTTPProvider(rpc_url, request_kwargs={"timeout": 10})) + if not web3.is_connected(): + raise RuntimeError(f"Could not connect to Ethereum RPC: {rpc_url}") + return web3 + + +def resolve_name(client: Web3, name: str, include_text: bool) -> dict: + node = namehash(name) + + registry = client.eth.contract(address=ENS_REGISTRY_ADDRESS, abi=ENS_REGISTRY_ABI) + resolver_address = registry.functions.resolver(node).call() + + empty_address = "0x0000000000000000000000000000000000000000" + + if resolver_address == empty_address: + return { + "name": name, + "resolver": None, + "address": None, + "contenthash": None, + "text": {} if include_text else None, + } + + resolver = client.eth.contract(address=resolver_address, abi=PUBLIC_RESOLVER_ABI) + + resolved_address = None + try: + addr = resolver.functions.addr(node).call() + if addr and addr != empty_address: + resolved_address = Web3.to_checksum_address(addr) + except Exception: + resolved_address = None + + resolved_contenthash = None + try: + ch = resolver.functions.contenthash(node).call() + if ch: + resolved_contenthash = "0x" + ch.hex() + except Exception: + resolved_contenthash = None + + result = { + "name": name, + "resolver": resolver_address, + "address": resolved_address, + "contenthash": resolved_contenthash, + } + + if include_text: + text_keys = ["url", "description", "com.twitter", "com.github"] + text_values = {} + for key in text_keys: + try: + value = resolver.functions.text(node, key).call() + except Exception: + value = None + if value: + text_values[key] = value + result["text"] = text_values + + return result + + +def main() -> int: + parser = argparse.ArgumentParser(description="Resolve ENS (.eth) domains locally") + parser.add_argument("name", help="ENS name, e.g. vitalik.eth") + parser.add_argument( + "--rpc", + default="https://ethereum-rpc.publicnode.com", + help="Ethereum JSON-RPC endpoint", + ) + parser.add_argument( + "--include-text", + action="store_true", + help="Include common ENS text records", + ) + parser.add_argument( + "--pretty", + action="store_true", + help="Pretty-print JSON output", + ) + args = parser.parse_args() + + try: + client = build_client(args.rpc) + output = resolve_name(client, args.name, args.include_text) + except Exception as exc: + print(str(exc), file=sys.stderr) + return 1 + + if args.pretty: + print(json.dumps(output, indent=2, sort_keys=True)) + else: + print(json.dumps(output)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())