diff --git a/README.md b/README.md
index c71246e..57ddd3d 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,8 @@ The stack now uses safer defaults for production:
* The published SOCKS entrypoint now requires credentials from a Docker secret.
* CoreDNS recursive resolution for the catch-all zone is restricted to trusted/internal networks.
* Pi-hole ARP cache parsing is disabled in Docker (`FTLCONF_database_network_parseARPcache=false`) to prevent recurring netlink `neigh`/ARP errors.
+* Fixed `10.5.0.x` service IPs are retained for the stack's internal routing/DNS design, but host/LAN-specific values now default to portable settings and can be overridden with environment variables.
+* Optional integrations that need operator-specific credentials, such as Tailscale, are no longer enabled with placeholder defaults in the base stack.
Before starting in production, set these secret files:
@@ -18,11 +20,28 @@ printf 'proxyuser:REPLACE_WITH_A_LONG_RANDOM_PASSWORD\n' > secrets/3proxy_users.
printf 'REPLACE_WITH_A_LONG_RANDOM_NAMECOIN_RPC_PASSWORD\n' > secrets/namecoin_rpc_password.txt
```
+Optional Tailscale note:
+
+* The `tailscale` service is now behind the Compose `tailscale` profile.
+* It is not started by default.
+* To enable it, export a real auth key and start that profile explicitly:
+
+```sh
+export TS_AUTHKEY=tskey-your-real-key
+docker compose --profile tailscale up -d tailscale
+```
+
+Release default note for `.zil`:
+
+* `zildns` now uses explicit public resolvers inside its container so its Zilliqa RPC lookups do not depend on Docker's embedded DNS path.
+* This change is limited to the `zildns` container; the stack's internal fixed `10.5.0.x` service IP design remains unchanged.
+
SOCKS auth note:
* `dark3proxy` now reads credentials from `secrets/3proxy_users.txt`.
* Each non-comment line in that secret must be `username:password`.
* If you intentionally want an internal-only unauthenticated proxy, set `PROXY_REQUIRE_AUTH=false` for `dark3proxy` and remove the public `2000:1080` port mapping.
+* The router now defaults to **no auth bypass CIDRs**. If you want trusted subnets to skip SOCKS auth, set `ROUTER_NO_AUTH_CIDRS` in your shell or `.env`, for example `ROUTER_NO_AUTH_CIDRS=192.168.1.0/24`.
Pi-hole note: disabling ARP parsing avoids noisy `Failed to read ARP cache`
messages in containerized setups where neighbor-table netlink operations are not
@@ -54,6 +73,18 @@ Public proxy access is exposed on a single host port:
Local admin endpoint:
* `127.0.0.1:2002` -> 3proxy admin (`dark3proxy:8161`), host-local only.
+* `127.0.0.1:2003` -> Pi-hole web UI (`darkpihole:80`), host-local by default.
+* `127.0.0.1:2004` -> status dashboard (`darkstatus:8080`), host-local by default.
+
+These host-local admin bindings can be overridden with environment variables when
+needed:
+
+```sh
+export PIHOLE_WEB_BIND_HOST=192.168.1.31
+export PIHOLE_WEB_BIND_PORT=2003
+export STATUS_DASHBOARD_BIND_HOST=192.168.1.31
+export STATUS_DASHBOARD_BIND_PORT=2004
+```
Router internals for `:2000`:
@@ -261,17 +292,24 @@ records in Namecoin itself and wait for local `namecoind` sync completion.
Quick test:
```sh
-docker exec darkpihole dig @10.5.0.4 A oxen.loki
+LOKI_ADDR="$(docker logs darklokinet 2>&1 | sed -n 's/.*endpoint:\([a-z0-9]\{52\}\.loki\).*/\1/p' | tail -n1)"
+docker exec darkpihole dig @10.5.0.4 A "$LOKI_ADDR"
```
> **Note:** Lokinet may need a short bootstrap period after startup before
-> `.loki` names resolve.
+> `.loki` names resolve. Older sample names such as `oxen.loki` are no longer a
+> reliable health check; resolving the container's self-published `.loki`
+> address is a better validation of the local Lokinet path.
-## Monitoring the proxy
+## Monitoring and stack status
-A lightweight Prometheus stack is included to expose 3proxy metrics and give
-you visibility into traffic volumes, connected sessions, etc.
+Two lightweight tools are included for visibility:
+
+* `3proxy_exporter` exposes 3proxy counters in Prometheus format for external
+ scraping.
+* `status_dashboard` serves a local-only status page for the whole stack,
+ including container state, probe results, proxy usernames, and recent logs.
### how it works
@@ -279,32 +317,39 @@ you visibility into traffic volumes, connected sessions, etc.
status socket. Samples look like `PROXY CONNS 12` or `SOCKS IN 345`.
* `monitor/exporter.py` polls that socket every few seconds and exports
the counters on HTTP port **9100** in Prometheus format.
-* The `docker-compose.yml` file defines two monitoring services:
- `3proxy_exporter` and `prometheus`.
- Prometheus scrapes the exporter and exposes the metrics for inspection or
- external dashboarding.
+* `monitor/status_dashboard.py` talks to the local Docker socket, runs targeted
+ DNS and SOCKS checks, and serves both HTML and JSON for stack status.
### building & running
-The exporter lives in `monitor/`; build the image and start the stack:
+The monitoring utilities live in `monitor/`; build the images and start the
+stack:
```sh
-# build everything including the monitoring services
-docker-compose build 3proxy_exporter prometheus
+# build the exporter and status dashboard
+docker-compose build 3proxy_exporter status_dashboard
docker-compose up -d
```
-Prometheus will be accessible on port **9090**. If you want dashboards, you
-can point any external Grafana or compatible tool at Prometheus and use metrics
-such as `proxy_conns` and `proxy_bytes_in`.
+By default the status page is available on **http://127.0.0.1:2004/** and the
+raw JSON is available on **http://127.0.0.1:2004/api/status**. If you override
+the bind host/port via `STATUS_DASHBOARD_BIND_HOST` or
+`STATUS_DASHBOARD_BIND_PORT`, use that address instead.
+
+If you want external dashboards or alerts, point a Prometheus-compatible
+collector at the exporter and use metrics such as `proxy_conns` and
+`proxy_bytes_in`.
### tips
-* Alerts can be added in Prometheus rules, e.g. fire when `proxy_conns`
- exceeds a threshold for several minutes.
-* If you don’t want the full stack, you can still query the monitor port
- directly with `nc`; nothing in the proxy depends on the exporter.
+* Alerts can be added in your external Prometheus rules, e.g. fire when
+ `proxy_conns` exceeds a threshold for several minutes.
+* You can still query the monitor port directly with `nc`; nothing in the
+ proxy depends on the exporter.
+* The status dashboard can show logs and configured proxy usernames, so keep it
+ behind a trusted admin network or add your own access controls before
+ exposing it broadly.
* The `scripts/validate-config.sh` script warns if a 3proxy config lacks a
`monitor` line.
@@ -321,8 +366,12 @@ any warnings or errors. The script covers:
* CoreDNS/PopuraDNS Corefile syntax
* Unbound configuration syntax
* Presence of required secret files
+* Portable host-local defaults for Pi-hole, the status dashboard, and router auth bypass CIDRs
* Simple 3proxy configuration sanity (presence of `socks`/`proxy` rules)
* Detection of mutable `:latest` image tags in deployment compose files
+* Verification that critical resolver/backend services keep explicit healthchecks
+* Live CoreDNS smoke checks for `.onion`, `.i2p`, `.eth`, and `.zil`
+* Verification that Tailscale stays opt-in and does not ship a placeholder auth key
* Verification that restart policies exist
The GitHub Actions workflow also includes a **smoke test** job that spins up
diff --git a/docker-compose.lock.yml b/docker-compose.lock.yml
index ea8571b..ccd1517 100644
--- a/docker-compose.lock.yml
+++ b/docker-compose.lock.yml
@@ -7,7 +7,7 @@ services:
container_name: dark3proxy-exporter
depends_on:
dark3proxy:
- condition: service_started
+ condition: service_healthy
required: true
image: darkproxy-3proxy-exporter:local
networks:
@@ -20,7 +20,7 @@ services:
container_name: darkalfis
dns:
- 10.5.0.6
- image: cofob/alfis
+ image: cofob/alfis@sha256:0c5788b1e409557bb814dc2d055d584db8c48b3d42b375bf13feb59583af0585
networks:
darkproxy:
ipv4_address: 10.5.0.3
@@ -79,7 +79,7 @@ services:
PROXY_USERS_FILE: /run/secrets/PROXY_USERS
ROUTER_I2P_MAP_FILE: /var/lib/i2pdns/map.json
ROUTER_I2P_POOL_CIDR: 172.31.0.0/16
- ROUTER_NO_AUTH_CIDRS: 192.168.1.0/24
+ ROUTER_NO_AUTH_CIDRS: ""
ROUTER_REQUIRE_AUTH: "true"
ROUTER_USERS_FILE: /run/secrets/PROXY_USERS
YGG_CONNECT_WAIT_SECONDS: "8"
@@ -145,7 +145,7 @@ services:
- -conf=/emc/emercoin.conf
- -printtoconsole
container_name: darkemer
- image: wg00/emercoin:0.8.4
+ image: wg00/emercoin:0.8.4@sha256:b890987fb4b158305040dc76b32cd24ed5173dd7bc4aba983141ea0fe82e2996
mem_limit: "1073741824"
mem_reservation: "268435456"
networks:
@@ -173,9 +173,21 @@ services:
dockerfile: Dockerfile
cpus: 0.5
container_name: darkens
+ dns:
+ - 10.5.0.4
environment:
ENS_RPC_URL: https://ethereum-rpc.publicnode.com
ENSDNS_TTL: "60"
+ healthcheck:
+ test:
+ - CMD
+ - python
+ - -c
+ - import socket; from dnslib import DNSRecord; q=DNSRecord.question('vitalik.eth','TXT'); s=socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.settimeout(2); s.sendto(q.pack(), ('127.0.0.1', 53)); data, _ = s.recvfrom(4096); DNSRecord.parse(data)
+ timeout: 10s
+ interval: 1m0s
+ retries: 3
+ start_period: 15s
mem_limit: "268435456"
mem_reservation: "67108864"
networks:
@@ -205,8 +217,8 @@ services:
test:
- CMD
- bash
- - -c
- - pgrep i2pd && pgrep yggdrasil
+ - -lc
+ - pgrep i2pd >/dev/null && pgrep yggdrasil >/dev/null && [ -s /var/lib/i2pd/addressbook/addresses.csv ]
timeout: 10s
interval: 30s
retries: 3
@@ -238,6 +250,15 @@ services:
target: /run/secrets/tz
sysctls:
net.ipv6.conf.all.disable_ipv6: "0"
+ volumes:
+ - type: volume
+ source: i2pd_state
+ target: /var/lib/i2pd
+ volume: {}
+ - type: volume
+ source: yggdrasil_state
+ target: /var/lib/yggdrasil
+ volume: {}
i2pdns:
build:
context: /home/blade/darkproxy/i2pdns
@@ -250,6 +271,16 @@ services:
I2PDNS_MAP_FILE: /var/lib/i2pdns/map.json
I2PDNS_POOL_CIDR: 172.31.0.0/16
I2PDNS_TTL: "60"
+ healthcheck:
+ test:
+ - CMD
+ - python
+ - -c
+ - import socket; from dnslib import DNSRecord; q=DNSRecord.question('stats.i2p','A'); s=socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.settimeout(2); s.sendto(q.pack(), ('127.0.0.1', 53)); data, _ = s.recvfrom(4096); DNSRecord.parse(data)
+ timeout: 10s
+ interval: 1m0s
+ retries: 3
+ start_period: 10s
mem_limit: "134217728"
mem_reservation: "67108864"
networks:
@@ -279,6 +310,14 @@ services:
- source: /dev/net/tun
target: /dev/net/tun
permissions: rwm
+ healthcheck:
+ test:
+ - CMD-SHELL
+ - pidof lokinet >/dev/null && ip link show lokinet0 >/dev/null 2>&1
+ timeout: 10s
+ interval: 1m0s
+ retries: 3
+ start_period: 45s
mem_limit: "268435456"
mem_reservation: "67108864"
networks:
@@ -314,6 +353,14 @@ services:
-addnode=3.228.193.128:8334 \
-addnode=3.66.245.44:8334 \
-addnode=212.51.144.42:8334
+ healthcheck:
+ test:
+ - CMD-SHELL
+ - 'curl -fsS --user "namecoinrpc:$$(cat /run/secrets/NAMECOIN_RPC_PASSWORD)" --data-binary ''{"jsonrpc":"1.0","id":"health","method":"getblockcount","params":[]}'' -H ''content-type: text/plain;'' http://127.0.0.1:8336/ >/dev/null'
+ timeout: 10s
+ interval: 1m0s
+ retries: 3
+ start_period: 30s
image: ukd1/namecoind@sha256:6fdf63f61f687ee639893a8cdcd910bb24c5eb3a3493ff72142c911d7019200b
mem_limit: "2147483648"
mem_reservation: "536870912"
@@ -342,13 +389,23 @@ services:
container_name: darknamecoin
depends_on:
namecoind:
- condition: service_started
+ condition: service_healthy
required: true
environment:
NAMECOIN_RPC_PASSWORD_FILE: /run/secrets/NAMECOIN_RPC_PASSWORD
NAMECOIN_RPC_TIMEOUT: "8"
NAMECOIN_RPC_URL: http://darknamecoind:8336/
NAMECOIN_RPC_USER: namecoinrpc
+ healthcheck:
+ test:
+ - CMD
+ - python
+ - -c
+ - import socket; from dnslib import DNSRecord; q=DNSRecord.question('d.bit','TXT'); s=socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.settimeout(2); s.sendto(q.pack(), ('127.0.0.1', 53)); data, _ = s.recvfrom(4096); DNSRecord.parse(data)
+ timeout: 10s
+ interval: 1m0s
+ retries: 3
+ start_period: 15s
mem_limit: "268435456"
mem_reservation: "67108864"
networks:
@@ -369,10 +426,13 @@ services:
- SYS_NICE
cpus: 1.5
container_name: darkpihole
+ entrypoint:
+ - /usr/local/bin/darkproxy-pihole-entrypoint.sh
environment:
FTLCONF_database_network_parseARPcache: "false"
+ FTLCONF_dns_listeningMode: all
FTLCONF_dns_upstreams: 10.5.0.4#53
- PIHOLE_DNS_: 10.5.0.4
+ PIHOLE_DNS_1: 10.5.0.4
TEMPERATUREUNIT: f
TZ: America/Detroit
WEBPASSWORD_FILE: /run/secrets/PIHOLE_WEBPASSWORD
@@ -390,7 +450,7 @@ services:
interval: 1m0s
retries: 3
start_period: 30s
- image: pihole/pihole@sha256:1c32c36b862a12762656b6471c854cebc01fe945639ba3a893611337c2c95e99
+ image: pihole/pihole@sha256:712b39f1fdb55121cef509813dfbe02d2bdef9c28e07404fa1d422f5157323b2
mem_limit: "536870912"
mem_reservation: "134217728"
networks:
@@ -430,9 +490,47 @@ services:
target: /etc/dnsmasq.d
bind:
create_host_path: true
- prometheus:
- container_name: darkprom
- image: prom/prometheus@sha256:e4254400b85610324913f0dc4acf92603d9984e7519414c5a12811aa6146acc3
+ - type: bind
+ source: /home/blade/darkproxy/scripts/pihole-entrypoint.sh
+ target: /usr/local/bin/darkproxy-pihole-entrypoint.sh
+ read_only: true
+ bind:
+ create_host_path: true
+ status_dashboard:
+ build:
+ context: /home/blade/darkproxy/monitor
+ dockerfile: status.Dockerfile
+ container_name: darkstatus
+ depends_on:
+ dark3proxy:
+ condition: service_healthy
+ required: true
+ pihole:
+ condition: service_healthy
+ required: true
+ environment:
+ DASHBOARD_PORT: "8080"
+ DNS_PORT: "53"
+ DNS_SERVER: darkpihole
+ DOCKER_PROJECT: darkproxy
+ DOCKER_SOCKET: /var/run/docker.sock
+ LOG_TAIL_LINES: "120"
+ PROXY_HOST: dark3proxy
+ PROXY_PORT: "1080"
+ PROXY_USERS_FILE: /run/secrets/PROXY_USERS
+ SOCKET_TIMEOUT: "5"
+ STATUS_CACHE_SECONDS: "15"
+ healthcheck:
+ test:
+ - CMD
+ - python
+ - -c
+ - import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=5).read()
+ timeout: 10s
+ interval: 1m0s
+ retries: 3
+ start_period: 20s
+ image: darkproxy-status-dashboard:local
networks:
darkproxy:
ipv4_address: 10.5.0.21
@@ -440,50 +538,22 @@ services:
ports:
- mode: ingress
host_ip: 127.0.0.1
- target: 9090
- published: "9090"
+ target: 8080
+ published: "2004"
protocol: tcp
+ restart: unless-stopped
+ secrets:
+ - source: PROXY_USERS
+ target: /run/secrets/PROXY_USERS
security_opt:
- no-new-privileges:true
volumes:
- type: bind
- source: /home/blade/darkproxy/monitor/prometheus.yml
- target: /etc/prometheus/prometheus.yml
+ source: /var/run/docker.sock
+ target: /var/run/docker.sock
read_only: true
bind:
create_host_path: true
- tailscale:
- build:
- context: /home/blade/darkproxy/tailscale
- dockerfile: Dockerfile
- cap_add:
- - NET_ADMIN
- - SYS_MODULE
- container_name: darkscale
- devices:
- - source: /dev/net/tun
- target: /dev/net/tun
- permissions: rwm
- environment:
- TS_AUTHKEY: tskey-YOUR-AUTH-KEY-HERE
- TS_EXTRA_ARGS: --accept-dns=false --advertise-exit-node
- TS_STATE_DIR: /var/lib/tailscale
- hostname: darkproxy-exit
- networks:
- darkproxy:
- ipv4_address: 10.5.0.10
- platform: linux/amd64
- restart: unless-stopped
- security_opt:
- - no-new-privileges:true
- sysctls:
- net.ipv4.ip_forward: "1"
- net.ipv6.conf.all.forwarding: "1"
- volumes:
- - type: volume
- source: tailscale_data
- target: /var/lib/tailscale
- volume: {}
tor_yggdrasil:
build:
context: /home/blade/darkproxy/tor_yggdrasil_docker
@@ -524,6 +594,14 @@ services:
unbound:
cpus: 4
container_name: darkunbound
+ healthcheck:
+ test:
+ - CMD-SHELL
+ - drill @127.0.0.1 google.com >/dev/null 2>&1
+ timeout: 10s
+ interval: 1m0s
+ retries: 3
+ start_period: 20s
image: mvance/unbound@sha256:76906da36d1806f3387338f15dcf8b357c51ce6897fb6450d6ce010460927e90
mem_limit: "805306368"
mem_reservation: "268435456"
@@ -562,12 +640,25 @@ services:
dockerfile: Dockerfile
cpus: 0.5
container_name: darkzil
+ dns:
+ - 1.1.1.1
+ - 8.8.8.8
environment:
ZILDNS_CACHE_SECONDS: "300"
ZILDNS_PREWARM_DOMAIN: brad.zil
ZILDNS_TTL: "120"
ZILDNS_ZNS_NETWORK: mainnet
ZILDNS_ZNS_URL: https://api.zilliqa.com
+ healthcheck:
+ test:
+ - CMD
+ - node
+ - -e
+ - const net=require('net'); const socket=net.createConnection({host:'127.0.0.1', port:53}); socket.setTimeout(2000); socket.on('connect',()=>{socket.end(); process.exit(0);}); socket.on('timeout',()=>{socket.destroy(); process.exit(1);}); socket.on('error',()=>process.exit(1));
+ timeout: 10s
+ interval: 1m0s
+ retries: 3
+ start_period: 20s
mem_limit: "268435456"
mem_reservation: "67108864"
networks:
@@ -597,10 +688,12 @@ volumes:
name: emc_data
i2p_dns_map:
name: i2p_dns_map
+ i2pd_state:
+ name: i2pd_state
namecoin_data:
name: namecoin_data
- tailscale_data:
- name: tailscale_data
+ yggdrasil_state:
+ name: yggdrasil_state
secrets:
NAMECOIN_RPC_PASSWORD:
name: darkproxy_NAMECOIN_RPC_PASSWORD
diff --git a/docker-compose.yml b/docker-compose.yml
index 05f7211..31853db 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -16,7 +16,7 @@ services:
PROXY_REQUIRE_AUTH: "true"
PROXY_USERS_FILE: /run/secrets/PROXY_USERS
ROUTER_REQUIRE_AUTH: "true"
- ROUTER_NO_AUTH_CIDRS: 192.168.1.0/24
+ ROUTER_NO_AUTH_CIDRS: ${ROUTER_NO_AUTH_CIDRS:-}
ROUTER_USERS_FILE: /run/secrets/PROXY_USERS
ROUTER_I2P_MAP_FILE: /var/lib/i2pdns/map.json
ROUTER_I2P_POOL_CIDR: 172.31.0.0/16
@@ -80,12 +80,15 @@ services:
tz: /run/secrets/tz
secrets:
- tz
+ volumes:
+ - i2pd_state:/var/lib/i2pd
+ - yggdrasil_state:/var/lib/yggdrasil
restart: unless-stopped
cpus: "4.0"
mem_reservation: 512m
mem_limit: 2g
healthcheck:
- test: ["CMD", "bash", "-c", "pgrep i2pd && pgrep yggdrasil"]
+ test: ["CMD", "bash", "-lc", "pgrep i2pd >/dev/null && pgrep yggdrasil >/dev/null && [ -s /var/lib/i2pd/addressbook/addresses.csv ]"]
interval: 30s
timeout: 10s
retries: 3
@@ -120,6 +123,12 @@ services:
- "i2p_dns_map:/var/lib/i2pdns"
sysctls:
- "net.ipv6.conf.all.disable_ipv6=0"
+ healthcheck:
+ test: ["CMD", "python", "-c", "import socket; from dnslib import DNSRecord; q=DNSRecord.question('stats.i2p','A'); s=socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.settimeout(2); s.sendto(q.pack(), ('127.0.0.1', 53)); data, _ = s.recvfrom(4096); DNSRecord.parse(data)"]
+ interval: 1m
+ timeout: 10s
+ retries: 3
+ start_period: 10s
networks:
darkproxy:
ipv4_address: 10.5.0.16
@@ -153,7 +162,7 @@ services:
ipv4_address: 10.5.0.7
alfis:
- image: cofob/alfis
+ image: cofob/alfis@sha256:0c5788b1e409557bb814dc2d055d584db8c48b3d42b375bf13feb59583af0585
platform: linux/amd64
# platform removed; image is amd64 only
container_name: darkalfis
@@ -223,11 +232,18 @@ services:
cpus: "4.0"
mem_reservation: 256m
mem_limit: 768m
+ healthcheck:
+ test: ["CMD-SHELL", "drill @127.0.0.1 google.com >/dev/null 2>&1"]
+ interval: 1m
+ timeout: 10s
+ retries: 3
+ start_period: 20s
pihole:
container_name: darkpihole
- image: pihole/pihole@sha256:1c32c36b862a12762656b6471c854cebc01fe945639ba3a893611337c2c95e99
+ image: pihole/pihole@sha256:712b39f1fdb55121cef509813dfbe02d2bdef9c28e07404fa1d422f5157323b2
platform: linux/amd64
+ entrypoint: ["/usr/local/bin/darkproxy-pihole-entrypoint.sh"]
security_opt:
- no-new-privileges:true
# For DHCP it is recommended to remove these ports and instead add: network_mode: "host"
@@ -243,15 +259,15 @@ services:
# - "67:67/udp"
# Uncomment the line below if you are using Pi-hole as your NTP server
# - "123:123/udp"
- - "127.0.0.1:2003:80/tcp" # pihole web port (localhost only)
+ - "${PIHOLE_WEB_BIND_HOST:-127.0.0.1}:${PIHOLE_WEB_BIND_PORT:-2003}:80/tcp" # pihole web port (host-local by default)
environment:
# Set the appropriate timezone for your location (https://en.wikipedia.org/wiki/List_of_tz_database_time_zones), e.g:
TZ: 'America/Detroit'
# WEBPASSWORD: 'set a secure password here or it will be random'
# FTLCONF_webserver_api_password: 'darkproxy'
# If using Docker's default `bridge` network setting the dns listening mode should be set to 'all'
-# FTLCONF_dns_listeningMode: 'all'
- PIHOLE_DNS_: 10.5.0.4
+ FTLCONF_dns_listeningMode: 'all'
+ PIHOLE_DNS_1: 10.5.0.4
TEMPERATUREUNIT: f
WEBTHEME: lcars
WEBPASSWORD_FILE: /run/secrets/PIHOLE_WEBPASSWORD
@@ -266,6 +282,7 @@ services:
- './pihole/etc-pihole:/etc/pihole'
# Uncomment the below if you have custom dnsmasq config files that you want to persist. Not needed for most starting fresh with Pi-hole v6. If you're upgrading from v5 you and have used this directory before, you should keep it enabled for the first v6 container start to allow for a complete migration. It can be removed afterwards. Needs environment variable FTLCONF_misc_etc_dnsmasq_d: 'true'
- './pihole/etc-dnsmasq.d:/etc/dnsmasq.d'
+ - './scripts/pihole-entrypoint.sh:/usr/local/bin/darkproxy-pihole-entrypoint.sh:ro'
cap_add:
# See https://github.com/pi-hole/docker-pi-hole#note-on-capabilities
# Required if you are using Pi-hole as your DHCP server, else not needed
@@ -291,7 +308,7 @@ services:
ipv4_address: 10.5.0.6
emc:
- image: wg00/emercoin:0.8.4
+ image: wg00/emercoin:0.8.4@sha256:b890987fb4b158305040dc76b32cd24ed5173dd7bc4aba983141ea0fe82e2996
platform: linux/amd64
container_name: darkemer
security_opt:
@@ -327,8 +344,16 @@ services:
environment:
ENS_RPC_URL: https://ethereum-rpc.publicnode.com
ENSDNS_TTL: 60
+ dns:
+ - "10.5.0.4"
sysctls:
- "net.ipv6.conf.all.disable_ipv6=0"
+ healthcheck:
+ test: ["CMD", "python", "-c", "import socket; from dnslib import DNSRecord; q=DNSRecord.question('vitalik.eth','TXT'); s=socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.settimeout(2); s.sendto(q.pack(), ('127.0.0.1', 53)); data, _ = s.recvfrom(4096); DNSRecord.parse(data)"]
+ interval: 1m
+ timeout: 10s
+ retries: 3
+ start_period: 15s
networks:
darkproxy:
ipv4_address: 10.5.0.11
@@ -351,8 +376,17 @@ services:
ZILDNS_TTL: 120
ZILDNS_CACHE_SECONDS: 300
ZILDNS_PREWARM_DOMAIN: brad.zil
+ dns:
+ - "1.1.1.1"
+ - "8.8.8.8"
sysctls:
- "net.ipv6.conf.all.disable_ipv6=0"
+ healthcheck:
+ test: ["CMD", "node", "-e", "const net=require('net'); const socket=net.createConnection({host:'127.0.0.1', port:53}); socket.setTimeout(2000); socket.on('connect',()=>{socket.end(); process.exit(0);}); socket.on('timeout',()=>{socket.destroy(); process.exit(1);}); socket.on('error',()=>process.exit(1));"]
+ interval: 1m
+ timeout: 10s
+ retries: 3
+ start_period: 20s
networks:
darkproxy:
ipv4_address: 10.5.0.15
@@ -376,6 +410,12 @@ services:
- /dev/net/tun:/dev/net/tun
sysctls:
- "net.ipv6.conf.all.disable_ipv6=0"
+ healthcheck:
+ test: ["CMD-SHELL", "pidof lokinet >/dev/null && ip link show lokinet0 >/dev/null 2>&1"]
+ interval: 1m
+ timeout: 10s
+ retries: 3
+ start_period: 45s
networks:
darkproxy:
ipv4_address: 10.5.0.14
@@ -417,6 +457,12 @@ services:
- namecoin_data:/data
sysctls:
- "net.ipv6.conf.all.disable_ipv6=0"
+ healthcheck:
+ test: ["CMD-SHELL", "curl -fsS --user \"namecoinrpc:$(cat /run/secrets/NAMECOIN_RPC_PASSWORD)\" --data-binary '{\"jsonrpc\":\"1.0\",\"id\":\"health\",\"method\":\"getblockcount\",\"params\":[]}' -H 'content-type: text/plain;' http://127.0.0.1:8336/ >/dev/null"]
+ interval: 1m
+ timeout: 10s
+ retries: 3
+ start_period: 30s
networks:
darkproxy:
ipv4_address: 10.5.0.13
@@ -434,7 +480,8 @@ services:
mem_reservation: 64m
mem_limit: 256m
depends_on:
- - namecoind
+ namecoind:
+ condition: service_healthy
environment:
NAMECOIN_RPC_URL: http://darknamecoind:8336/
NAMECOIN_RPC_USER: namecoinrpc
@@ -444,6 +491,12 @@ services:
- NAMECOIN_RPC_PASSWORD
sysctls:
- "net.ipv6.conf.all.disable_ipv6=0"
+ healthcheck:
+ test: ["CMD", "python", "-c", "import socket; from dnslib import DNSRecord; q=DNSRecord.question('d.bit','TXT'); s=socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.settimeout(2); s.sendto(q.pack(), ('127.0.0.1', 53)); data, _ = s.recvfrom(4096); DNSRecord.parse(data)"]
+ interval: 1m
+ timeout: 10s
+ retries: 3
+ start_period: 15s
networks:
darkproxy:
ipv4_address: 10.5.0.12
@@ -453,6 +506,7 @@ services:
context: ./tailscale
dockerfile: Dockerfile
platform: linux/amd64
+ profiles: ["tailscale"]
container_name: darkscale
hostname: darkproxy-exit
security_opt:
@@ -463,8 +517,7 @@ services:
devices:
- /dev/net/tun:/dev/net/tun
environment:
- # ⚠️ Get your own auth key from https://login.tailscale.com/admin/settings/keys
- - TS_AUTHKEY=${TS_AUTHKEY:-tskey-YOUR-AUTH-KEY-HERE}
+ - TS_AUTHKEY=${TS_AUTHKEY:-}
- TS_EXTRA_ARGS=--accept-dns=false --advertise-exit-node
- TS_STATE_DIR=/var/lib/tailscale
volumes:
@@ -487,26 +540,60 @@ services:
security_opt:
- no-new-privileges:true
depends_on:
- - dark3proxy
+ dark3proxy:
+ condition: service_healthy
networks:
darkproxy:
ipv4_address: 10.5.0.20
- prometheus:
- image: prom/prometheus@sha256:e4254400b85610324913f0dc4acf92603d9984e7519414c5a12811aa6146acc3
- container_name: darkprom
+ status_dashboard:
+ container_name: darkstatus
+ build:
+ context: ./monitor
+ dockerfile: status.Dockerfile
+ image: darkproxy-status-dashboard:local
platform: linux/amd64
security_opt:
- no-new-privileges:true
+ depends_on:
+ dark3proxy:
+ condition: service_healthy
+ pihole:
+ condition: service_healthy
+ environment:
+ DASHBOARD_PORT: 8080
+ DOCKER_PROJECT: darkproxy
+ DOCKER_SOCKET: /var/run/docker.sock
+ DNS_SERVER: darkpihole
+ DNS_PORT: 53
+ PROXY_HOST: dark3proxy
+ PROXY_PORT: 1080
+ PROXY_USERS_FILE: /run/secrets/PROXY_USERS
+ STATUS_CACHE_SECONDS: 15
+ SOCKET_TIMEOUT: 5
+ LOG_TAIL_LINES: 120
+ secrets:
+ - PROXY_USERS
volumes:
- - ./monitor/prometheus.yml:/etc/prometheus/prometheus.yml:ro
+ - /var/run/docker.sock:/var/run/docker.sock:ro
ports:
- - "127.0.0.1:9090:9090"
+ - "${STATUS_DASHBOARD_BIND_HOST:-127.0.0.1}:${STATUS_DASHBOARD_BIND_PORT:-2004}:8080"
+ healthcheck:
+ test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=5).read()"]
+ interval: 1m
+ timeout: 10s
+ retries: 3
+ start_period: 20s
networks:
darkproxy:
ipv4_address: 10.5.0.21
+ restart: unless-stopped
volumes:
+ i2pd_state:
+ name: i2pd_state
+ yggdrasil_state:
+ name: yggdrasil_state
darkalfis_data:
name: darkalfis_data
emc_data:
diff --git a/ensdns/server.py b/ensdns/server.py
index 4c93a2f..96780c4 100644
--- a/ensdns/server.py
+++ b/ensdns/server.py
@@ -2,6 +2,7 @@
import os
import socket
import threading
+import time
from dnslib import A, AAAA, CNAME, QTYPE, RR, TXT, DNSHeader, DNSRecord, RCODE
from web3 import HTTPProvider, Web3
@@ -11,6 +12,7 @@ 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"))
+RECONNECT_INTERVAL = float(os.getenv("ENSDNS_RECONNECT_INTERVAL", "30"))
ENS_REGISTRY_ADDRESS = Web3.to_checksum_address("0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e")
@@ -44,6 +46,10 @@ PUBLIC_RESOLVER_ABI = [
EMPTY_ADDRESS = "0x0000000000000000000000000000000000000000"
+class ENSBackendUnavailable(RuntimeError):
+ pass
+
+
def namehash(name: str) -> bytes:
node = b"\x00" * 32
labels = [label for label in name.strip().lower().split(".") if label]
@@ -59,18 +65,58 @@ def normalize_qname(qname: str) -> str:
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)
+ self.rpc_url = rpc_url
+ self._lock = threading.Lock()
+ self._web3 = None
+ self._registry = None
+ self._last_connect_attempt = 0.0
+ self._last_error = None
+
+ def _connect(self, force: bool = False) -> bool:
+ now = time.monotonic()
+ with self._lock:
+ if self._web3 is not None and self._registry is not None and self._web3.is_connected():
+ return True
+ if not force and now - self._last_connect_attempt < RECONNECT_INTERVAL:
+ return False
+
+ self._last_connect_attempt = now
+ web3 = Web3(HTTPProvider(self.rpc_url, request_kwargs={"timeout": 10}))
+ if not web3.is_connected():
+ self._web3 = None
+ self._registry = None
+ self._last_error = f"Could not connect to Ethereum RPC: {self.rpc_url}"
+ print(f"[ensdns] {self._last_error}", flush=True)
+ return False
+
+ self._web3 = web3
+ self._registry = web3.eth.contract(address=ENS_REGISTRY_ADDRESS, abi=ENS_REGISTRY_ABI)
+ self._last_error = None
+ print(f"[ensdns] connected to {self.rpc_url}", flush=True)
+ return True
+
+ def _ensure_backend(self):
+ if not self._connect():
+ detail = self._last_error or f"Could not connect to Ethereum RPC: {self.rpc_url}"
+ raise ENSBackendUnavailable(detail)
+ return self._web3, self._registry
def resolve(self, name: str):
+ web3, registry = self._ensure_backend()
node = namehash(name)
- resolver_addr = self.registry.functions.resolver(node).call()
+ try:
+ resolver_addr = registry.functions.resolver(node).call()
+ except Exception as exc:
+ with self._lock:
+ self._web3 = None
+ self._registry = None
+ self._last_error = f"ENS registry lookup failed: {exc}"
+ raise ENSBackendUnavailable(self._last_error) from exc
+
if not resolver_addr or resolver_addr == EMPTY_ADDRESS:
return None
- resolver = self.web3.eth.contract(address=resolver_addr, abi=PUBLIC_RESOLVER_ABI)
+ resolver = web3.eth.contract(address=resolver_addr, abi=PUBLIC_RESOLVER_ABI)
addr = None
contenthash = None
@@ -105,7 +151,13 @@ def answer_query(record: DNSRecord, ens: ENSResolver) -> DNSRecord:
reply.header.rcode = RCODE.NXDOMAIN
return reply
- resolved = ens.resolve(qname)
+ try:
+ resolved = ens.resolve(qname)
+ except ENSBackendUnavailable as exc:
+ print(f"[ensdns] backend unavailable while resolving {qname}: {exc}", flush=True)
+ reply.header.rcode = RCODE.SERVFAIL
+ return reply
+
if resolved is None:
reply.header.rcode = RCODE.NXDOMAIN
return reply
@@ -163,8 +215,9 @@ def serve_tcp(sock: socket.socket, ens: ENSResolver):
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)
+ if not ens._connect(force=True):
+ print(f"[ensdns] starting degraded; RPC unavailable at {RPC_URL}", flush=True)
udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
udp_sock.bind((LISTEN_HOST, LISTEN_PORT))
diff --git a/monitor/status.Dockerfile b/monitor/status.Dockerfile
new file mode 100644
index 0000000..c7a31e8
--- /dev/null
+++ b/monitor/status.Dockerfile
@@ -0,0 +1,19 @@
+FROM python:3.12-alpine
+
+WORKDIR /app
+COPY status_dashboard.py /app/status_dashboard.py
+
+ENV DASHBOARD_PORT=8080 \
+ DOCKER_PROJECT=darkproxy \
+ DOCKER_SOCKET=/var/run/docker.sock \
+ DNS_SERVER=darkpihole \
+ DNS_PORT=53 \
+ PROXY_HOST=dark3proxy \
+ PROXY_PORT=1080 \
+ PROXY_USERS_FILE=/run/secrets/PROXY_USERS \
+ STATUS_CACHE_SECONDS=15 \
+ SOCKET_TIMEOUT=5 \
+ LOG_TAIL_LINES=120
+
+EXPOSE 8080
+CMD ["python", "/app/status_dashboard.py"]
diff --git a/monitor/status_dashboard.py b/monitor/status_dashboard.py
new file mode 100644
index 0000000..c3c7cb8
--- /dev/null
+++ b/monitor/status_dashboard.py
@@ -0,0 +1,880 @@
+import http.client
+import json
+import os
+import random
+import re
+import socket
+import struct
+import threading
+import time
+import urllib.parse
+from datetime import datetime, timezone
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+
+
+DASHBOARD_PORT = int(os.getenv("DASHBOARD_PORT", "8080"))
+DOCKER_SOCKET = os.getenv("DOCKER_SOCKET", "/var/run/docker.sock")
+DOCKER_PROJECT = os.getenv("DOCKER_PROJECT", "darkproxy")
+DNS_SERVER = os.getenv("DNS_SERVER", "darkpihole")
+DNS_PORT = int(os.getenv("DNS_PORT", "53"))
+PROXY_HOST = os.getenv("PROXY_HOST", "dark3proxy")
+PROXY_PORT = int(os.getenv("PROXY_PORT", "1080"))
+PROXY_USERS_FILE = os.getenv("PROXY_USERS_FILE", "/run/secrets/PROXY_USERS")
+SOCKET_TIMEOUT = float(os.getenv("SOCKET_TIMEOUT", "5"))
+STATUS_CACHE_SECONDS = int(os.getenv("STATUS_CACHE_SECONDS", "15"))
+LOG_TAIL_LINES = int(os.getenv("LOG_TAIL_LINES", "120"))
+
+ERROR_RE = re.compile(r"error|failed|not found|panic|traceback|unhealthy|refused|parse", re.IGNORECASE)
+IGNORE_RE = {
+ "darkscale": re.compile(r"tpmrm0|Tailscale is stopped", re.IGNORECASE),
+ "darkpihole": re.compile(r"refused to do a recursive query", re.IGNORECASE),
+ "darki2p": re.compile(
+ r"SessionCreated read error: End of file|"
+ r"Connect error Operation canceled|"
+ r"Connect error Network is unreachable|"
+ r"RouterInfo for .* not found|"
+ r"RouterInfo not found, failed to send messages|"
+ r"NetDbReq: .* not found after 5 attempts",
+ re.IGNORECASE,
+ ),
+}
+RCODE_NAMES = {
+ 0: "NOERROR",
+ 1: "FORMERR",
+ 2: "SERVFAIL",
+ 3: "NXDOMAIN",
+ 4: "NOTIMP",
+ 5: "REFUSED",
+}
+DNS_CHECKS = [
+ {
+ "name": "google.com",
+ "record_type": "A",
+ "severity": "critical",
+ "label": "Public DNS path",
+ "note": "Pi-hole -> CoreDNS -> Unbound",
+ },
+ {
+ "name": "facebookcorewwwi.onion",
+ "record_type": "A",
+ "severity": "critical",
+ "label": ".onion resolution",
+ "note": "Tor DNS path",
+ },
+ {
+ "name": "stats.i2p",
+ "record_type": "A",
+ "severity": "critical",
+ "label": ".i2p resolution",
+ "note": "i2pdns bridge path",
+ },
+ {
+ "name": "vitalik.eth",
+ "record_type": "TXT",
+ "severity": "warning",
+ "label": ".eth resolution",
+ "note": "ENS resolver path",
+ },
+ {
+ "name": "brad.zil",
+ "record_type": "TXT",
+ "severity": "warning",
+ "label": ".zil resolution",
+ "note": "Zilliqa resolver path",
+ },
+ {
+ "name": "d.bit",
+ "record_type": "TXT",
+ "severity": "info",
+ "label": ".bit resolution",
+ "note": "Sync-dependent Namecoin path",
+ },
+]
+
+STATUS_CACHE = {"expires_at": 0.0, "payload": None}
+CACHE_LOCK = threading.Lock()
+
+
+class UnixSocketHTTPConnection(http.client.HTTPConnection):
+ def __init__(self, socket_path: str, timeout: float):
+ super().__init__("localhost", timeout=timeout)
+ self.socket_path = socket_path
+
+ def connect(self):
+ self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ self.sock.settimeout(self.timeout)
+ self.sock.connect(self.socket_path)
+
+
+def now_iso() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+def docker_request(path: str) -> tuple[int, bytes]:
+ conn = UnixSocketHTTPConnection(DOCKER_SOCKET, timeout=SOCKET_TIMEOUT)
+ try:
+ conn.request("GET", path)
+ response = conn.getresponse()
+ return response.status, response.read()
+ finally:
+ conn.close()
+
+
+def docker_json(path: str):
+ status, payload = docker_request(path)
+ if status >= 400:
+ raise RuntimeError(f"Docker API request failed for {path}: HTTP {status}")
+ return json.loads(payload.decode("utf-8"))
+
+
+def docker_bytes(path: str) -> bytes:
+ status, payload = docker_request(path)
+ if status >= 400:
+ raise RuntimeError(f"Docker API request failed for {path}: HTTP {status}")
+ return payload
+
+
+def decode_docker_stream(payload: bytes) -> str:
+ # Docker may frame stdout/stderr for non-TTY containers using an 8-byte header.
+ chunks = []
+ offset = 0
+ while offset + 8 <= len(payload):
+ if payload[offset + 1 : offset + 4] != b"\x00\x00\x00":
+ return payload.decode("utf-8", errors="replace")
+ frame_len = struct.unpack(">I", payload[offset + 4 : offset + 8])[0]
+ frame_start = offset + 8
+ frame_end = frame_start + frame_len
+ if frame_end > len(payload):
+ return payload.decode("utf-8", errors="replace")
+ chunks.append(payload[frame_start:frame_end])
+ offset = frame_end
+ if offset == len(payload):
+ return b"".join(chunks).decode("utf-8", errors="replace")
+ return payload.decode("utf-8", errors="replace")
+
+
+def service_log_text(container_id: str, tail: int) -> str:
+ query = urllib.parse.urlencode({"stdout": 1, "stderr": 1, "tail": max(1, min(tail, 500))})
+ payload = docker_bytes(f"/containers/{container_id}/logs?{query}")
+ return decode_docker_stream(payload).strip()
+
+
+def extract_error_excerpt(name: str, log_text: str) -> list[str]:
+ ignore = IGNORE_RE.get(name)
+ matches = []
+ for line in log_text.splitlines():
+ if not ERROR_RE.search(line):
+ continue
+ if ignore and ignore.search(line):
+ continue
+ matches.append(line)
+ return matches[-6:]
+
+
+def read_proxy_users() -> list[dict[str, str]]:
+ users = []
+ try:
+ with open(PROXY_USERS_FILE, "r", encoding="utf-8") as handle:
+ for raw_line in handle:
+ line = raw_line.strip()
+ if not line or line.startswith("#") or ":" not in line:
+ continue
+ username, password = line.split(":", 1)
+ if username:
+ users.append({"username": username, "password": password})
+ except FileNotFoundError:
+ return []
+ return users
+
+
+def encode_dns_name(name: str) -> bytes:
+ parts = name.rstrip(".").split(".")
+ encoded = bytearray()
+ for part in parts:
+ label = part.encode("idna")
+ encoded.append(len(label))
+ encoded.extend(label)
+ encoded.append(0)
+ return bytes(encoded)
+
+
+def read_dns_name(payload: bytes, offset: int) -> tuple[str, int]:
+ labels = []
+ jumped = False
+ next_offset = offset
+ while True:
+ length = payload[offset]
+ if length == 0:
+ offset += 1
+ if not jumped:
+ next_offset = offset
+ break
+ if length & 0xC0 == 0xC0:
+ pointer = struct.unpack(">H", payload[offset : offset + 2])[0] & 0x3FFF
+ offset = pointer
+ if not jumped:
+ next_offset += 2
+ jumped = True
+ continue
+ offset += 1
+ labels.append(payload[offset : offset + length].decode("utf-8", errors="replace"))
+ offset += length
+ if not jumped:
+ next_offset = offset
+ return ".".join(labels), next_offset
+
+
+def parse_dns_answers(payload: bytes, qtype: int) -> tuple[str, list[str]]:
+ _, _, _, answer_count, _, _ = struct.unpack(">HHHHHH", payload[:12])
+ offset = 12
+ for _ in range(1):
+ _, offset = read_dns_name(payload, offset)
+ offset += 4
+ answers = []
+ for _ in range(answer_count):
+ _, offset = read_dns_name(payload, offset)
+ record_type, _, _, rdlength = struct.unpack(">HHIH", payload[offset : offset + 10])
+ offset += 10
+ rdata = payload[offset : offset + rdlength]
+ offset += rdlength
+ if record_type == 1 and qtype == 1 and rdlength == 4:
+ answers.append(socket.inet_ntoa(rdata))
+ elif record_type == 16 and qtype == 16 and rdlength > 0:
+ strings = []
+ inner_offset = 0
+ while inner_offset < len(rdata):
+ chunk_len = rdata[inner_offset]
+ inner_offset += 1
+ strings.append(rdata[inner_offset : inner_offset + chunk_len].decode("utf-8", errors="replace"))
+ inner_offset += chunk_len
+ answers.append("".join(strings))
+ rcode = payload[3] & 0x0F
+ return RCODE_NAMES.get(rcode, f"RCODE_{rcode}"), answers
+
+
+def dns_query(name: str, record_type: str) -> dict[str, object]:
+ qtype = 1 if record_type == "A" else 16
+ query_id = random.randint(0, 65535)
+ flags = 0x0100
+ header = struct.pack(">HHHHHH", query_id, flags, 1, 0, 0, 0)
+ question = encode_dns_name(name) + struct.pack(">HH", qtype, 1)
+ message = header + question
+ start = time.monotonic()
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
+ sock.settimeout(SOCKET_TIMEOUT)
+ sock.sendto(message, (DNS_SERVER, DNS_PORT))
+ payload, _ = sock.recvfrom(4096)
+ duration_ms = int((time.monotonic() - start) * 1000)
+ status, answers = parse_dns_answers(payload, qtype)
+ return {"status": status, "answers": answers, "latency_ms": duration_ms}
+
+
+def run_dns_check(spec: dict[str, str]) -> dict[str, object]:
+ try:
+ result = dns_query(spec["name"], spec["record_type"])
+ outcome = "ok" if result["status"] == "NOERROR" and result["answers"] else "failed"
+ if spec["severity"] == "info" and result["status"] in {"NOERROR", "NXDOMAIN"}:
+ outcome = "ok"
+ detail = result["status"]
+ if result["answers"]:
+ detail = f"{result['status']} ({', '.join(result['answers'][:2])})"
+ return {
+ "kind": "dns",
+ "label": spec["label"],
+ "target": spec["name"],
+ "record_type": spec["record_type"],
+ "severity": spec["severity"],
+ "outcome": outcome,
+ "detail": detail,
+ "note": spec["note"],
+ "latency_ms": result["latency_ms"],
+ }
+ except Exception as exc:
+ return {
+ "kind": "dns",
+ "label": spec["label"],
+ "target": spec["name"],
+ "record_type": spec["record_type"],
+ "severity": spec["severity"],
+ "outcome": "failed",
+ "detail": str(exc),
+ "note": spec["note"],
+ "latency_ms": -1,
+ }
+
+
+def recv_exact(sock: socket.socket, size: int) -> bytes:
+ chunks = []
+ remaining = size
+ while remaining > 0:
+ data = sock.recv(remaining)
+ if not data:
+ raise RuntimeError("unexpected EOF")
+ chunks.append(data)
+ remaining -= len(data)
+ return b"".join(chunks)
+
+
+def socks_reply_message(code: int) -> str:
+ return {
+ 0x00: "succeeded",
+ 0x01: "general failure",
+ 0x02: "connection not allowed",
+ 0x03: "network unreachable",
+ 0x04: "host unreachable",
+ 0x05: "connection refused",
+ 0x06: "TTL expired",
+ 0x07: "command not supported",
+ 0x08: "address type not supported",
+ }.get(code, f"reply {code}")
+
+
+def run_proxy_check(users: list[dict[str, str]]) -> dict[str, object]:
+ if not users:
+ return {
+ "kind": "proxy",
+ "label": "SOCKS ingress",
+ "target": f"{PROXY_HOST}:{PROXY_PORT}",
+ "severity": "warning",
+ "outcome": "skipped",
+ "detail": "No proxy users found in secret",
+ "note": "Shows auth + CONNECT readiness",
+ "latency_ms": -1,
+ }
+ user = users[0]
+ host = "example.com".encode("idna")
+ start = time.monotonic()
+ try:
+ with socket.create_connection((PROXY_HOST, PROXY_PORT), timeout=SOCKET_TIMEOUT) as sock:
+ sock.settimeout(SOCKET_TIMEOUT)
+ sock.sendall(b"\x05\x01\x02")
+ greeting = recv_exact(sock, 2)
+ if greeting != b"\x05\x02":
+ raise RuntimeError(f"unexpected auth method {greeting!r}")
+
+ username = user["username"].encode("utf-8")
+ password = user["password"].encode("utf-8")
+ sock.sendall(bytes([0x01, len(username)]) + username + bytes([len(password)]) + password)
+ auth_reply = recv_exact(sock, 2)
+ if auth_reply[1] != 0x00:
+ raise RuntimeError("authentication failed")
+
+ request = b"\x05\x01\x00\x03" + bytes([len(host)]) + host + struct.pack(">H", 80)
+ sock.sendall(request)
+ header = recv_exact(sock, 4)
+ reply_code = header[1]
+ addr_type = header[3]
+ if addr_type == 0x01:
+ recv_exact(sock, 4)
+ elif addr_type == 0x03:
+ domain_len = recv_exact(sock, 1)[0]
+ recv_exact(sock, domain_len)
+ elif addr_type == 0x04:
+ recv_exact(sock, 16)
+ recv_exact(sock, 2)
+ if reply_code != 0x00:
+ raise RuntimeError(socks_reply_message(reply_code))
+ except Exception as exc:
+ return {
+ "kind": "proxy",
+ "label": "SOCKS ingress",
+ "target": f"{PROXY_HOST}:{PROXY_PORT}",
+ "severity": "critical",
+ "outcome": "failed",
+ "detail": str(exc),
+ "note": "Shows auth + CONNECT readiness",
+ "latency_ms": -1,
+ }
+
+ return {
+ "kind": "proxy",
+ "label": "SOCKS ingress",
+ "target": f"{PROXY_HOST}:{PROXY_PORT}",
+ "severity": "critical",
+ "outcome": "ok",
+ "detail": f"Authenticated as {user['username']} and opened CONNECT tunnel",
+ "note": "Shows auth + CONNECT readiness",
+ "latency_ms": int((time.monotonic() - start) * 1000),
+ }
+
+
+def service_severity(state: str, health: str, restart_count: int, error_excerpt: list[str]) -> str:
+ if state in {"exited", "dead"} or health == "unhealthy":
+ return "critical"
+ if state != "running" or health == "starting" or restart_count > 0 or error_excerpt:
+ return "degraded"
+ return "healthy"
+
+
+def list_project_containers() -> list[dict[str, object]]:
+ filters = urllib.parse.quote(json.dumps({"label": [f"com.docker.compose.project={DOCKER_PROJECT}"]}, separators=(",", ":")))
+ raw_containers = docker_json(f"/containers/json?all=1&filters={filters}")
+ services = []
+ for item in raw_containers:
+ name = item.get("Names", [item["Id"]])[0].lstrip("/")
+ inspect = docker_json(f"/containers/{item['Id']}/json")
+ state = inspect["State"]["Status"]
+ health = inspect["State"].get("Health", {}).get("Status", "none")
+ restart_count = inspect.get("RestartCount", 0)
+ logs = service_log_text(item["Id"], LOG_TAIL_LINES)
+ error_excerpt = extract_error_excerpt(name, logs)
+ services.append(
+ {
+ "id": item["Id"],
+ "service": item.get("Labels", {}).get("com.docker.compose.service", name),
+ "container_name": name,
+ "state": state,
+ "status_text": item.get("Status", state),
+ "health": health,
+ "restart_count": restart_count,
+ "image": item.get("Image", ""),
+ "ports": item.get("Ports", []),
+ "severity": service_severity(state, health, restart_count, error_excerpt),
+ "error_excerpt": error_excerpt,
+ }
+ )
+ return sorted(services, key=lambda svc: svc["service"])
+
+
+def summarize_status(services: list[dict[str, object]], checks: list[dict[str, object]]) -> dict[str, object]:
+ critical_services = sum(1 for svc in services if svc["severity"] == "critical")
+ degraded_services = sum(1 for svc in services if svc["severity"] == "degraded")
+ failed_critical_checks = sum(1 for check in checks if check["severity"] == "critical" and check["outcome"] != "ok")
+ failed_warning_checks = sum(1 for check in checks if check["severity"] == "warning" and check["outcome"] != "ok")
+
+ if critical_services or failed_critical_checks:
+ overall = "critical"
+ elif degraded_services or failed_warning_checks:
+ overall = "degraded"
+ else:
+ overall = "healthy"
+
+ return {
+ "overall": overall,
+ "service_counts": {
+ "total": len(services),
+ "healthy": sum(1 for svc in services if svc["severity"] == "healthy"),
+ "degraded": degraded_services,
+ "critical": critical_services,
+ },
+ "check_counts": {
+ "total": len(checks),
+ "ok": sum(1 for check in checks if check["outcome"] == "ok"),
+ "failed": sum(1 for check in checks if check["outcome"] == "failed"),
+ "skipped": sum(1 for check in checks if check["outcome"] == "skipped"),
+ },
+ }
+
+
+def build_status_payload() -> dict[str, object]:
+ services = list_project_containers()
+ users = read_proxy_users()
+ checks = [run_dns_check(spec) for spec in DNS_CHECKS]
+ checks.append(run_proxy_check(users))
+ summary = summarize_status(services, checks)
+ return {
+ "generated_at": now_iso(),
+ "project": DOCKER_PROJECT,
+ "summary": summary,
+ "services": services,
+ "checks": checks,
+ "users": [{"username": entry["username"]} for entry in users],
+ }
+
+
+def get_status_payload(force_refresh: bool = False) -> dict[str, object]:
+ now = time.monotonic()
+ with CACHE_LOCK:
+ if not force_refresh and STATUS_CACHE["payload"] is not None and now < STATUS_CACHE["expires_at"]:
+ return STATUS_CACHE["payload"]
+ payload = build_status_payload()
+ STATUS_CACHE["payload"] = payload
+ STATUS_CACHE["expires_at"] = now + STATUS_CACHE_SECONDS
+ return payload
+
+
+def find_service(identifier: str) -> dict[str, object] | None:
+ payload = get_status_payload()
+ for service in payload["services"]:
+ if service["service"] == identifier or service["container_name"] == identifier:
+ return service
+ return None
+
+
+HTML = """
+
+
+
+
+ darkproxy status
+
+
+
+
+
+
+
darkproxy status
+
Loading…
+
+
+
JSON
+
+
+
+
+
+
+
Services
+
+
+
+ | Service |
+ State |
+ Health |
+ Restarts |
+ Errors |
+ |
+
+
+
+
+
+
+
+
Users
+
Configured proxy usernames only. Passwords are never shown.
+
+
+
+
+
+
Checks
+
+
+
+ | Check |
+ Target |
+ Severity |
+ Result |
+ Latency |
+ Detail |
+
+
+
+
+
+
+
+
+
Logs
+
+
+
+
Select a service to view recent logs.
+
+
+
+
+
+
+"""
+
+
+class Handler(BaseHTTPRequestHandler):
+ def send_json(self, payload: dict[str, object], status: int = 200):
+ body = json.dumps(payload, indent=2).encode("utf-8")
+ self.send_response(status)
+ self.send_header("Content-Type", "application/json; charset=utf-8")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ try:
+ self.wfile.write(body)
+ except BrokenPipeError:
+ return
+
+ def send_html(self, body: str, status: int = 200):
+ payload = body.encode("utf-8")
+ self.send_response(status)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.send_header("Content-Length", str(len(payload)))
+ self.end_headers()
+ try:
+ self.wfile.write(payload)
+ except BrokenPipeError:
+ return
+
+ def do_GET(self):
+ parsed = urllib.parse.urlparse(self.path)
+ params = urllib.parse.parse_qs(parsed.query)
+ try:
+ if parsed.path == "/":
+ self.send_html(HTML)
+ return
+ if parsed.path == "/api/status":
+ force_refresh = params.get("refresh", ["0"])[0] == "1"
+ self.send_json(get_status_payload(force_refresh=force_refresh))
+ return
+ if parsed.path == "/healthz":
+ self.send_json({"ok": True, "generated_at": now_iso()})
+ return
+ if parsed.path == "/api/users":
+ users = [{"username": entry["username"]} for entry in read_proxy_users()]
+ self.send_json({"users": users})
+ return
+ if parsed.path == "/api/logs":
+ service_name = params.get("service", [""])[0]
+ if not service_name:
+ self.send_json({"error": "missing service parameter"}, status=400)
+ return
+ service = find_service(service_name)
+ if service is None:
+ self.send_json({"error": f"unknown service {service_name}"}, status=404)
+ return
+ tail = int(params.get("tail", ["200"])[0])
+ self.send_json(
+ {
+ "service": service["service"],
+ "container_name": service["container_name"],
+ "logs": service_log_text(service["id"], tail),
+ }
+ )
+ return
+ self.send_json({"error": "not found"}, status=404)
+ except BrokenPipeError:
+ return
+ except Exception as exc:
+ self.send_json({"error": str(exc)}, status=500)
+
+ def log_message(self, format, *args):
+ return
+
+
+if __name__ == "__main__":
+ server = ThreadingHTTPServer(("0.0.0.0", DASHBOARD_PORT), Handler)
+ server.serve_forever()
diff --git a/scripts/validate-config.sh b/scripts/validate-config.sh
index 40688ed..3699ba1 100644
--- a/scripts/validate-config.sh
+++ b/scripts/validate-config.sh
@@ -40,7 +40,7 @@ if command -v docker >/dev/null 2>&1; then
exit 1
fi
set +e
- timeout 8s docker run --rm "$COREDNS_IMAGE" -conf /Corefile -dns.port=0
+ timeout -k 2s 8s docker run --rm "$COREDNS_IMAGE" -conf /Corefile -dns.port=0
COREDNS_STATUS=$?
set -e
if [ "$COREDNS_STATUS" -ne 0 ] && [ "$COREDNS_STATUS" -ne 124 ]; then
@@ -50,7 +50,7 @@ if command -v docker >/dev/null 2>&1; then
elif command -v coredns >/dev/null 2>&1 && coredns -plugins 2>/dev/null | grep -q '^meshname$'; then
echo "[validate-config] checking PopuraDNS/Corefile with local compiled coredns"
set +e
- timeout 8s coredns -conf PopuraDNS/Corefile -dns.port=0
+ timeout -k 2s 8s coredns -conf PopuraDNS/Corefile -dns.port=0
COREDNS_STATUS=$?
set -e
if [ "$COREDNS_STATUS" -ne 0 ] && [ "$COREDNS_STATUS" -ne 124 ]; then
@@ -61,6 +61,22 @@ else
echo "[validate-config] cannot check Corefile syntax (need docker or a compiled local coredns with meshname plugin)"
fi
+# enforce portable defaults for host/LAN-specific settings in the base stack
+if ! grep -q 'ROUTER_NO_AUTH_CIDRS: ${ROUTER_NO_AUTH_CIDRS:-}' docker-compose.yml; then
+ echo "[validate-config] error: ROUTER_NO_AUTH_CIDRS should default to empty and be overridden explicitly per deployment" >&2
+ exit 1
+fi
+
+if ! grep -q '\${PIHOLE_WEB_BIND_HOST:-127.0.0.1}:\${PIHOLE_WEB_BIND_PORT:-2003}:80/tcp' docker-compose.yml; then
+ echo "[validate-config] error: Pi-hole web binding should default to 127.0.0.1 and be configurable via env vars" >&2
+ exit 1
+fi
+
+if ! grep -q '\${STATUS_DASHBOARD_BIND_HOST:-127.0.0.1}:\${STATUS_DASHBOARD_BIND_PORT:-2004}:8080' docker-compose.yml; then
+ echo "[validate-config] error: status dashboard binding should default to 127.0.0.1 and be configurable via env vars" >&2
+ exit 1
+fi
+
# ensure onion and i2p zones are present in Corefile
for zone in "onion.:53" "i2p.:53" "eth.:53" "bit.:53" "alt.:53" "loki.:53" "zil.:53" "web3.:53" "exit.:53" "onion4.:53" "onion6.:53"; do
if ! grep -q "$zone" PopuraDNS/Corefile; then
@@ -100,6 +116,16 @@ if command -v dig >/dev/null 2>&1; then
echo "[validate-config] i2p query failed" >&2
exit 1
fi
+ echo "[validate-config] performing live DNS query for .eth via CoreDNS"
+ if ! dig @10.5.0.4 -p 53 TXT vitalik.eth +short | grep -q 'address='; then
+ echo "[validate-config] eth query failed" >&2
+ exit 1
+ fi
+ echo "[validate-config] performing live DNS query for .zil via CoreDNS"
+ if ! dig @10.5.0.4 -p 53 TXT brad.zil +short | grep -q 'zil_address='; then
+ echo "[validate-config] zil query failed" >&2
+ exit 1
+ fi
fi
# ensure referenced secrets exist
@@ -117,8 +143,8 @@ done
# check for mutable :latest tags in deployment compose files
if grep -qE 'image: .*:latest' docker-compose.yml; then
- echo "[validate-config] warning: some services use the ':latest' image tag;" \
- "pin to a specific version before deploying to production."
+ echo "[validate-config] error: mutable ':latest' image tags are not allowed in the release compose file" >&2
+ exit 1
fi
# warn if there are no healthchecks at all
@@ -139,15 +165,19 @@ fi
# unbound configuration syntax check
if command -v docker >/dev/null 2>&1; then
- echo "[validate-config] checking unbound configuration in compiled repo image"
- UNBOUND_IMAGE="$(docker build -q -f unbound_arm/Dockerfile unbound_arm | tail -n1)"
+ echo "[validate-config] checking unbound configuration in runtime image"
+ UNBOUND_IMAGE="$(awk '/image: mvance\/unbound@sha256:/{print $2; exit}' "$TEMP_FILE")"
+ if [ -z "$UNBOUND_IMAGE" ]; then
+ echo "[validate-config] error: failed to resolve unbound runtime image from rendered compose config" >&2
+ exit 1
+ fi
docker run --rm --entrypoint /bin/sh \
-v "${PWD}/unbound/unbound.conf:/opt/unbound/etc/unbound/unbound.conf:ro" \
-v "${PWD}/unbound/forward-records.conf:/opt/unbound/etc/unbound/forward-records.conf:ro" \
-v "${PWD}/unbound/a-records.conf:/opt/unbound/etc/unbound/a-records.conf:ro" \
-v "${PWD}/unbound/srv-records.conf:/opt/unbound/etc/unbound/srv-records.conf:ro" \
"$UNBOUND_IMAGE" -lc \
- 'getent passwd _unbound >/dev/null || useradd -r -s /usr/sbin/nologin _unbound; unbound-checkconf /opt/unbound/etc/unbound/unbound.conf'
+ 'getent passwd _unbound >/dev/null || useradd -r -s /usr/sbin/nologin _unbound; mkdir -p /opt/unbound/etc/unbound/dev /opt/unbound/etc/unbound/var/log; : > /opt/unbound/etc/unbound/dev/null; [ -s /opt/unbound/etc/unbound/var/root.key ] || /opt/unbound/sbin/unbound-anchor -a /opt/unbound/etc/unbound/var/root.key >/dev/null 2>&1 || :; [ -e /opt/unbound/etc/unbound/var/root.key ] || : > /opt/unbound/etc/unbound/var/root.key; /opt/unbound/sbin/unbound-checkconf /opt/unbound/etc/unbound/unbound.conf'
elif command -v unbound-checkconf >/dev/null 2>&1; then
echo "[validate-config] checking unbound configuration locally"
unbound-checkconf unbound/unbound.conf
@@ -177,7 +207,13 @@ if grep -q 'CHANGE_ME_NAMECOIN_RPC_PASSWORD' docker-compose.yml; then
fi
if grep -q 'tskey-YOUR-AUTH-KEY-HERE' docker-compose.yml; then
- echo "[validate-config] warning: tailscale is still configured with the placeholder auth key"
+ echo "[validate-config] error: tailscale placeholder auth key is still present in docker-compose.yml" >&2
+ exit 1
+fi
+
+if ! grep -q 'profiles: \["tailscale"\]' docker-compose.yml; then
+ echo "[validate-config] error: tailscale should be opt-in via a compose profile" >&2
+ exit 1
fi
# simple sanity checks
@@ -187,9 +223,24 @@ if grep -q "container_name: darkproxy" docker-compose.yml; then
fi
# ensure Pi-hole DNS environment is correctly formatted
-if grep -q "PIHOLE_DNS_" docker-compose.yml | grep -q "PIHOLE_DNS_:"; then
+if grep -q "PIHOLE_DNS_:" docker-compose.yml; then
echo "[validate-config] warning: PIHOLE_DNS_ variable ends with underscore;" \
- "consider using PIHOLE_DNS_1, PIHOLE_DNS_2 etc."
+ "consider using PIHOLE_DNS_1, PIHOLE_DNS_2 etc."
+fi
+
+# ensure critical services have healthchecks in the rendered compose config
+if [ -f "$TEMP_FILE" ]; then
+ for service in dark3proxy i2pd_yggdrasil i2pdns unbound pihole ensdns zildns lokinet namecoind namecoindns status_dashboard; do
+ if ! awk -v svc="$service" '
+ $0 ~ "^ " svc ":" { in_svc=1; next }
+ in_svc && $0 ~ "^ [^ ]" { exit !found }
+ in_svc && $0 ~ "^ healthcheck:" { found=1 }
+ END { if (in_svc) exit !found; exit 0 }
+ ' "$TEMP_FILE"; then
+ echo "[validate-config] error: service $service is missing a healthcheck in rendered compose config" >&2
+ exit 1
+ fi
+ done
fi
echo "[validate-config] all validations passed"
diff --git a/tailscale/post-rules.sh b/tailscale/post-rules.sh
index 670d24a..4f2db29 100644
--- a/tailscale/post-rules.sh
+++ b/tailscale/post-rules.sh
@@ -8,14 +8,16 @@ TAILSCALED_PID=$!
# Wait for Tailscale to initialize
sleep 2
-# Authenticate with Tailscale via auth key
-if [ -n "$TS_AUTHKEY" ] && [ "$TS_AUTHKEY" != "tskey-YOUR-AUTH-KEY-HERE" ]; then
- # tailscale up flags change over time; avoid deprecated options.
- /usr/local/bin/tailscale up --authkey="$TS_AUTHKEY" ${TS_EXTRA_ARGS:-"--accept-dns=false --advertise-exit-node"} || true
-else
- echo "TS_AUTHKEY is not set to a real key; skipping tailscale up"
+if [ -z "${TS_AUTHKEY:-}" ]; then
+ echo "TS_AUTHKEY must be set when enabling the tailscale profile" >&2
+ kill "$TAILSCALED_PID"
+ wait "$TAILSCALED_PID" || true
+ exit 1
fi
+# tailscale up flags change over time; avoid deprecated options.
+/usr/local/bin/tailscale up --authkey="$TS_AUTHKEY" ${TS_EXTRA_ARGS:-"--accept-dns=false --advertise-exit-node"}
+
# Wait a bit for Tailscale to be ready
sleep 3
diff --git a/zildns/Dockerfile b/zildns/Dockerfile
index 432ba51..06d204a 100644
--- a/zildns/Dockerfile
+++ b/zildns/Dockerfile
@@ -6,8 +6,10 @@ COPY package.json /app/package.json
RUN npm install --omit=dev
COPY server.js /app/server.js
+COPY entrypoint.sh /app/entrypoint.sh
+RUN chmod +x /app/entrypoint.sh
EXPOSE 53/udp
EXPOSE 53/tcp
-CMD ["node", "/app/server.js"]
+CMD ["/app/entrypoint.sh"]
diff --git a/zildns/entrypoint.sh b/zildns/entrypoint.sh
new file mode 100644
index 0000000..17f47b4
--- /dev/null
+++ b/zildns/entrypoint.sh
@@ -0,0 +1,9 @@
+#!/bin/sh
+set -e
+
+# Fix Docker's broken internal DNS by using external DNS directly
+echo "nameserver 1.1.1.1" > /etc/resolv.conf
+echo "nameserver 8.8.8.8" >> /etc/resolv.conf
+
+# Start the Node.js server
+exec node /app/server.js
diff --git a/zildns/server.js b/zildns/server.js
index 7444185..d1cbaff 100644
--- a/zildns/server.js
+++ b/zildns/server.js
@@ -12,7 +12,7 @@ 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 { Packet, UDPServer, TCPServer } = dns2;
const RCODE = {
NOERROR: 0,
SERVFAIL: 2,
@@ -58,32 +58,53 @@ function cacheSet(key, value) {
});
}
+function withTimeout(promise, ms) {
+ return Promise.race([
+ promise,
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms)
+ )
+ ]);
+}
+
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),
- ]);
+ try {
+ const [zilResult, ipfsResult] = await withTimeout(
+ Promise.allSettled([
+ resolution.addr(domain, "ZIL"),
+ resolution.ipfsHash(domain),
+ ]),
+ 5000 // 5 second timeout
+ );
- const result = {
- zilAddress: zilResult.status === "fulfilled" && zilResult.value ? zilResult.value : null,
- ipfsHash: ipfsResult.status === "fulfilled" && ipfsResult.value ? ipfsResult.value : null,
- };
+ 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;
+ const hasData = Boolean(result.zilAddress || result.ipfsHash);
+ const resolved = hasData ? result : null;
+ cacheSet(domain, resolved);
+ return resolved;
+ } catch (err) {
+ console.error(`[zildns] API failed for ${domain}:`, err.message);
+ // Cache negative result for a short time to avoid repeated API failures
+ cacheSet(domain, null);
+ return null;
+ }
}
function makeResponse(request) {
const response = Packet.createResponseFromRequest(request);
response.header.aa = 1;
response.header.ra = 1;
+ response.header.z = 0;
+ response.header.tc = 0;
return response;
}
@@ -123,13 +144,12 @@ function addCnameAnswer(response, name, resolved) {
});
}
-async function handleRequest(request, send) {
+async function buildResponse(request) {
const response = makeResponse(request);
const question = request.questions && request.questions[0];
if (!question) {
- send(response);
- return;
+ return response;
}
const qname = normalizeName(question.name);
@@ -138,15 +158,13 @@ async function handleRequest(request, send) {
if (!qname.endsWith(".zil")) {
response.header.rcode = RCODE.NXDOMAIN;
- send(response);
- return;
+ return response;
}
const resolved = await resolveZilDomain(qname);
if (!resolved) {
response.header.rcode = RCODE.NXDOMAIN;
- send(response);
- return;
+ return response;
}
if (isType("TXT") || isType("ANY")) {
@@ -157,35 +175,9 @@ async function handleRequest(request, send) {
addCnameAnswer(response, question.name, resolved);
}
- send(response);
+ return 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");
@@ -206,17 +198,48 @@ async function maybePrewarm() {
async function start() {
await maybePrewarm();
-
- server.listen({
- udp: {
- address: LISTEN_HOST,
- port: LISTEN_PORT,
- },
- tcp: {
- address: LISTEN_HOST,
- port: LISTEN_PORT,
- },
+
+ const udpServer = new UDPServer((request, send) => {
+ buildResponse(request)
+ .then((response) => {
+ send(response);
+ })
+ .catch((err) => {
+ console.error("[zildns] request failed:", err.message);
+ const response = makeResponse(request);
+ response.header.rcode = RCODE.SERVFAIL;
+ send(response);
+ });
});
+
+ const tcpServer = new TCPServer((request, send) => {
+ buildResponse(request)
+ .then((response) => {
+ send(response);
+ })
+ .catch((err) => {
+ console.error("[zildns] request failed:", err.message);
+ const response = makeResponse(request);
+ response.header.rcode = RCODE.SERVFAIL;
+ send(response);
+ });
+ });
+
+ udpServer.on("error", (err) => {
+ console.error("[zildns] udp error:", err);
+ });
+
+ tcpServer.on("error", (err) => {
+ console.error("[zildns] tcp error:", err);
+ });
+
+ udpServer.listen(LISTEN_PORT, LISTEN_HOST);
+ tcpServer.listen(LISTEN_PORT, LISTEN_HOST);
+
+ console.log(`[zildns] listening on ${LISTEN_HOST}:${LISTEN_PORT} (udp/tcp)`);
+ console.log(`[zildns] zns provider ${ZNS_URL} (${ZNS_NETWORK})`);
+
+ return { udpServer, tcpServer };
}
start().catch((err) => {