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