From ec3c2d92cfc5e3f7dacf24361c8888c25b626485 Mon Sep 17 00:00:00 2001 From: auto-ci Date: Fri, 6 Mar 2026 22:39:30 -0500 Subject: [PATCH] Add local zildns service and ARM smoke validation --- PopuraDNS | 2 +- README.md | 6 +- docker-compose.arm.yml | 3 + docker-compose.yml | 21 ++++ scripts/smoke-test.sh | 15 ++- zildns/Dockerfile | 13 +++ zildns/package.json | 12 +++ zildns/server.js | 225 +++++++++++++++++++++++++++++++++++++++++ 8 files changed, 293 insertions(+), 4 deletions(-) create mode 100644 zildns/Dockerfile create mode 100644 zildns/package.json create mode 100644 zildns/server.js diff --git a/PopuraDNS b/PopuraDNS index 175379d..13731b1 160000 --- a/PopuraDNS +++ b/PopuraDNS @@ -1 +1 @@ -Subproject commit 175379dd2820299e3be5a0a526222625b383e915 +Subproject commit 13731b16f863a0c78dbe0d237501f32801a37a8f diff --git a/README.md b/README.md index 2c13068..f81d16c 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ The stack now provides DNS resolution for a comprehensive set of alternative TLD | `.bit` | namecoindns (10.5.0.12) via CoreDNS | Namecoin names (local resolver service) | | `.alt` | local unbound (10.5.0.5) via CoreDNS | Alternative DNS root (local-only fallback mode) | | `.loki` | local lokinet (10.5.0.14) via CoreDNS | Lokinet privacy network | -| `.zil` | local unbound (10.5.0.5) via CoreDNS | Zilliqa blockchain (local-only fallback mode) | +| `.zil` | zildns (10.5.0.15) via CoreDNS | Zilliqa blockchain (local resolver service) | | `.web3` | local unbound (10.5.0.5) via CoreDNS | General blockchain namespace (local-only fallback mode) | | Alfis TLDs | Alfis (10.5.0.3) | `.anon`, `.btn`, `.conf`, `.index`, `.merch`, `.mirror`, `.mob`, `.screen`, `.srv`, `.ygg` | | EmerCoin TLDs | EmerCoin (10.5.0.9) | `.emc`, `.coin`, `.lib`, `.bazar`, `.enum` | @@ -116,7 +116,9 @@ Darkproxy now avoids direct public DNS forwarders for `.alt`, `.zil`, `.web3`, O Important limitations still apply for authoritative data: * `.eth` requires an Ethereum JSON-RPC backend. By default this stack uses `https://ethereum-rpc.publicnode.com` in `ensdns`. -* `.alt`, OpenNIC TLDs, `.zil`, and `.web3` are not in the ICANN root. In local-only fallback mode they are sent to local Unbound, so they usually return `NXDOMAIN` unless you provide your own authoritative/local resolver backend for those namespaces. +* `.alt`, OpenNIC TLDs, and `.web3` are not in the ICANN root. In local-only fallback mode they are sent to local Unbound, so they usually return `NXDOMAIN` unless you provide your own authoritative/local resolver backend for those namespaces. +* `.zil` is resolved through local `zildns`, which queries ZNS using `https://api.zilliqa.com` by default. You can override this endpoint with `ZILDNS_ZNS_URL` in `docker-compose.yml`. +* `zildns` supports `ZILDNS_CACHE_SECONDS` (record cache duration) and `ZILDNS_PREWARM_DOMAIN` (startup warm-up query) to reduce first-query latency. * `.loki` is resolved by local Lokinet, and Lokinet fallback DNS now points to local Unbound instead of a public resolver. diff --git a/docker-compose.arm.yml b/docker-compose.arm.yml index 689c8c1..8157d6a 100644 --- a/docker-compose.arm.yml +++ b/docker-compose.arm.yml @@ -39,6 +39,9 @@ services: coredns: platform: linux/arm64 + zildns: + platform: linux/arm64 + unbound: image: darkproxy-unbound:arm64 build: diff --git a/docker-compose.yml b/docker-compose.yml index cb9cfbe..d099660 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -266,6 +266,27 @@ services: darkproxy: ipv4_address: 10.5.0.11 + zildns: + build: + context: ./zildns + dockerfile: Dockerfile + container_name: darkzil + restart: unless-stopped + cpus: "0.5" + mem_reservation: 64m + mem_limit: 256m + environment: + ZILDNS_ZNS_URL: https://api.zilliqa.com + ZILDNS_ZNS_NETWORK: mainnet + ZILDNS_TTL: 120 + ZILDNS_CACHE_SECONDS: 300 + ZILDNS_PREWARM_DOMAIN: brad.zil + sysctls: + - "net.ipv6.conf.all.disable_ipv6=0" + networks: + darkproxy: + ipv4_address: 10.5.0.15 + lokinet: build: context: ./lokinet diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index c081f75..7cd79ce 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -4,7 +4,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" COMPOSE=(docker compose -f "$ROOT_DIR/docker-compose.yml" -f "$ROOT_DIR/docker-compose.arm.yml") -services=(dark3proxy darkdns darkscale darkpihole darki2p) +services=(dark3proxy darkdns darkzil darkscale darkpihole darki2p) dns_queries=(google.com torproject.org github.com wikipedia.org example.onion example.i2p) echo "[smoke] stack status" @@ -37,6 +37,19 @@ for pass in 1 2; do echo done +echo +echo "[smoke] .zil path checks via coredns" +for q in brad.zil not-a-real-name-xyz12345.zil; do + out="$(docker exec darkpihole sh -lc "dig @10.5.0.4 +time=8 +tries=1 '$q' TXT 2>&1" || true)" + status="$(printf "%s\n" "$out" | sed -n 's/.*status: \([A-Z]*\).*/\1/p' | head -n1)" + qtime="$(printf "%s\n" "$out" | sed -n 's/;; Query time: \([0-9]*\).*/\1/p' | head -n1)" + answers="$(printf "%s\n" "$out" | awk '/^;; ANSWER SECTION:/{flag=1;next}/^;;/{if(flag)exit}flag' | wc -l | tr -d ' ')" + [[ -n "$status" ]] || status="NO_RESPONSE" + [[ -n "$qtime" ]] || qtime="-1" + [[ -n "$answers" ]] || answers="0" + printf '%s status=%s query_ms=%s answers=%s\n' "$q" "$status" "$qtime" "$answers" +done + echo "[smoke] key error scan" for c in "${services[@]}"; do echo "=== $c ===" diff --git a/zildns/Dockerfile b/zildns/Dockerfile new file mode 100644 index 0000000..432ba51 --- /dev/null +++ b/zildns/Dockerfile @@ -0,0 +1,13 @@ +FROM node:16-alpine + +WORKDIR /app + +COPY package.json /app/package.json +RUN npm install --omit=dev + +COPY server.js /app/server.js + +EXPOSE 53/udp +EXPOSE 53/tcp + +CMD ["node", "/app/server.js"] diff --git a/zildns/package.json b/zildns/package.json new file mode 100644 index 0000000..d79ee67 --- /dev/null +++ b/zildns/package.json @@ -0,0 +1,12 @@ +{ + "name": "zildns", + "version": "1.0.0", + "private": true, + "description": "Blockchain-backed DNS resolver for .zil domains", + "license": "MIT", + "main": "server.js", + "dependencies": { + "@unstoppabledomains/resolution": "7.1.4", + "dns2": "^2.1.0" + } +} diff --git a/zildns/server.js b/zildns/server.js new file mode 100644 index 0000000..7444185 --- /dev/null +++ b/zildns/server.js @@ -0,0 +1,225 @@ +"use strict"; + +const dns2 = require("dns2"); +const { default: Resolution } = require("@unstoppabledomains/resolution"); + +const LISTEN_HOST = process.env.ZILDNS_LISTEN_HOST || "0.0.0.0"; +const LISTEN_PORT = Number(process.env.ZILDNS_LISTEN_PORT || "53"); +const DEFAULT_TTL = Number(process.env.ZILDNS_TTL || "120"); +const CACHE_SECONDS = Number(process.env.ZILDNS_CACHE_SECONDS || "30"); + +const ZNS_URL = process.env.ZILDNS_ZNS_URL || "https://api.zilliqa.com"; +const ZNS_NETWORK = process.env.ZILDNS_ZNS_NETWORK || "mainnet"; +const PREWARM_DOMAIN = normalizePrewarmDomain(process.env.ZILDNS_PREWARM_DOMAIN || "brad.zil"); + +const { Packet, createServer } = dns2; +const RCODE = { + NOERROR: 0, + SERVFAIL: 2, + NXDOMAIN: 3, +}; + +const resolution = new Resolution({ + sourceConfig: { + zns: { + url: ZNS_URL, + network: ZNS_NETWORK, + }, + }, +}); + +const cache = new Map(); + +function normalizeName(name) { + return String(name || "").replace(/\.$/, "").toLowerCase(); +} + +function normalizePrewarmDomain(name) { + const normalized = normalizeName(name); + return normalized.endsWith(".zil") ? normalized : ""; +} + +function cacheGet(key) { + const entry = cache.get(key); + if (!entry) { + return null; + } + if (Date.now() > entry.expiresAt) { + cache.delete(key); + return null; + } + return entry.value; +} + +function cacheSet(key, value) { + cache.set(key, { + value, + expiresAt: Date.now() + CACHE_SECONDS * 1000, + }); +} + +async function resolveZilDomain(domain) { + const cached = cacheGet(domain); + if (cached !== null) { + return cached; + } + + const [zilResult, ipfsResult] = await Promise.allSettled([ + resolution.addr(domain, "ZIL"), + resolution.ipfsHash(domain), + ]); + + const result = { + zilAddress: zilResult.status === "fulfilled" && zilResult.value ? zilResult.value : null, + ipfsHash: ipfsResult.status === "fulfilled" && ipfsResult.value ? ipfsResult.value : null, + }; + + const hasData = Boolean(result.zilAddress || result.ipfsHash); + const resolved = hasData ? result : null; + cacheSet(domain, resolved); + return resolved; +} + +function makeResponse(request) { + const response = Packet.createResponseFromRequest(request); + response.header.aa = 1; + response.header.ra = 1; + return response; +} + +function addTxtAnswers(response, name, resolved) { + if (resolved.zilAddress) { + response.answers.push({ + name, + type: Packet.TYPE.TXT, + class: Packet.CLASS.IN, + ttl: DEFAULT_TTL, + data: `zil_address=${resolved.zilAddress}`, + }); + } + + if (resolved.ipfsHash) { + response.answers.push({ + name, + type: Packet.TYPE.TXT, + class: Packet.CLASS.IN, + ttl: DEFAULT_TTL, + data: `ipfs_hash=${resolved.ipfsHash}`, + }); + } +} + +function addCnameAnswer(response, name, resolved) { + if (!resolved.ipfsHash) { + return; + } + + response.answers.push({ + name, + type: Packet.TYPE.CNAME, + class: Packet.CLASS.IN, + ttl: DEFAULT_TTL, + domain: `${resolved.ipfsHash}.ipfs.dweb.link.`, + }); +} + +async function handleRequest(request, send) { + const response = makeResponse(request); + const question = request.questions && request.questions[0]; + + if (!question) { + send(response); + return; + } + + const qname = normalizeName(question.name); + const qtype = question.type; + const isType = (name) => qtype === name || qtype === Packet.TYPE[name]; + + if (!qname.endsWith(".zil")) { + response.header.rcode = RCODE.NXDOMAIN; + send(response); + return; + } + + const resolved = await resolveZilDomain(qname); + if (!resolved) { + response.header.rcode = RCODE.NXDOMAIN; + send(response); + return; + } + + if (isType("TXT") || isType("ANY")) { + addTxtAnswers(response, question.name, resolved); + } + + if (isType("CNAME") || isType("A") || isType("AAAA") || isType("ANY")) { + addCnameAnswer(response, question.name, resolved); + } + + send(response); +} + +const server = createServer({ + udp: true, + tcp: true, + handle: (request, send) => { + handleRequest(request, send).catch((err) => { + const response = makeResponse(request); + response.header.rcode = RCODE.SERVFAIL; + send(response); + console.error("[zildns] request failed:", err && err.message ? err.message : String(err)); + }); + }, +}); + +server.on("listening", () => { + console.log(`[zildns] listening on ${LISTEN_HOST}:${LISTEN_PORT} (udp/tcp)`); + console.log(`[zildns] zns provider ${ZNS_URL} (${ZNS_NETWORK})`); +}); + +server.on("close", () => { + console.log("[zildns] server closed"); +}); + +server.on("error", (err) => { + console.error("[zildns] server error:", err); +}); + +async function maybePrewarm() { + if (!PREWARM_DOMAIN) { + console.log("[zildns] prewarm disabled"); + return; + } + + const started = Date.now(); + try { + await resolveZilDomain(PREWARM_DOMAIN); + console.log(`[zildns] prewarm complete for ${PREWARM_DOMAIN} in ${Date.now() - started}ms`); + } catch (err) { + console.warn( + `[zildns] prewarm failed for ${PREWARM_DOMAIN}:`, + err && err.message ? err.message : String(err), + ); + } +} + +async function start() { + await maybePrewarm(); + + server.listen({ + udp: { + address: LISTEN_HOST, + port: LISTEN_PORT, + }, + tcp: { + address: LISTEN_HOST, + port: LISTEN_PORT, + }, + }); +} + +start().catch((err) => { + console.error("[zildns] startup failed:", err); + process.exit(1); +});