Harden release defaults and validation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
+143
-50
@@ -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
|
||||
|
||||
+104
-17
@@ -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:
|
||||
|
||||
+61
-8
@@ -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))
|
||||
|
||||
@@ -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"]
|
||||
@@ -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 = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>darkproxy status</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0f172a;
|
||||
--panel: #111827;
|
||||
--panel-2: #1f2937;
|
||||
--text: #e5e7eb;
|
||||
--muted: #94a3b8;
|
||||
--healthy: #22c55e;
|
||||
--degraded: #f59e0b;
|
||||
--critical: #ef4444;
|
||||
--border: #334155;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
.wrap {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
h1, h2 {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.topbar, .cards, .panel-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
.topbar {
|
||||
grid-template-columns: 1fr auto auto;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.cards {
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.card, .panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
.panel-grid {
|
||||
grid-template-columns: 2fr 1fr;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
th, td {
|
||||
padding: 10px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.healthy { color: var(--healthy); }
|
||||
.degraded { color: var(--degraded); }
|
||||
.critical { color: var(--critical); }
|
||||
.badge.healthy { background: rgba(34, 197, 94, 0.15); }
|
||||
.badge.degraded { background: rgba(245, 158, 11, 0.15); }
|
||||
.badge.critical { background: rgba(239, 68, 68, 0.15); }
|
||||
.badge.info { background: rgba(148, 163, 184, 0.15); color: var(--muted); }
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
button, select {
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
pre {
|
||||
background: #020617;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
min-height: 220px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
.error-list {
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
}
|
||||
@media (max-width: 960px) {
|
||||
.panel-grid, .topbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="topbar">
|
||||
<div>
|
||||
<h1>darkproxy status</h1>
|
||||
<div class="muted" id="updated-at">Loading…</div>
|
||||
</div>
|
||||
<button id="refresh-btn">Refresh now</button>
|
||||
<a class="muted" href="/api/status" target="_blank" rel="noreferrer">JSON</a>
|
||||
</div>
|
||||
|
||||
<div class="cards" id="summary-cards"></div>
|
||||
|
||||
<div class="panel-grid">
|
||||
<div class="panel">
|
||||
<h2>Services</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Service</th>
|
||||
<th>State</th>
|
||||
<th>Health</th>
|
||||
<th>Restarts</th>
|
||||
<th>Errors</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="services-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Users</h2>
|
||||
<div class="muted">Configured proxy usernames only. Passwords are never shown.</div>
|
||||
<ul id="users-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel" style="margin-bottom: 16px;">
|
||||
<h2>Checks</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Check</th>
|
||||
<th>Target</th>
|
||||
<th>Severity</th>
|
||||
<th>Result</th>
|
||||
<th>Latency</th>
|
||||
<th>Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="checks-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div style="display:flex; gap:12px; align-items:center; margin-bottom:12px; flex-wrap:wrap;">
|
||||
<h2 style="margin:0;">Logs</h2>
|
||||
<select id="logs-service"></select>
|
||||
<button id="load-logs-btn">Load logs</button>
|
||||
</div>
|
||||
<pre id="logs-output">Select a service to view recent logs.</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let lastStatus = null;
|
||||
|
||||
function badgeClass(value) {
|
||||
return ["healthy", "degraded", "critical"].includes(value) ? value : "info";
|
||||
}
|
||||
|
||||
function esc(value) {
|
||||
return String(value ?? "").replace(/[&<>"]/g, (ch) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """
|
||||
}[ch]));
|
||||
}
|
||||
|
||||
function renderSummary(summary) {
|
||||
const cards = [
|
||||
["Overall", summary.overall],
|
||||
["Services", `${summary.service_counts.healthy}/${summary.service_counts.total} healthy`],
|
||||
["Degraded", `${summary.service_counts.degraded}`],
|
||||
["Critical", `${summary.service_counts.critical}`],
|
||||
["Checks OK", `${summary.check_counts.ok}/${summary.check_counts.total}`]
|
||||
];
|
||||
document.getElementById("summary-cards").innerHTML = cards.map(([label, value]) => `
|
||||
<div class="card">
|
||||
<div class="muted">${esc(label)}</div>
|
||||
<div class="badge ${badgeClass(String(value).toLowerCase())}">${esc(value)}</div>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderServices(services) {
|
||||
document.getElementById("services-body").innerHTML = services.map((service) => `
|
||||
<tr>
|
||||
<td>
|
||||
<strong>${esc(service.service)}</strong><br>
|
||||
<span class="muted">${esc(service.container_name)}</span>
|
||||
</td>
|
||||
<td><span class="badge ${badgeClass(service.severity)}">${esc(service.state)}</span><br><span class="muted">${esc(service.status_text)}</span></td>
|
||||
<td>${esc(service.health)}</td>
|
||||
<td>${esc(service.restart_count)}</td>
|
||||
<td>
|
||||
${service.error_excerpt.length ? `<ul class="error-list">${service.error_excerpt.map((line) => `<li>${esc(line)}</li>`).join("")}</ul>` : '<span class="muted">none</span>'}
|
||||
</td>
|
||||
<td><button data-service="${esc(service.service)}">Logs</button></td>
|
||||
</tr>
|
||||
`).join("");
|
||||
|
||||
const select = document.getElementById("logs-service");
|
||||
const current = select.value;
|
||||
select.innerHTML = services.map((service) => `<option value="${esc(service.service)}">${esc(service.service)}</option>`).join("");
|
||||
if (services.some((service) => service.service === current)) {
|
||||
select.value = current;
|
||||
}
|
||||
|
||||
document.querySelectorAll("button[data-service]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
select.value = button.dataset.service;
|
||||
loadLogs();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderChecks(checks) {
|
||||
document.getElementById("checks-body").innerHTML = checks.map((check) => `
|
||||
<tr>
|
||||
<td><strong>${esc(check.label)}</strong><br><span class="muted">${esc(check.note)}</span></td>
|
||||
<td>${esc(check.target)} ${check.record_type ? `<span class="muted">${esc(check.record_type)}</span>` : ""}</td>
|
||||
<td><span class="badge ${badgeClass(check.severity === "warning" ? "degraded" : check.severity)}">${esc(check.severity)}</span></td>
|
||||
<td><span class="badge ${badgeClass(check.outcome === "ok" ? "healthy" : (check.outcome === "skipped" ? "info" : "critical"))}">${esc(check.outcome)}</span></td>
|
||||
<td>${check.latency_ms >= 0 ? `${esc(check.latency_ms)} ms` : '<span class="muted">n/a</span>'}</td>
|
||||
<td>${esc(check.detail)}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderUsers(users) {
|
||||
const list = document.getElementById("users-list");
|
||||
if (!users.length) {
|
||||
list.innerHTML = "<li class='muted'>No users found</li>";
|
||||
return;
|
||||
}
|
||||
list.innerHTML = users.map((user) => `<li>${esc(user.username)}</li>`).join("");
|
||||
}
|
||||
|
||||
async function loadStatus(force = false) {
|
||||
const suffix = force ? "?refresh=1" : "";
|
||||
const response = await fetch(`/api/status${suffix}`);
|
||||
const payload = await response.json();
|
||||
lastStatus = payload;
|
||||
document.getElementById("updated-at").textContent = `Updated ${payload.generated_at}`;
|
||||
renderSummary(payload.summary);
|
||||
renderServices(payload.services);
|
||||
renderChecks(payload.checks);
|
||||
renderUsers(payload.users);
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
const service = document.getElementById("logs-service").value;
|
||||
if (!service) {
|
||||
return;
|
||||
}
|
||||
document.getElementById("logs-output").textContent = "Loading logs…";
|
||||
const response = await fetch(`/api/logs?service=${encodeURIComponent(service)}`);
|
||||
const payload = await response.json();
|
||||
document.getElementById("logs-output").textContent = payload.logs || "(no log output)";
|
||||
}
|
||||
|
||||
document.getElementById("refresh-btn").addEventListener("click", () => loadStatus(true));
|
||||
document.getElementById("load-logs-btn").addEventListener("click", loadLogs);
|
||||
|
||||
loadStatus().catch((error) => {
|
||||
document.getElementById("updated-at").textContent = `Failed to load status: ${error}`;
|
||||
});
|
||||
setInterval(() => loadStatus().catch(() => {}), 15000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
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()
|
||||
+61
-10
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+3
-1
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
+80
-57
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user