feat(ens): add local ensdns service and host-side test docs
This commit is contained in:
@@ -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 `<name>.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 <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
|
||||
|
||||
A lightweight Prometheus/Grafana stack is included to expose 3proxy metrics
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
web3>=7,<8
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user