feat(ens): add local ensdns service and host-side test docs
Configuration validation / lint (push) Has been cancelled
Configuration validation / smoke (push) Has been cancelled

This commit is contained in:
auto-ci
2026-03-01 21:34:18 -05:00
parent 11b766c906
commit 968538ecc9
6 changed files with 411 additions and 2 deletions
+44 -2
View File
@@ -33,9 +33,12 @@ docker run --rm --network darkproxy infoblox/dig dig @10.5.0.4 facebookcorewwwi.
# I2P .i2p domains # I2P .i2p domains
docker run --rm --network darkproxy infoblox/dig dig @10.5.0.4 stats.i2p 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 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 # 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 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 > 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. > 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 `<name>.eth.limo.`.
## Supported TLDs and resolvers ## 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 | | `.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 | | `.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 | | `.bit` | Google 8.8.8.8 | Namecoin names |
| `.alt` | alt-root 185.121.177.177 | Alternative DNS root | | `.alt` | alt-root 185.121.177.177 | Alternative DNS root |
| `.loki` | Cloudflare 1.1.1.1 | Lokinet privacy network | | `.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. 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 <url>` 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 ## Monitoring the proxy
A lightweight Prometheus/Grafana stack is included to expose 3proxy metrics A lightweight Prometheus/Grafana stack is included to expose 3proxy metrics
+15
View File
@@ -220,6 +220,21 @@ services:
restart: unless-stopped restart: unless-stopped
stop_grace_period: 30s 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: tailscale:
build: build:
context: ./tailscale context: ./tailscale
+12
View File
@@ -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"]
+182
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
web3>=7,<8
+157
View File
@@ -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())