Compare commits

24 Commits

Author SHA1 Message Date
auto-ci 21718042b6 Harden release defaults and validation
Configuration validation / lint (push) Has been cancelled
Configuration validation / smoke (push) Has been cancelled
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-03 19:29:00 -04:00
auto-ci b6d49549fe Pin deployment images and refresh docs
Configuration validation / lint (push) Has been cancelled
Configuration validation / smoke (push) Has been cancelled
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-02 15:23:53 -04:00
auto-ci ec4aff6cfe dns: add i2pdns bridge for .i2p resolution
Configuration validation / lint (push) Has been cancelled
Configuration validation / smoke (push) Has been cancelled
2026-03-09 18:02:37 -04:00
auto-ci 4bbc8284d0 compose: add dark3proxy healthcheck for router stack
Configuration validation / lint (push) Has been cancelled
Configuration validation / smoke (push) Has been cancelled
2026-03-09 17:25:57 -04:00
auto-ci 77a376c3e0 dark3proxy: add hostname-preserving socks router on :2000 2026-03-09 17:22:41 -04:00
auto-ci 58dcb6d9a6 dark3proxy: migrate to plain 3proxy runtime 2026-03-09 13:55:48 -04:00
auto-ci 2bccb692a2 compose: separate amd64 base from arm override
Configuration validation / lint (push) Has been cancelled
Configuration validation / smoke (push) Has been cancelled
2026-03-08 21:34:43 -04:00
auto-ci 6d0cad85f5 Expose only proxy port 2000 and update docs
Configuration validation / lint (push) Has been cancelled
Configuration validation / smoke (push) Has been cancelled
2026-03-08 15:08:03 -04:00
auto-ci 1ec1aa6307 Clarify README SOCKS auth and secret usage 2026-03-08 14:08:46 -04:00
auto-ci 74fe9ed02c Fix primary SOCKS onion routing and document port usage
Configuration validation / lint (push) Has been cancelled
Configuration validation / smoke (push) Has been cancelled
2026-03-08 13:53:02 -04:00
auto-ci 22e16773ef docs: clarify local DNS resolution model 2026-03-07 21:27:38 -05:00
auto-ci 2ced93a971 Clarify .loki and .zil resolver dependency notes 2026-03-07 13:06:19 -05:00
auto-ci ec3c2d92cf Add local zildns service and ARM smoke validation
Configuration validation / lint (push) Has been cancelled
Configuration validation / smoke (push) Has been cancelled
2026-03-06 22:39:30 -05:00
auto-ci 8174346537 Enable native ARM runtime for 3proxy, coredns, and unbound
Configuration validation / lint (push) Has been cancelled
Configuration validation / smoke (push) Has been cancelled
2026-03-06 21:02:25 -05:00
auto-ci f95545040a Reduce benign warning noise in smoke checks
Configuration validation / lint (push) Has been cancelled
Configuration validation / smoke (push) Has been cancelled
2026-03-06 04:33:22 -05:00
auto-ci 0728862fbe Stabilize ARM i2pd startup and add smoke test runner
Configuration validation / lint (push) Has been cancelled
Configuration validation / smoke (push) Has been cancelled
2026-03-05 22:11:28 -05:00
auto-ci e4fd2074e2 Add monitoring stack files and finalize ARM build cleanups
Configuration validation / lint (push) Has been cancelled
Configuration validation / smoke (push) Has been cancelled
2026-03-05 20:57:01 -05:00
auto-ci 2e56306a4e Stabilize ARM restart path for DNS and proxy services 2026-03-05 20:52:06 -05:00
auto-ci 4cae0783a6 feat(arm): build alfis from source for arm64 override 2026-03-04 17:50:53 -05:00
auto-ci 9f86f78db5 perf(stack): tune resources and fix unbound bind path
Configuration validation / lint (push) Has been cancelled
Configuration validation / smoke (push) Has been cancelled
2026-03-03 22:24:06 -05:00
auto-ci a13d26f0eb feat(dns): enable local-only fallback resolver paths 2026-03-03 21:44:54 -05:00
auto-ci 771293f309 chore(git): ignore local runtime state dirs 2026-03-03 20:32:36 -05:00
auto-ci c485e7c8b6 add local lokinet resolver service and .loki dns routing
Configuration validation / lint (push) Has been cancelled
Configuration validation / smoke (push) Has been cancelled
2026-03-03 20:09:59 -05:00
auto-ci 7a2a7b2f48 harden pihole arp parsing and improve namecoin bootstrap
Configuration validation / lint (push) Has been cancelled
Configuration validation / smoke (push) Has been cancelled
2026-03-02 12:52:21 -05:00
41 changed files with 4867 additions and 527 deletions
+4
View File
@@ -5,3 +5,7 @@ tor/data/
secrets/ secrets/
generated.yml generated.yml
# local runtime state
pihole/etc-pihole/
# tailscale source files are versioned; runtime state is stored in Docker volume
+65 -51
View File
@@ -1,74 +1,88 @@
# Stage 1: Build # Stage 1: Build
FROM --platform=$BUILDPLATFORM ubuntu:22.04 AS builder FROM --platform=$TARGETPLATFORM ubuntu:22.04 AS builder
ARG BUILDPLATFORM
ARG TARGETPLATFORM
ARG TARGETARCH ARG TARGETARCH
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive
# Install build dependencies RUN apt-get update && apt-get install -y --no-install-recommends \
RUN apt-get update && apt-get install -y \ wget \
qtbase5-dev \ ca-certificates \
qtchooser \ && rm -rf /var/lib/apt/lists/*
qt5-qmake \
qtbase5-dev-tools \
wget \
git \
build-essential \
libqt5core5a \
&& rm -rf /var/lib/apt/lists/*
# Download appropriate 3proxy binary based on architecture # Download and extract appropriate 3proxy package based on architecture.
RUN if [ "$TARGETARCH" = "amd64" ]; then \ RUN set -eux; \
wget -O 3proxy.deb https://github.com/3proxy/3proxy/releases/download/0.9.4/3proxy-0.9.4.x86_64.deb; \ if [ "$TARGETARCH" = "amd64" ]; then \
elif [ "$TARGETARCH" = "arm64" ]; then \ pkg_url="https://github.com/3proxy/3proxy/releases/download/0.9.4/3proxy-0.9.4.x86_64.deb"; \
wget -O 3proxy.deb https://github.com/3proxy/3proxy/releases/download/0.9.4/3proxy-0.9.4.aarch64.deb || \ elif [ "$TARGETARCH" = "arm64" ]; then \
{ echo "ARM64 binary not available, using x86_64"; wget -O 3proxy.deb https://github.com/3proxy/3proxy/releases/download/0.9.4/3proxy-0.9.4.x86_64.deb; }; \ pkg_url="https://github.com/3proxy/3proxy/releases/download/0.9.4/3proxy-0.9.4.aarch64.deb"; \
elif [ "$TARGETARCH" = "arm" ]; then \ elif [ "$TARGETARCH" = "arm" ]; then \
wget -O 3proxy.deb https://github.com/3proxy/3proxy/releases/download/0.9.4/3proxy-0.9.4.armv7l.deb || \ pkg_url="https://github.com/3proxy/3proxy/releases/download/0.9.4/3proxy-0.9.4.armv7l.deb"; \
{ echo "ARMv7 binary not available"; exit 1; }; \ else \
fi && \ echo "Unsupported architecture: $TARGETARCH"; exit 1; \
dpkg -i 3proxy.deb || true fi; \
wget -O /tmp/3proxy.deb "$pkg_url"; \
dpkg-deb -x /tmp/3proxy.deb /tmp/3proxy-root; \
test -x /tmp/3proxy-root/bin/3proxy; \
install -m 0755 /tmp/3proxy-root/bin/3proxy /usr/local/bin/3proxy; \
mkdir -p /usr/local/3proxy; \
cp -a /tmp/3proxy-root/usr/local/3proxy/. /usr/local/3proxy/ || true
# Build 3proxy-eagle in a dedicated source directory # Stage 1b: Build Yggdrasil tooling for target arch
RUN git clone https://gitea.darkness.services/acetone/3proxy-eagle.git /src/3proxy-eagle \ FROM --platform=$BUILDPLATFORM golang:1.22-bookworm AS builder_yggdrasil
&& cd /src/3proxy-eagle \
&& find . -name "*.cpp" -o -name "*.h" | xargs sed -i 's/127\.0\.0\.1/0.0.0.0/g' \ ARG TARGETARCH
&& cd /src/3proxy-eagle/src \ ARG YGGDRASIL_VERSION=v0.5.12
&& qmake 3proxy-eagle.pro \
&& make -j$(nproc) RUN git clone https://github.com/yggdrasil-network/yggdrasil-go.git /src/yggdrasil-go \
&& cd /src/yggdrasil-go \
&& git checkout ${YGGDRASIL_VERSION}
RUN set -eux; \
case "${TARGETARCH}" in \
amd64) goarch=amd64 ;; \
arm64) goarch=arm64 ;; \
arm) goarch=arm ;; \
*) echo "Unsupported architecture: ${TARGETARCH}"; exit 1 ;; \
esac; \
cd /src/yggdrasil-go; \
mkdir -p /out; \
CGO_ENABLED=0 GOOS=linux GOARCH=${goarch} go build -o /out/yggdrasil ./cmd/yggdrasil; \
CGO_ENABLED=0 GOOS=linux GOARCH=${goarch} go build -o /out/yggdrasilctl ./cmd/yggdrasilctl; \
CGO_ENABLED=0 GOOS=linux GOARCH=${goarch} go build -o /out/genkeys ./cmd/genkeys
# Stage 2: Runtime # Stage 2: Runtime
FROM ubuntu:22.04 FROM ubuntu:22.04
ARG TARGETARCH RUN apt-get update && apt-get install -y --no-install-recommends \
RUN apt-get update && apt-get install -y \
libqt5core5a \
libqt5network5 \
libqt5xml5 \
libqt5sql5 \
libqt5sql5-sqlite \
ca-certificates \ ca-certificates \
iproute2 \
iputils-ping \
python3 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Copy binaries using the correct absolute paths from Stage 1 # Copy binaries using the correct absolute paths from Stage 1
COPY --from=builder /usr/bin/3proxy /usr/bin/3proxy COPY --from=builder /usr/local/bin/3proxy /usr/bin/3proxy
COPY --from=builder /src/3proxy-eagle/src/3proxy-eagle /usr/bin/3proxy-eagle COPY --from=builder /usr/local/3proxy /usr/local/3proxy
COPY --from=builder_yggdrasil /out/yggdrasil /usr/bin/yggdrasil
COPY --from=builder_yggdrasil /out/yggdrasilctl /usr/bin/yggdrasilctl
COPY --from=builder_yggdrasil /out/genkeys /usr/bin/genkeys
# Copy local runtime helpers/config
COPY ./entrypoint.sh /entrypoint.sh
COPY ./socks_router.py /usr/local/bin/socks_router.py
COPY ./yggdrasil.conf /etc/yggdrasil/yggdrasil.conf
# Setup config directories # Setup config directories
RUN mkdir -p /etc/3proxy /etc/3proxy-eagle/data RUN mkdir -p /etc/3proxy /etc/yggdrasil \
&& chmod +x /usr/local/bin/socks_router.py \
&& chmod +x /entrypoint.sh
# Ensure these files exist in your local directory where you run 'docker build' # Ensure these files exist in your local directory where you run 'docker build'
#COPY first-instanse.cfg /etc/3proxy/ #COPY first-instanse.cfg /etc/3proxy/
#COPY second-instanse.cfg /etc/3proxy/ #COPY second-instanse.cfg /etc/3proxy/
EXPOSE 8161 EXPOSE 1080 3128 8161
# Run in foreground # Start Yggdrasil first, then run plain 3proxy instances from entrypoint.
CMD ["/usr/bin/3proxy-eagle", \ ENTRYPOINT ["/entrypoint.sh"]
"-i", "/usr/bin/3proxy,/etc/3proxy/first-instanse.cfg", \ CMD []
"-i", "/usr/bin/3proxy,/etc/3proxy/second-instanse.cfg", \
"-w", "/etc/3proxy-eagle/data", \
"-t", "DarkProxy"]
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
set -euo pipefail
YGG_CONF="${YGG_CONF:-/etc/yggdrasil/yggdrasil.conf}"
YGG_CONNECT_WAIT_SECONDS="${YGG_CONNECT_WAIT_SECONDS:-8}"
YGG_RUNTIME_CONF="${YGG_RUNTIME_CONF:-/run/yggdrasil.conf}"
PROXY_CFG_PRIMARY="${PROXY_CFG_PRIMARY:-/etc/3proxy/first-instanse.cfg}"
PROXY_CFG_SECONDARY="${PROXY_CFG_SECONDARY:-/etc/3proxy/second-instanse.cfg}"
SOCKS_ROUTER_BIN="${SOCKS_ROUTER_BIN:-/usr/local/bin/socks_router.py}"
ygg_pid=""
proxy_primary_pid=""
proxy_secondary_pid=""
router_pid=""
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
}
cleanup() {
if [[ -n "${proxy_primary_pid}" ]]; then
kill -TERM "${proxy_primary_pid}" 2>/dev/null || true
fi
if [[ -n "${proxy_secondary_pid}" ]]; then
kill -TERM "${proxy_secondary_pid}" 2>/dev/null || true
fi
if [[ -n "${router_pid}" ]]; then
kill -TERM "${router_pid}" 2>/dev/null || true
fi
if [[ -n "${ygg_pid}" ]]; then
kill -TERM "${ygg_pid}" 2>/dev/null || true
fi
wait || true
}
trap cleanup TERM INT
if [[ ! -f "${YGG_CONF}" ]]; then
log "Missing Yggdrasil config at ${YGG_CONF}"
exit 1
fi
if [[ ! -f "${PROXY_CFG_PRIMARY}" ]]; then
log "Missing primary 3proxy config at ${PROXY_CFG_PRIMARY}"
exit 1
fi
if [[ ! -f "${PROXY_CFG_SECONDARY}" ]]; then
log "Missing secondary 3proxy config at ${PROXY_CFG_SECONDARY}"
exit 1
fi
if [[ ! -f "${SOCKS_ROUTER_BIN}" ]]; then
log "Missing SOCKS router at ${SOCKS_ROUTER_BIN}"
exit 1
fi
cp "${YGG_CONF}" "${YGG_RUNTIME_CONF}"
# Populate keys once if config still has empty key fields.
if ! grep -Eq '^[[:space:]]*PrivateKey:[[:space:]]+[0-9a-fA-F]+' "${YGG_RUNTIME_CONF}"; then
log "Generating Yggdrasil keys for dark3proxy"
key_output="$(timeout 8 /usr/bin/genkeys 2>/dev/null || true)"
priv_key="$(printf '%s\n' "${key_output}" | awk '/^Priv:/{print $2; exit}')"
pub_key="$(printf '%s\n' "${key_output}" | awk '/^Pub:/{print $2; exit}')"
if [[ -z "${priv_key}" || -z "${pub_key}" ]]; then
log "Failed to parse generated Yggdrasil keys"
exit 1
fi
sed -i -E \
-e "s|^([[:space:]]*PublicKey:).*|\1 ${pub_key}|" \
-e "s|^([[:space:]]*PrivateKey:).*|\1 ${priv_key}|" \
"${YGG_RUNTIME_CONF}"
fi
sysctl net.ipv6.conf.all.disable_ipv6=0 >/dev/null 2>&1 || true
log "Starting Yggdrasil"
/usr/bin/yggdrasil -useconffile "${YGG_RUNTIME_CONF}" &
ygg_pid=$!
sleep "${YGG_CONNECT_WAIT_SECONDS}"
if ! kill -0 "${ygg_pid}" 2>/dev/null; then
log "Yggdrasil exited during startup"
exit 1
fi
log "Starting secondary 3proxy instance (${PROXY_CFG_SECONDARY})"
/usr/bin/3proxy "${PROXY_CFG_SECONDARY}" &
proxy_secondary_pid=$!
sleep 1
if ! kill -0 "${proxy_secondary_pid}" 2>/dev/null; then
log "Secondary 3proxy instance exited during startup"
exit 1
fi
log "Starting hostname-preserving SOCKS router (${SOCKS_ROUTER_BIN})"
python3 "${SOCKS_ROUTER_BIN}" &
router_pid=$!
sleep 1
if ! kill -0 "${router_pid}" 2>/dev/null; then
log "SOCKS router exited during startup"
exit 1
fi
log "Starting primary 3proxy instance (${PROXY_CFG_PRIMARY})"
/usr/bin/3proxy "${PROXY_CFG_PRIMARY}" &
proxy_primary_pid=$!
sleep 1
if ! kill -0 "${proxy_primary_pid}" 2>/dev/null; then
log "Primary 3proxy instance exited during startup"
exit 1
fi
wait -n "${proxy_secondary_pid}" "${proxy_primary_pid}" "${router_pid}" "${ygg_pid}"
exit_code=$?
log "A managed process exited, shutting down"
cleanup
exit "${exit_code}"
+27 -17
View File
@@ -1,46 +1,56 @@
# regular 3proxy configuration # regular 3proxy configuration
fakeresolve
flush flush
auth strong nserver 10.5.0.7/9053
users $/run/secrets/PROXY_USERS nserver 10.5.0.6
admin -p8161 -a127.0.0.1 nscache 65536
# strict required by 3proxy-eagle log lines auth none
# Expose admin interface to host via docker port mapping (2002 -> 8161).
admin -p8161
# Keep compact per-request accounting in container logs.
log log
logformat " type=%N destination=%n to=%O from=%I" logformat " type=%N destination=%n to=%O from=%I"
# 3proxy-eagle black list format # Blocklist (plain 3proxy syntax).
{{vk.com,ok.ru,mail.ru,gosuslugi.ru,127.0.0.1}} deny * * vk.com
deny * * ok.ru
deny * * mail.ru
deny * * gosuslugi.ru
# regular 3proxy configuration again # regular 3proxy configuration again
# expose a lightweight status socket for Prometheus scraping
# the exporter will connect here and convert counters to metrics
monitor -p6800
## i2p sites ## i2p sites
allow * * *.i2p allow * * .i2p
#parent 1000 http 127.0.0.1 4444 #parent 1000 http 127.0.0.1 4444
parent 1000 socks5+ 10.5.0.2 4447 parent 1000 socks5+ 10.5.0.2 4447
flush
## yggdrasil (127.0.0.1 1085 is second instanse) ## yggdrasil (127.0.0.1 1085 is second instanse)
allow * * 200::/7 allow * * 200::/7
parent 1000 socks5+ 127.0.0.1 1085 parent 1000 socks5+ 127.0.0.1 1085
flush
## ygg domains resolving by alfis ## ygg domains resolving by alfis
allow * * *.ygg allow * * .ygg
parent 1000 socks5+ 127.0.0.1 1085 parent 1000 socks5+ 127.0.0.1 1085
flush
## meshnames domains resolving by meshnamed ## meshnames domains resolving by meshnamed
allow * * *.meshname allow * * .meshname
parent 1000 socks5+ 127.0.0.1 1085 parent 1000 socks5+ 127.0.0.1 1085
allow * * *.meship flush
allow * * .meship
parent 1000 socks5+ 127.0.0.1 1085 parent 1000 socks5+ 127.0.0.1 1085
flush
## onion sites ## onion sites
allow * * *.onion allow * * .onion
parent 1000 socks5+ 10.5.0.7 9050 parent 1000 socks5+ 10.5.0.7 9050
flush
## clearnet via another proxy or change to tor (9050) ## clearnet via another proxy or change to tor (9050)
allow * allow *
parent 1000 socks5+ 10.5.0.7 9050 parent 1000 socks5+ 10.5.0.7 9050
socks -olSO_REUSEADDR,SO_REUSEPORT -ocTCP_TIMESTAMPS,TCP_NODELAY -osTCP_NODELAY -46 -i0.0.0.0 -p1080 socks -olSO_REUSEADDR,SO_REUSEPORT -ocTCP_TIMESTAMPS,TCP_NODELAY -osTCP_NODELAY -46 -i0.0.0.0 -p1088
proxy -olSO_REUSEADDR,SO_REUSEPORT -ocTCP_TIMESTAMPS,TCP_NODELAY -osTCP_NODELAY -46 -a1 -i0.0.0.0 -p3128 proxy -olSO_REUSEADDR,SO_REUSEPORT -ocTCP_TIMESTAMPS,TCP_NODELAY -osTCP_NODELAY -46 -a1 -i0.0.0.0 -p3128
+351
View File
@@ -0,0 +1,351 @@
#!/usr/bin/env python3
import asyncio
import ipaddress
import json
import os
import signal
import struct
import time
from datetime import datetime
from typing import Optional, Tuple
SOCKS_VERSION = 5
CMD_CONNECT = 1
ATYP_IPV4 = 1
ATYP_DOMAIN = 3
ATYP_IPV6 = 4
REP_SUCCEEDED = 0
REP_GENERAL_FAILURE = 1
REP_CONNECTION_NOT_ALLOWED = 2
REP_NETWORK_UNREACHABLE = 3
REP_HOST_UNREACHABLE = 4
REP_COMMAND_NOT_SUPPORTED = 7
REP_ADDRESS_TYPE_NOT_SUPPORTED = 8
YGG_NET = ipaddress.ip_network("200::/7")
def env_endpoint(var_name: str, default: str) -> Tuple[str, int]:
raw = os.getenv(var_name, default).strip()
host, sep, port_s = raw.rpartition(":")
if not sep:
raise ValueError(f"Invalid endpoint for {var_name}: {raw}")
return host, int(port_s)
LISTEN_HOST = os.getenv("ROUTER_LISTEN_HOST", "0.0.0.0")
LISTEN_PORT = int(os.getenv("ROUTER_LISTEN_PORT", "1080"))
UPSTREAM_TOR = env_endpoint("ROUTER_UPSTREAM_TOR", "10.5.0.7:9050")
UPSTREAM_I2P = env_endpoint("ROUTER_UPSTREAM_I2P", "10.5.0.2:4447")
UPSTREAM_YGG = env_endpoint("ROUTER_UPSTREAM_YGG", "127.0.0.1:1085")
I2P_MAP_FILE = os.getenv("ROUTER_I2P_MAP_FILE", "").strip()
I2P_POOL_CIDR = os.getenv("ROUTER_I2P_POOL_CIDR", "172.31.0.0/16")
I2P_POOL = ipaddress.ip_network(I2P_POOL_CIDR)
_i2p_map_mtime = -1.0
_i2p_map_last_check = 0.0
_i2p_ip_to_host = {}
BLOCKLIST = [
item.strip().lower()
for item in os.getenv("ROUTER_BLOCKLIST", "vk.com,ok.ru,mail.ru,gosuslugi.ru").split(",")
if item.strip()
]
def log(msg: str) -> None:
ts = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
print(f"[{ts}] [socks-router] {msg}", flush=True)
def is_blocked_domain(hostname: str) -> bool:
host = hostname.rstrip(".").lower()
for blocked in BLOCKLIST:
if host == blocked or host.endswith("." + blocked):
return True
return False
def _reload_i2p_map_if_needed() -> None:
global _i2p_map_mtime, _i2p_map_last_check, _i2p_ip_to_host
if not I2P_MAP_FILE:
return
now = time.monotonic()
if now - _i2p_map_last_check < 1.0:
return
_i2p_map_last_check = now
try:
mtime = os.path.getmtime(I2P_MAP_FILE)
except OSError:
_i2p_ip_to_host = {}
_i2p_map_mtime = -1.0
return
if mtime == _i2p_map_mtime:
return
try:
with open(I2P_MAP_FILE, "r", encoding="utf-8") as fh:
data = json.load(fh)
mapping = data.get("ip_to_host", {}) if isinstance(data, dict) else {}
if isinstance(mapping, dict):
_i2p_ip_to_host = {str(k): str(v) for k, v in mapping.items()}
else:
_i2p_ip_to_host = {}
_i2p_map_mtime = mtime
except Exception:
_i2p_ip_to_host = {}
def lookup_i2p_hostname_for_virtual_ip(ip_str: str) -> Optional[str]:
if not I2P_MAP_FILE:
return None
try:
ip = ipaddress.ip_address(ip_str)
except ValueError:
return None
if ip.version != 4 or ip not in I2P_POOL:
return None
_reload_i2p_map_if_needed()
host = _i2p_ip_to_host.get(str(ip))
if host and host.endswith(".i2p"):
return host
return None
def pick_upstream(host: str, atyp: int) -> Optional[Tuple[str, int]]:
if atyp == ATYP_DOMAIN:
dhost = host.rstrip(".").lower()
if is_blocked_domain(dhost):
return None
if dhost.endswith(".onion"):
return UPSTREAM_TOR
if dhost.endswith(".i2p"):
return UPSTREAM_I2P
if dhost.endswith(".ygg") or dhost.endswith(".meshname") or dhost.endswith(".meship"):
return UPSTREAM_YGG
return UPSTREAM_TOR
if atyp == ATYP_IPV6:
try:
ip = ipaddress.ip_address(host)
if ip in YGG_NET:
return UPSTREAM_YGG
except ValueError:
return None
return UPSTREAM_TOR
if atyp == ATYP_IPV4:
return UPSTREAM_TOR
return None
async def read_exact(reader: asyncio.StreamReader, n: int) -> bytes:
data = await reader.readexactly(n)
if len(data) != n:
raise asyncio.IncompleteReadError(partial=data, expected=n)
return data
async def send_reply(writer: asyncio.StreamWriter, rep: int) -> None:
writer.write(b"\x05" + bytes([rep]) + b"\x00\x01\x00\x00\x00\x00\x00\x00")
await writer.drain()
async def pipe(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
try:
while True:
chunk = await reader.read(65536)
if not chunk:
break
writer.write(chunk)
await writer.drain()
except Exception:
pass
finally:
try:
writer.close()
await writer.wait_closed()
except Exception:
pass
async def handle_client(client_reader: asyncio.StreamReader, client_writer: asyncio.StreamWriter) -> None:
peer = client_writer.get_extra_info("peername")
upstream_writer = None
try:
header = await read_exact(client_reader, 2)
ver, n_methods = header[0], header[1]
if ver != SOCKS_VERSION:
client_writer.close()
await client_writer.wait_closed()
return
_ = await read_exact(client_reader, n_methods)
client_writer.write(b"\x05\x00")
await client_writer.drain()
req = await read_exact(client_reader, 4)
ver, cmd, _rsv, atyp = req
if ver != SOCKS_VERSION or cmd != CMD_CONNECT:
await send_reply(client_writer, REP_COMMAND_NOT_SUPPORTED)
return
if atyp == ATYP_IPV4:
raw_addr = await read_exact(client_reader, 4)
dst_host = str(ipaddress.IPv4Address(raw_addr))
elif atyp == ATYP_IPV6:
raw_addr = await read_exact(client_reader, 16)
dst_host = str(ipaddress.IPv6Address(raw_addr))
elif atyp == ATYP_DOMAIN:
dlen = (await read_exact(client_reader, 1))[0]
raw_addr = await read_exact(client_reader, dlen)
dst_host = raw_addr.decode("ascii", errors="ignore")
else:
await send_reply(client_writer, REP_ADDRESS_TYPE_NOT_SUPPORTED)
return
dst_port = struct.unpack("!H", await read_exact(client_reader, 2))[0]
connect_atyp = atyp
connect_host = dst_host
connect_raw_addr = raw_addr
if atyp == ATYP_IPV4:
mapped_i2p = lookup_i2p_hostname_for_virtual_ip(dst_host)
if mapped_i2p:
connect_atyp = ATYP_DOMAIN
connect_host = mapped_i2p
connect_raw_addr = None
upstream = pick_upstream(connect_host, connect_atyp)
if upstream is None:
await send_reply(client_writer, REP_CONNECTION_NOT_ALLOWED)
log(f"deny peer={peer} dst={dst_host}:{dst_port}")
return
up_host, up_port = upstream
if connect_host != dst_host:
log(
f"route peer={peer} dst={dst_host}:{dst_port} mapped={connect_host} via={up_host}:{up_port}"
)
else:
log(f"route peer={peer} dst={dst_host}:{dst_port} via={up_host}:{up_port}")
try:
upstream_reader, upstream_writer = await asyncio.open_connection(up_host, up_port)
except Exception:
await send_reply(client_writer, REP_NETWORK_UNREACHABLE)
return
# SOCKS5 greeting to upstream.
upstream_writer.write(b"\x05\x01\x00")
await upstream_writer.drain()
up_auth = await read_exact(upstream_reader, 2)
if up_auth[0] != SOCKS_VERSION or up_auth[1] != 0x00:
await send_reply(client_writer, REP_GENERAL_FAILURE)
return
connect_req = bytearray(b"\x05\x01\x00")
connect_req.append(connect_atyp)
if connect_atyp == ATYP_DOMAIN:
try:
host_bytes = connect_host.encode("idna")
except UnicodeError:
host_bytes = connect_host.encode("ascii", errors="ignore")
if len(host_bytes) > 255:
await send_reply(client_writer, REP_HOST_UNREACHABLE)
return
connect_req.append(len(host_bytes))
connect_req.extend(host_bytes)
else:
connect_req.extend(connect_raw_addr)
connect_req.extend(struct.pack("!H", dst_port))
upstream_writer.write(connect_req)
await upstream_writer.drain()
up_resp = await read_exact(upstream_reader, 4)
if up_resp[0] != SOCKS_VERSION:
await send_reply(client_writer, REP_GENERAL_FAILURE)
return
up_rep = up_resp[1]
up_atyp = up_resp[3]
if up_atyp == ATYP_IPV4:
_ = await read_exact(upstream_reader, 4)
elif up_atyp == ATYP_IPV6:
_ = await read_exact(upstream_reader, 16)
elif up_atyp == ATYP_DOMAIN:
ln = (await read_exact(upstream_reader, 1))[0]
_ = await read_exact(upstream_reader, ln)
else:
await send_reply(client_writer, REP_GENERAL_FAILURE)
return
_ = await read_exact(upstream_reader, 2)
if up_rep != REP_SUCCEEDED:
await send_reply(client_writer, up_rep if up_rep <= REP_ADDRESS_TYPE_NOT_SUPPORTED else REP_GENERAL_FAILURE)
return
await send_reply(client_writer, REP_SUCCEEDED)
await asyncio.gather(
pipe(client_reader, upstream_writer),
pipe(upstream_reader, client_writer),
)
except asyncio.IncompleteReadError:
pass
except Exception as exc:
log(f"error peer={peer} err={exc}")
try:
await send_reply(client_writer, REP_GENERAL_FAILURE)
except Exception:
pass
finally:
try:
client_writer.close()
await client_writer.wait_closed()
except Exception:
pass
async def main() -> int:
server = await asyncio.start_server(handle_client, LISTEN_HOST, LISTEN_PORT)
sockets = ", ".join(str(s.getsockname()) for s in (server.sockets or []))
log(f"listening on {sockets}")
stop_event = asyncio.Event()
def _stop() -> None:
stop_event.set()
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(sig, _stop)
except NotImplementedError:
pass
await stop_event.wait()
server.close()
await server.wait_closed()
log("shutdown complete")
return 0
if __name__ == "__main__":
try:
raise SystemExit(asyncio.run(main()))
except KeyboardInterrupt:
raise SystemExit(0)
+27
View File
@@ -0,0 +1,27 @@
{
# Keep an explicit peer to darki2p so dark3proxy always has a Ygg path.
Peers: [
tls://10.5.0.2:10654
]
InterfacePeers: {}
# No public listener needed for dark3proxy use-case.
Listen: []
AdminListen: unix:///var/run/yggdrasil.sock
# Disable multicast discovery for deterministic container behavior.
MulticastInterfaces: []
AllowedPublicKeys: []
PublicKey:
PrivateKey:
IfName: auto
IfMTU: 65535
NodeInfoPrivacy: false
NodeInfo: {}
}
Submodule PopuraDNS updated: f76f02bb72...350f6d3b5d
+180 -47
View File
@@ -4,24 +4,53 @@ Welcome to darkproxy...
The stack now uses safer defaults for production: The stack now uses safer defaults for production:
* Sensitive passwords are loaded from Docker secrets instead of inline values. * Service admin/web passwords are loaded from Docker secrets instead of inline values.
* Admin/monitoring ports are bound to `127.0.0.1` on the host. * Admin/monitoring ports are bound to `127.0.0.1` on the host.
* 3proxy now requires authentication (`auth strong`). * 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: Before starting in production, set these secret files:
```sh ```sh
printf 'proxyuser:CL:YOUR_STRONG_PROXY_PASSWORD\n' > secrets/3proxy_users.txt
printf 'YOUR_STRONG_PIHOLE_PASSWORD\n' > secrets/pihole_webpassword.txt printf 'YOUR_STRONG_PIHOLE_PASSWORD\n' > secrets/pihole_webpassword.txt
printf 'YOUR_STRONG_GRAFANA_PASSWORD\n' > secrets/grafana_admin_password.txt printf 'proxyuser:REPLACE_WITH_A_LONG_RANDOM_PASSWORD\n' > secrets/3proxy_users.txt
printf 'REPLACE_WITH_A_LONG_RANDOM_NAMECOIN_RPC_PASSWORD\n' > secrets/namecoin_rpc_password.txt
``` ```
SOCKS clients must now authenticate to 3proxy with credentials from Optional Tailscale note:
`secrets/3proxy_users.txt`.
* 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
supported. This only affects Pi-hole's network-table enrichment, not DNS
blocking/forwarding behavior.
This container runs the following vontainers in the stack. This container runs the following vontainers in the stack.
3proxy-eagle - Socks5/http proxy dark3proxy - SOCKS5/http proxy with hostname-preserving router
- DNS - DNS
- pihole - ad blocking and metrics - pihole - ad blocking and metrics
- PopuraDNS - non-recursive DNS forwards requests to the following: - PopuraDNS - non-recursive DNS forwards requests to the following:
@@ -34,13 +63,67 @@ This container runs the following vontainers in the stack.
* Socks5 server for I2P * Socks5 server for I2P
- Tor container - Tor container
## SOCKS ports on host
Public proxy access is exposed on a single host port:
* `<host-ip>:2000` -> `dark3proxy` SOCKS (`dark3proxy:1080`), externally reachable.
- Intended as the single client entrypoint for clearnet, onion, and Ygg/I2P routing rules.
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`:
* Front-end process: `3proxy/socks_router.py` (SOCKS5), keeps destination hostnames intact.
* Upstream dispatch:
- `*.onion` -> Tor SOCKS (`10.5.0.7:9050`)
- `*.i2p` -> i2pd SOCKS (`10.5.0.2:4447`)
- `*.ygg`, `*.meshname`, `*.meship`, and `200::/7` -> local Ygg-guarded SOCKS (`127.0.0.1:1085`)
- all other targets -> Tor SOCKS (`10.5.0.7:9050`)
* The primary 3proxy instance still enforces ACL/parent rules internally, with SOCKS moved to internal port `1088` to avoid collision with the router front-end.
Additional non-proxy endpoint:
* `127.0.0.1:2006/udp` -> Tor DNSPort (`darktor:9053`).
Quick checks:
```sh
# Onion over the only published proxy port
curl --proxy-user proxyuser:REPLACE_WITH_A_LONG_RANDOM_PASSWORD --socks5-hostname <host-ip>:2000 -I http://duckduckgogg42xjoc72x3sjasowoarfbgcmvfimaftt6twagswzczad.onion
# Clearnet over the only published proxy port
curl --proxy-user proxyuser:REPLACE_WITH_A_LONG_RANDOM_PASSWORD --socks5-hostname <host-ip>:2000 -I https://example.com
```
The system offers a DNS that resolves many darknet and clearnet IP's. The The system offers a DNS that resolves many darknet and clearnet IP's. The
stack now handles: stack now handles:
Local resolution model (important):
* DNS queries are resolved inside this Docker stack on your host (`pihole -> CoreDNS -> local resolver services`).
* The stack does not forward DNS queries to public DNS providers for supported darknet/blockchain TLDs.
* Some namespaces still require blockchain RPC data sources for authoritative records (see limitations below).
* `*.onion` forwarded to Tor's DNSPort running in the `darktor` container at * `*.onion` forwarded to Tor's DNSPort running in the `darktor` container at
10.5.0.7:9053. 10.5.0.7:9053.
* `*.i2p` served by the built-in DNS server inside the `darki2p` container * `*.i2p` resolved by local `i2pdns` bridge service (`10.5.0.16:53`).
(10.5.0.2:53) which is enabled via the `[dns]` section in `i2pd.conf`. - `i2pdns` synthesizes deterministic private A records and writes IP<->hostname mappings.
- `dark3proxy` consumes that map so synthetic IP requests are translated back to `.i2p` hostnames and routed to i2pd SOCKS (`10.5.0.2:4447`).
You can query these directly from any container on the `darkproxy` network, for You can query these directly from any container on the `darkproxy` network, for
example: example:
@@ -62,10 +145,9 @@ docker run --rm --network darkproxy infoblox/dig dig @10.5.0.4 TXT vitalik.eth
docker run --rm --network darkproxy infoblox/dig dig @10.5.0.4 example.bit docker run --rm --network darkproxy infoblox/dig dig @10.5.0.4 example.bit
``` ```
> **Note:** the CoreDNS service at `10.5.0.4` no longer enforces an ACL for > **Note:** the CoreDNS service at `10.5.0.4` enforces an ACL on the root zone.
> the root zone it will happily perform recursive lookups for any client. > Recursive lookups are limited to loopback, RFC1918/private ranges, and the
> Queries from your host should now work without the “refused to do a recursive > Yggdrasil/internal subnets used by this stack.
> query” error. If you later reintroduce an ACL, update this note accordingly.
### Host-side quick tests (no extra images) ### Host-side quick tests (no extra images)
@@ -90,19 +172,37 @@ The stack now provides DNS resolution for a comprehensive set of alternative TLD
| TLD | Resolver | Purpose | | TLD | Resolver | Purpose |
|-----|----------|---------| |-----|----------|---------|
| `.onion`, `.exit`, `.onion4`, `.onion6` | Tor DNSPort (10.5.0.7:9053) | Tor hidden services | | `.onion`, `.exit`, `.onion4`, `.onion6` | Tor DNSPort (10.5.0.7:9053) | Tor hidden services |
| `.i2p` | i2pd DNS (10.5.0.2:53) | I2P eepsite names | | `.i2p` | i2pdns bridge (10.5.0.16:53) + i2pd SOCKS (10.5.0.2:4447) | I2P eepsite names |
| `.eth` | ensdns (10.5.0.11) via CoreDNS | Ethereum Name Service (local resolver) | | `.eth` | ensdns (10.5.0.11) via CoreDNS | Ethereum Name Service (local resolver) |
| `.bit` | namecoindns (10.5.0.12) via CoreDNS | Namecoin names (local resolver service) | | `.bit` | namecoindns (10.5.0.12) via CoreDNS | Namecoin names (local resolver service) |
| `.alt` | alt-root 185.121.177.177 | Alternative DNS root | | `.alt` | local unbound (10.5.0.5) via CoreDNS | Alternative DNS root (local-only fallback mode) |
| `.loki` | Cloudflare 1.1.1.1 | Lokinet privacy network | | `.loki` | local lokinet (10.5.0.14) via CoreDNS | Lokinet privacy network (no Cloudflare/public DNS forwarder dependency) |
| `.zil` | Cloudflare 1.1.1.1 | Zilliqa blockchain | | `.zil` | zildns (10.5.0.15) via CoreDNS | Zilliqa blockchain (local resolver service; no Cloudflare DNS dependency) |
| `.web3` | Cloudflare 1.1.1.1 | General blockchain namespace | | `.web3` | local unbound (10.5.0.5) via CoreDNS | General blockchain namespace (local-only fallback mode) |
| Alfis TLDs | Alfis (10.5.0.3) | `.anon`, `.btn`, `.conf`, `.index`, `.merch`, `.mirror`, `.mob`, `.screen`, `.srv`, `.ygg` | | Alfis TLDs | Alfis (10.5.0.3) | `.anon`, `.btn`, `.conf`, `.index`, `.merch`, `.mirror`, `.mob`, `.screen`, `.srv`, `.ygg` |
| EmerCoin TLDs | EmerCoin (10.5.0.9) | `.emc`, `.coin`, `.lib`, `.bazar`, `.enum` | | EmerCoin TLDs | EmerCoin (10.5.0.9) | `.emc`, `.coin`, `.lib`, `.bazar`, `.enum` |
| OpenNIC TLDs | OpenNIC public resolvers | `.bbs`, `.chan`, `.cyb`, `.dyn`, `.epic`, `.geek`, `.gopher`, `.indy`, `.libre`, `.neo`, `.null`, `.o`, `.oss`, `.oz`, `.parody`, `.pirate`, `.fur`, `.ku`, `.rm`, `.te`, `.ti`, `.uu`, `.ko` | | OpenNIC TLDs | local unbound (10.5.0.5) via CoreDNS | `.bbs`, `.chan`, `.cyb`, `.dyn`, `.epic`, `.geek`, `.gopher`, `.indy`, `.libre`, `.neo`, `.null`, `.o`, `.oss`, `.oz`, `.parody`, `.pirate`, `.fur`, `.ku`, `.rm`, `.te`, `.ti`, `.uu`, `.ko` (local-only fallback mode) |
| Clearnet | Unbound (10.5.0.5) | All other domains via recursive resolution | | Clearnet | Unbound (10.5.0.5) | All other domains via recursive resolution |
## Local-only DNS mode limitations
Darkproxy now avoids direct public DNS forwarders for `.alt`, `.zil`, `.web3`, OpenNIC TLDs, and Lokinet fallback DNS.
Resolution still happens locally in this stack. The remaining external dependency
surface is blockchain RPC/data access for record lookup, not public DNS
forwarding.
Important limitations still apply for authoritative data:
* `.eth` requires an Ethereum JSON-RPC backend. By default this stack uses `https://ethereum-rpc.publicnode.com` in `ensdns`.
* `.alt`, OpenNIC TLDs, and `.web3` are not in the ICANN root. In local-only fallback mode they are sent to local Unbound, so they usually return `NXDOMAIN` unless you provide your own authoritative/local resolver backend for those namespaces.
* `.zil` is resolved through local `zildns`, which queries ZNS using `https://api.zilliqa.com` by default. This is a Zilliqa RPC lookup path (on-chain resolution), not a Cloudflare DNS forwarder path. You can override the RPC endpoint with `ZILDNS_ZNS_URL` in `docker-compose.yml`.
* `zildns` supports `ZILDNS_CACHE_SECONDS` (record cache duration) and `ZILDNS_PREWARM_DOMAIN` (startup warm-up query) to reduce first-query latency.
* For browser convenience, `A` lookups may return a CNAME to a public IPFS gateway when a `.zil` content hash exists. That gateway alias is separate from DNS resolution itself.
* `.loki` is resolved by local Lokinet, and Lokinet fallback DNS points to local Unbound (no Cloudflare/public DNS forwarder dependency).
## Configuration validation and drift tests ## Configuration validation and drift tests
A simple automation is included to help detect accidental changes to the A simple automation is included to help detect accidental changes to the
@@ -110,11 +210,13 @@ compose and DNS configuration:
1. **Local check** run `scripts/validate-config.sh` (or its PowerShell equivalent). 1. **Local check** run `scripts/validate-config.sh` (or its PowerShell equivalent).
- The script requires `docker` to be installed; it will generate a normalized - The script requires `docker` to be installed; it will generate a normalized
manifest and compare it against `docker-compose.lock.yml`. manifest and compare it against `docker-compose.lock.yml`.
- If the baseline file does not exist it will be created; commit the file after - If the baseline file does not exist it will be created; commit the file after
review. review.
- CoreDNS syntax is also validated using either a local `coredns` binary or a - CoreDNS syntax is validated with the repo's compiled CoreDNS image so the
container. custom `meshname` and `meship` plugins are available during the check.
- Unbound syntax is validated with the repo's Unbound image so the chrooted
runtime layout matches the stack.
2. **CI workflow** `.github/workflows/config-validation.yml` is triggered on 2. **CI workflow** `.github/workflows/config-validation.yml` is triggered on
pushes or pull requests affecting `docker-compose.yml` or `PopuraDNS/`. pushes or pull requests affecting `docker-compose.yml` or `PopuraDNS/`.
@@ -125,8 +227,8 @@ compose and DNS configuration:
compose file, regenerate the baseline: compose file, regenerate the baseline:
```sh ```sh
scripts/validate-config.sh # fails with drift docker compose config > docker-compose.lock.yml
cp generated.yml docker-compose.lock.yml scripts/validate-config.sh
git add docker-compose.lock.yml git add docker-compose.lock.yml
``` ```
@@ -180,10 +282,34 @@ records in Namecoin itself and wait for local `namecoind` sync completion.
> `.bit` responses may be empty or incomplete. > `.bit` responses may be empty or incomplete.
## Monitoring the proxy ## Local Lokinet (.loki) resolver
A lightweight Prometheus/Grafana stack is included to expose 3proxy metrics `.loki` lookups are resolved by a local Lokinet daemon path:
and give you visibility into traffic volumes, connected sessions, etc.
* CoreDNS forwards `loki.:53` to `10.5.0.14:53` (`lokinet`).
* `lokinet` resolves `.loki` names through the Lokinet network.
Quick test:
```sh
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. 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 and stack status
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 ### how it works
@@ -191,34 +317,39 @@ and give you visibility into traffic volumes, connected sessions, etc.
status socket. Samples look like `PROXY CONNS 12` or `SOCKS IN 345`. status socket. Samples look like `PROXY CONNS 12` or `SOCKS IN 345`.
* `monitor/exporter.py` polls that socket every few seconds and exports * `monitor/exporter.py` polls that socket every few seconds and exports
the counters on HTTP port **9100** in Prometheus format. the counters on HTTP port **9100** in Prometheus format.
* The `docker-compose.yml` file now defines three new services: * `monitor/status_dashboard.py` talks to the local Docker socket, runs targeted
`3proxy_exporter`, `prometheus` and `grafana`. DNS and SOCKS checks, and serves both HTML and JSON for stack status.
Prometheus scrapes the exporter and Grafana points at Prometheus as a data
source.
### building & running ### 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 ```sh
# build everything including the new services # build the exporter and status dashboard
docker-compose build 3proxy_exporter prometheus grafana docker-compose build 3proxy_exporter status_dashboard
docker-compose up -d docker-compose up -d
``` ```
Prometheus will be accessible on port **9090**, Grafana on **3000** (admin By default the status page is available on **http://127.0.0.1:2004/** and the
password `secret`). Use Grafana to create or import dashboards a simple raw JSON is available on **http://127.0.0.1:2004/api/status**. If you override
example JSON is provided in `monitor/3proxy-dashboard.json`. You can import the bind host/port via `STATUS_DASHBOARD_BIND_HOST` or
that file directly or build your own panels using metrics such as `STATUS_DASHBOARD_BIND_PORT`, use that address instead.
`proxy_conns` and `proxy_bytes_in`.
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 ### tips
* Alerts can be added in Prometheus rules, e.g. fire when `proxy_conns` * Alerts can be added in your external Prometheus rules, e.g. fire when
exceeds a threshold for several minutes. `proxy_conns` exceeds a threshold for several minutes.
* If you dont want the full stack, you can still query the monitor port * You can still query the monitor port directly with `nc`; nothing in the
directly with `nc`; nothing in the proxy depends on the exporter. 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 * The `scripts/validate-config.sh` script warns if a 3proxy config lacks a
`monitor` line. `monitor` line.
@@ -235,8 +366,12 @@ any warnings or errors. The script covers:
* CoreDNS/PopuraDNS Corefile syntax * CoreDNS/PopuraDNS Corefile syntax
* Unbound configuration syntax * Unbound configuration syntax
* Presence of required secret files * 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) * Simple 3proxy configuration sanity (presence of `socks`/`proxy` rules)
* Detection of `:latest` image tags (pin to fixed versions) * 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 * Verification that restart policies exist
The GitHub Actions workflow also includes a **smoke test** job that spins up The GitHub Actions workflow also includes a **smoke test** job that spins up
@@ -252,5 +387,3 @@ running `docker run ...` commands to exercise the images, scanning the
Run the script locally via `./scripts/validate-config.sh` (or Run the script locally via `./scripts/validate-config.sh` (or
`.scripts/validate-config.ps1` on Windows) and review the output carefully. `.scripts/validate-config.ps1` on Windows) and review the output carefully.
CI will execute the same validations automatically on pull requests. CI will execute the same validations automatically on pull requests.
+32
View File
@@ -0,0 +1,32 @@
FROM alpine:3.20 AS code-cloner
WORKDIR /src
RUN apk add --no-cache git
RUN git clone --depth 1 https://github.com/Revertron/Alfis.git
FROM rust:slim-bullseye AS builder
RUN apt-get update \
&& apt-get install -y --no-install-recommends pkg-config libcairo2-dev ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=code-cloner /src/Alfis /src
WORKDIR /src
RUN cargo build --release --no-default-features --features="doh"
FROM debian:bullseye-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates libcairo2 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /src/target/release/alfis /alfis
COPY --from=builder /src/alfis.toml /storage/alfis.toml
EXPOSE 4244/tcp
EXPOSE 53/tcp
EXPOSE 53/udp
WORKDIR /storage
VOLUME ["/storage"]
CMD ["/alfis", "-c", "/storage/alfis.toml"]
+59 -23
View File
@@ -3,41 +3,77 @@
# #
# docker compose -f docker-compose.yml -f docker-compose.arm.yml up --build # docker compose -f docker-compose.yml -f docker-compose.arm.yml up --build
# #
# This file adds `platform: linux/arm64` hints to services so the correct # The base compose is AMD64-only. This override is the ARM-only mode.
# architecture is selected when running on an ARM host. It does **not** replace # Apply this file on ARM hosts to switch services to linux/arm64 and ARM builds.
# any images; if an image is not available for ARM (e.g. alfis, unbound, the # If an image/build does not support ARM, startup should fail rather than silently
# prebuilt tor_yggdrasil) Docker will attempt emulation via QEMU or fall back to # using emulation.
# amd64.
#
# You can also modify this file to swap in ARM-specific images or build
# instructions if you have them.
services: services:
dark3proxy: dark3proxy:
platform: linux/arm64 platform: linux/arm64
i2pd_yggdrasil: 3proxy_exporter:
platform: linux/arm64
i2pdns:
platform: linux/arm64
i2pd_yggdrasil:
# Build from local ARM context in this repo to avoid read-only upstream mirror constraints.
build:
context: ./i2pd_yggdrasil_arm
dockerfile: Dockerfile
platform: linux/arm64 platform: linux/arm64
tor_yggdrasil: tor_yggdrasil:
build:
context: ./tor_yggdrasil_docker
dockerfile: Dockerfile
platform: linux/arm64 platform: linux/arm64
alfis: alfis:
image: darkproxy-alfis:arm64
build:
context: ./alfis_arm
dockerfile: Dockerfile
platform: linux/arm64 platform: linux/arm64
coredns: coredns:
platform: linux/arm64 platform: linux/arm64
zildns:
platform: linux/arm64
unbound: unbound:
image: darkproxy-unbound:arm64
build:
context: ./unbound_arm
dockerfile: Dockerfile
platform: linux/arm64 platform: linux/arm64
pihole: pihole:
platform: linux/arm64 platform: linux/arm64
emc: emc:
platform: linux/arm64 platform: linux/arm64
geth: ensdns:
platform: linux/arm64 platform: linux/arm64
# networks, volumes, and secrets are inherited from the base compose file lokinet:
platform: linux/arm64
namecoind:
image: sevenrats/namecoin-core@sha256:c5275f813f85f5e1b1f43e4ca044b35c8f622457710a87c1dcb51733e42b59d1
platform: linux/arm64
namecoindns:
platform: linux/arm64
tailscale:
platform: linux/arm64
prometheus:
platform: linux/arm64
# networks, volumes, and secrets are inherited from the base compose file
+709 -269
View File
@@ -1,269 +1,709 @@
services: name: darkproxy
dark3proxy: services:
container_name: dark3proxy 3proxy_exporter:
image: host60/darkproxy-dark3proxy:v1.0 build:
build: context: /home/blade/darkproxy/monitor
context: ./3proxy/. dockerfile: Dockerfile
dns: container_name: dark3proxy-exporter
- "10.5.0.6" depends_on:
dns_search: internal.namespace #namespace used in internal DNS dark3proxy:
volumes: condition: service_healthy
- "./3proxy/first-instanse.cfg:/etc/3proxy/first-instanse.cfg" required: true
- "./3proxy/second-instanse.cfg:/etc/3proxy/second-instanse.cfg" image: darkproxy-3proxy-exporter:local
restart: unless-stopped networks:
sysctls: darkproxy:
- "net.ipv6.conf.all.disable_ipv6=0" ipv4_address: 10.5.0.20
ports: platform: linux/amd64
- 100.117.196.19:2000:1080 # socks port security_opt:
- 100.117.196.19:2002:8161 # 3proxy-eagle web page - no-new-privileges:true
networks: alfis:
darkproxy: container_name: darkalfis
ipv4_address: 10.5.0.8 dns:
- 10.5.0.6
i2pd_yggdrasil: image: cofob/alfis@sha256:0c5788b1e409557bb814dc2d055d584db8c48b3d42b375bf13feb59583af0585
container_name: darki2p networks:
# image: i2pd_yggdrasil:latest darkproxy:
image: host60/i2pd_yggdrasil:v1.0 ipv4_address: 10.5.0.3
privileged: true platform: linux/amd64
cap_add: restart: unless-stopped
- NET_ADMIN security_opt:
devices: - no-new-privileges:true
- /dev/net/tun:/dev/net/tun stop_grace_period: 10s
ports: sysctls:
# I2P Service Ports net.ipv6.conf.all.disable_ipv6: "0"
# - "2827:2827" # BOB Bridge volumes:
# - "4444:4444" # HTTP Proxy - type: volume
# - "4447:4447" # SOCKS Proxy source: darkalfis_data
- "100.117.196.19:2001:7070" # Webconsole target: /storage
# - "7650:7650" # I2PControl volume: {}
# - "7654:7654" # I2CP coredns:
# - "7656:7656" # SAM Bridge (TCP) build:
- "10765:10765" # Main I2P Listener context: /home/blade/darkproxy/PopuraDNS
# [dns] section enabled in i2pd.conf; DNS port is 53 (accessible internally at 10.5.0.2:53) dockerfile: Dockerfile
# mapping to host is possible if desired, e.g. 10853:53/udp cpus: 1
# Yggdrasil Ports container_name: darkdns
- "10654:10654" # Yggdrasil Listener logging:
environment: driver: json-file
tz: /run/secrets/tz options:
secrets: max-file: "3"
- tz max-size: 10m
restart: unless-stopped mem_limit: "268435456"
healthcheck: mem_reservation: "67108864"
test: ["CMD", "bash", "-c", "pgrep i2pd && pgrep yggdrasil"] networks:
interval: 30s darkproxy:
timeout: 10s ipv4_address: 10.5.0.4
retries: 3 platform: linux/amd64
start_period: 60s restart: unless-stopped
sysctls: security_opt:
- "net.ipv6.conf.all.disable_ipv6=0" - no-new-privileges:true
mac_address: ce:22:b8:0e:6e:78 sysctls:
networks: net.ipv6.conf.all.disable_ipv6: "0"
darkproxy: dark3proxy:
ipv4_address: 10.5.0.2 build:
context: /home/blade/darkproxy/3proxy
tor_yggdrasil: dockerfile: Dockerfile
image: host60/tor_yggdrasil:v1.0 cap_add:
# image: tor_yggdrasil:latest - NET_ADMIN
# container_name: tor_over_yggdrasil cpus: 1
container_name: darktor container_name: dark3proxy
# platform removed; image is amd64-only devices:
cap_add: - source: /dev/net/tun
- NET_ADMIN # Required for network tunnel management target: /dev/net/tun
security_opt: permissions: rwm
- no-new-privileges:true # Security enhancement - prevent privilege esca> dns:
devices: - 10.5.0.6
- /dev/net/tun:/dev/net/tun # TUN device for Yggdrasil network interface dns_search:
ports: - internal.namespace
# - "127.0.0.1:9050:9050/tcp" # SOCKS5 proxy port (Tor) for application-l> environment:
- "10655:10655/tcp" # Yggdrasil peer connections PROXY_REQUIRE_AUTH: "true"
secrets: PROXY_USERS_FILE: /run/secrets/PROXY_USERS
- YGGDRASIL_GENERATE_KEYS ROUTER_I2P_MAP_FILE: /var/lib/i2pdns/map.json
environment: ROUTER_I2P_POOL_CIDR: 172.31.0.0/16
YGGDRASIL_GENERATE_KEYS: /run/secrets/YGGDRASIL_GENERATE_KEYS # Generate new Yggdrasil keys on startup ROUTER_NO_AUTH_CIDRS: ""
restart: unless-stopped # Auto-restart unless manually stopped ROUTER_REQUIRE_AUTH: "true"
dns: ROUTER_USERS_FILE: /run/secrets/PROXY_USERS
- "10.5.0.6" YGG_CONNECT_WAIT_SECONDS: "8"
sysctls: healthcheck:
- "net.ipv6.conf.all.disable_ipv6=0" test:
networks: - CMD
darkproxy: - bash
ipv4_address: 10.5.0.7 - -lc
- pgrep -f 'yggdrasil -useconffile' >/dev/null && pgrep -f '3proxy /etc/3proxy/second-instanse.cfg' >/dev/null && pgrep -f '3proxy /etc/3proxy/first-instanse.cfg' >/dev/null && pgrep -f 'python3 /usr/local/bin/socks_router.py' >/dev/null
alfis: timeout: 10s
image: cofob/alfis interval: 30s
# platform removed; image is amd64 only retries: 3
container_name: darkalfis start_period: 45s
volumes: mem_limit: "268435456"
- darkalfis_data:/storage mem_reservation: "67108864"
restart: unless-stopped networks:
stop_grace_period: 10s darkproxy:
dns: ipv4_address: 10.5.0.8
- "10.5.0.6" platform: linux/amd64
sysctls: ports:
- "net.ipv6.conf.all.disable_ipv6=0" - mode: ingress
networks: target: 1080
darkproxy: published: "2000"
ipv4_address: 10.5.0.3 protocol: tcp
- mode: ingress
coredns: host_ip: 127.0.0.1
# image: darkdns target: 8161
image: host60/darkdns:v1.0 published: "2002"
build: protocol: tcp
context: ./PopuraDNS restart: unless-stopped
dockerfile: Dockerfile secrets:
container_name: darkdns - source: PROXY_USERS
restart: unless-stopped target: /run/secrets/PROXY_USERS
sysctls: security_opt:
- "net.ipv6.conf.all.disable_ipv6=0" - no-new-privileges:true
logging: sysctls:
driver: "json-file" net.ipv6.conf.all.disable_ipv6: "0"
options: volumes:
max-size: "10m" # Maximum log file size - type: bind
max-file: "3" # Maximum of 3 rotating log files source: /home/blade/darkproxy/3proxy/first-instanse.cfg
networks: target: /etc/3proxy/first-instanse.cfg
darkproxy: bind:
ipv4_address: 10.5.0.4 create_host_path: true
- type: bind
unbound: source: /home/blade/darkproxy/3proxy/second-instanse.cfg
container_name: darkunbound target: /etc/3proxy/second-instanse.cfg
image: mvance/unbound:latest bind:
# platform removed; image is amd64-only create_host_path: true
sysctls: - type: bind
- "net.ipv6.conf.all.disable_ipv6=0" source: /home/blade/darkproxy/3proxy/yggdrasil.conf
networks: target: /etc/yggdrasil/yggdrasil.conf
darkproxy: bind:
ipv4_address: 10.5.0.5 create_host_path: true
volumes: - type: volume
- type: bind source: i2p_dns_map
read_only: true target: /var/lib/i2pdns
source: ./unbound/unbound.conf read_only: true
target: /opt/unbound/etc/unbound/unbound.conf volume: {}
- "./unbound/forward-records.conf:/opt/unbound/etc/unbound/forward-records.conf" emc:
- "./unbound/a-records.conf:/opt/unbound/etc/unbound/a-records.conf" cpus: 1
# ports: command:
# - "5053:5053/tcp" - -datadir=/emc/data
# - "5053:5053/udp" - -conf=/emc/emercoin.conf
# healthcheck: - -printtoconsole
# disable: true container_name: darkemer
restart: unless-stopped image: wg00/emercoin:0.8.4@sha256:b890987fb4b158305040dc76b32cd24ed5173dd7bc4aba983141ea0fe82e2996
mem_limit: "1073741824"
pihole: mem_reservation: "268435456"
container_name: darkpihole networks:
image: pihole/pihole:latest darkproxy:
platform: linux/arm64 ipv4_address: 10.5.0.9
# For DHCP it is recommended to remove these ports and instead add: network_mode: "host" platform: linux/amd64
ports: restart: unless-stopped
# DNS Ports security_opt:
# - "53:53/tcp" - no-new-privileges:true
# - "53:53/udp" stop_grace_period: 30s
# Default HTTP Port volumes:
# - "80:80/tcp" - type: volume
# Default HTTPs Port. FTL will generate a self-signed certificate source: emc_data
# - "443:443/tcp target: /emc/data
# Uncomment the line below if you are using Pi-hole as your DHCP server volume: {}
# - "67:67/udp" - type: bind
# Uncomment the line below if you are using Pi-hole as your NTP server source: /etc/localtime
# - "123:123/udp" target: /etc/localtime
- "100.117.196.19:2003:80/tcp" # pihole web port read_only: true
environment: bind:
# Set the appropriate timezone for your location (https://en.wikipedia.org/wiki/List_of_tz_database_time_zones), e.g: create_host_path: true
TZ: 'America/Detroit' ensdns:
# WEBPASSWORD: 'set a secure password here or it will be random' build:
# FTLCONF_webserver_api_password: 'darkproxy' context: /home/blade/darkproxy/ensdns
# If using Docker's default `bridge` network setting the dns listening mode should be set to 'all' dockerfile: Dockerfile
# FTLCONF_dns_listeningMode: 'all' cpus: 0.5
PIHOLE_DNS_: 10.5.0.4 container_name: darkens
TEMPERATUREUNIT: f dns:
WEBTHEME: lcars - 10.5.0.4
WEBPASSWORD: darkproxy environment:
FTLCONF_dns_upstreams: coredns ENS_RPC_URL: https://ethereum-rpc.publicnode.com
# Volumes store your data between container upgrades ENSDNS_TTL: "60"
volumes: healthcheck:
# For persisting Pi-hole's databases and common configuration file test:
- './pihole/etc-pihole:/etc/pihole' - CMD
# 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' - python
- './pihole/etc-dnsmasq.d:/etc/dnsmasq.d' - -c
cap_add: - 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)
# See https://github.com/pi-hole/docker-pi-hole#note-on-capabilities timeout: 10s
# Required if you are using Pi-hole as your DHCP server, else not needed interval: 1m0s
- NET_ADMIN retries: 3
# Required if you are using Pi-hole as your NTP client to be able to set the host's system time start_period: 15s
# - SYS_TIME mem_limit: "268435456"
# Optional, if Pi-hole should get some more processing time mem_reservation: "67108864"
- SYS_NICE networks:
restart: unless-stopped # Recommended but not required (DHCP needs NET_ADMIN) darkproxy:
sysctls: ipv4_address: 10.5.0.11
- "net.ipv6.conf.all.disable_ipv6=0" platform: linux/amd64
healthcheck: restart: unless-stopped
test: ["CMD", "dig", "+short", "+norecurse", "+timeout=2", "@127.0.0.1", "google.com"] security_opt:
interval: 1m - no-new-privileges:true
timeout: 10s sysctls:
retries: 3 net.ipv6.conf.all.disable_ipv6: "0"
start_period: 30s i2pd_yggdrasil:
networks: build:
darkproxy: context: /home/blade/darkproxy/i2pd_yggdrasil_docker/src
ipv4_address: 10.5.0.6 dockerfile: Dockerfile
cap_add:
geth: - NET_ADMIN
image: ethereum/client-go:latest cpus: 4
platform: linux/arm64 container_name: darki2p
container_name: darkgeth devices:
command: - source: /dev/net/tun
- --syncmode=light target: /dev/net/tun
- --http permissions: rwm
- --http.addr=0.0.0.0 environment:
- --http.port=8545 tz: /run/secrets/tz
- --http.api=eth,net,web3 healthcheck:
- --cache=256 test:
networks: - CMD
darkproxy: - bash
ipv4_address: 10.5.0.10 - -lc
restart: unless-stopped - pgrep i2pd >/dev/null && pgrep yggdrasil >/dev/null && [ -s /var/lib/i2pd/addressbook/addresses.csv ]
stop_grace_period: 30s timeout: 10s
healthcheck: interval: 30s
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8545"] retries: 3
interval: 30s start_period: 15m0s
timeout: 10s mem_limit: "2147483648"
retries: 3 mem_reservation: "536870912"
start_period: 120s mac_address: ce:22:b8:0e:6e:78
sysctls: networks:
- "net.ipv6.conf.all.disable_ipv6=0" darkproxy:
ipv4_address: 10.5.0.2
emc: platform: linux/amd64
image: wg00/emercoin:0.8.4 ports:
platform: linux/arm64 - mode: ingress
container_name: darkemer target: 7070
volumes: published: "2001"
- emc_data:/emc protocol: tcp
- /etc/localtime:/etc/localtime:ro - mode: ingress
networks: target: 10765
darkproxy: published: "10765"
ipv4_address: 10.5.0.9 protocol: tcp
restart: unless-stopped - mode: ingress
stop_grace_period: 30s target: 10654
published: "10654"
volumes: protocol: tcp
darkalfis_data: privileged: true
name: darkalfis_data restart: unless-stopped
emc_data: secrets:
name: emc_data - source: tz
tor-data: target: /run/secrets/tz
driver: local sysctls:
net.ipv6.conf.all.disable_ipv6: "0"
networks: volumes:
darkproxy: - type: volume
# name: darkproxy source: i2pd_state
enable_ipv6: true target: /var/lib/i2pd
driver: bridge volume: {}
ipam: - type: volume
config: source: yggdrasil_state
- subnet: 10.5.0.0/16 target: /var/lib/yggdrasil
gateway: 10.5.0.1 volume: {}
- subnet: 2001:0BC5::/112 i2pdns:
gateway: 2001:0BC5::1 build:
context: /home/blade/darkproxy/i2pdns
secrets: dockerfile: Dockerfile
tz: cpus: 0.5
file: ./secrets/tz.txt container_name: darki2pdns
YGGDRASIL_GENERATE_KEYS: environment:
file: ./secrets/YGGDRASIL_GENERATE_KEYS.txt I2PDNS_LISTEN_HOST: 0.0.0.0
I2PDNS_LISTEN_PORT: "53"
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:
darkproxy:
ipv4_address: 10.5.0.16
platform: linux/amd64
restart: unless-stopped
security_opt:
- no-new-privileges:true
sysctls:
net.ipv6.conf.all.disable_ipv6: "0"
volumes:
- type: volume
source: i2p_dns_map
target: /var/lib/i2pdns
volume: {}
lokinet:
build:
context: /home/blade/darkproxy/lokinet
dockerfile: Dockerfile
cap_add:
- NET_ADMIN
- NET_BIND_SERVICE
cpus: 1
container_name: darklokinet
devices:
- 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:
darkproxy:
ipv4_address: 10.5.0.14
platform: linux/amd64
restart: unless-stopped
security_opt:
- no-new-privileges:true
sysctls:
net.ipv6.conf.all.disable_ipv6: "0"
namecoind:
cpus: 2
container_name: darknamecoind
entrypoint:
- /bin/sh
- -ec
- |
exec /usr/local/bin/namecoind \
-server=1 \
-daemon=0 \
-datadir=/data \
-rpcbind=0.0.0.0 \
-rpcallowip=10.5.0.0/16 \
-rpcuser=namecoinrpc \
"-rpcpassword=$$(cat /run/secrets/NAMECOIN_RPC_PASSWORD)" \
-printtoconsole \
-txindex=1 \
-dnsseed=1 \
-addnode=162.212.154.52:8334 \
-addnode=162.210.196.27:8334 \
-addnode=23.108.191.178:8334 \
-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"
networks:
darkproxy:
ipv4_address: 10.5.0.13
platform: linux/amd64
restart: unless-stopped
secrets:
- source: NAMECOIN_RPC_PASSWORD
target: /run/secrets/NAMECOIN_RPC_PASSWORD
security_opt:
- no-new-privileges:true
sysctls:
net.ipv6.conf.all.disable_ipv6: "0"
volumes:
- type: volume
source: namecoin_data
target: /data
volume: {}
namecoindns:
build:
context: /home/blade/darkproxy/namecoindns
dockerfile: Dockerfile
cpus: 0.5
container_name: darknamecoin
depends_on:
namecoind:
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:
darkproxy:
ipv4_address: 10.5.0.12
platform: linux/amd64
restart: unless-stopped
secrets:
- source: NAMECOIN_RPC_PASSWORD
target: /run/secrets/NAMECOIN_RPC_PASSWORD
security_opt:
- no-new-privileges:true
sysctls:
net.ipv6.conf.all.disable_ipv6: "0"
pihole:
cap_add:
- NET_ADMIN
- 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_1: 10.5.0.4
TEMPERATUREUNIT: f
TZ: America/Detroit
WEBPASSWORD_FILE: /run/secrets/PIHOLE_WEBPASSWORD
WEBTHEME: lcars
healthcheck:
test:
- CMD
- dig
- +short
- +norecurse
- +timeout=2
- '@127.0.0.1'
- google.com
timeout: 10s
interval: 1m0s
retries: 3
start_period: 30s
image: pihole/pihole@sha256:712b39f1fdb55121cef509813dfbe02d2bdef9c28e07404fa1d422f5157323b2
mem_limit: "536870912"
mem_reservation: "134217728"
networks:
darkproxy:
ipv4_address: 10.5.0.6
platform: linux/amd64
ports:
- mode: ingress
target: 53
published: "53"
protocol: tcp
- mode: ingress
target: 53
published: "53"
protocol: udp
- mode: ingress
host_ip: 127.0.0.1
target: 80
published: "2003"
protocol: tcp
restart: unless-stopped
secrets:
- source: PIHOLE_WEBPASSWORD
target: /run/secrets/PIHOLE_WEBPASSWORD
security_opt:
- no-new-privileges:true
sysctls:
net.ipv6.conf.all.disable_ipv6: "0"
volumes:
- type: bind
source: /home/blade/darkproxy/pihole/etc-pihole
target: /etc/pihole
bind:
create_host_path: true
- type: bind
source: /home/blade/darkproxy/pihole/etc-dnsmasq.d
target: /etc/dnsmasq.d
bind:
create_host_path: true
- 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
platform: linux/amd64
ports:
- mode: ingress
host_ip: 127.0.0.1
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: /var/run/docker.sock
target: /var/run/docker.sock
read_only: true
bind:
create_host_path: true
tor_yggdrasil:
build:
context: /home/blade/darkproxy/tor_yggdrasil_docker
dockerfile: Dockerfile
cpus: 2
command:
- -f
- /etc/tor/torrc
container_name: darktor
dns:
- 10.5.0.6
entrypoint:
- tor
mem_limit: "1073741824"
mem_reservation: "268435456"
networks:
darkproxy:
ipv4_address: 10.5.0.7
platform: linux/amd64
ports:
- mode: ingress
host_ip: 127.0.0.1
target: 9053
published: "2006"
protocol: udp
restart: unless-stopped
security_opt:
- no-new-privileges:true
sysctls:
net.ipv6.conf.all.disable_ipv6: "0"
volumes:
- type: bind
source: /home/blade/darkproxy/tor_yggdrasil_docker/torrc
target: /etc/tor/torrc
read_only: true
bind:
create_host_path: true
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"
networks:
darkproxy:
ipv4_address: 10.5.0.5
platform: linux/amd64
restart: unless-stopped
security_opt:
- no-new-privileges:true
sysctls:
net.ipv6.conf.all.disable_ipv6: "0"
volumes:
- type: bind
source: /home/blade/darkproxy/unbound/unbound.conf
target: /opt/unbound/etc/unbound/unbound.conf
read_only: true
- type: bind
source: /home/blade/darkproxy/unbound/forward-records.conf
target: /opt/unbound/etc/unbound/forward-records.conf
bind:
create_host_path: true
- type: bind
source: /home/blade/darkproxy/unbound/a-records.conf
target: /opt/unbound/etc/unbound/a-records.conf
bind:
create_host_path: true
- type: bind
source: /home/blade/darkproxy/unbound/srv-records.conf
target: /opt/unbound/etc/unbound/srv-records.conf
bind:
create_host_path: true
zildns:
build:
context: /home/blade/darkproxy/zildns
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:
darkproxy:
ipv4_address: 10.5.0.15
platform: linux/amd64
restart: unless-stopped
security_opt:
- no-new-privileges:true
sysctls:
net.ipv6.conf.all.disable_ipv6: "0"
networks:
darkproxy:
name: darkproxy_darkproxy
driver: bridge
ipam:
config:
- subnet: 10.5.0.0/16
gateway: 10.5.0.1
- subnet: 2001:0BC5::/112
gateway: 2001:0BC5::1
enable_ipv6: true
volumes:
darkalfis_data:
name: darkalfis_data
emc_data:
name: emc_data
i2p_dns_map:
name: i2p_dns_map
i2pd_state:
name: i2pd_state
namecoin_data:
name: namecoin_data
yggdrasil_state:
name: yggdrasil_state
secrets:
NAMECOIN_RPC_PASSWORD:
name: darkproxy_NAMECOIN_RPC_PASSWORD
file: /home/blade/darkproxy/secrets/namecoin_rpc_password.txt
PIHOLE_WEBPASSWORD:
name: darkproxy_PIHOLE_WEBPASSWORD
file: /home/blade/darkproxy/secrets/pihole_webpassword.txt
PROXY_USERS:
name: darkproxy_PROXY_USERS
file: /home/blade/darkproxy/secrets/3proxy_users.txt
tz:
name: darkproxy_tz
file: /home/blade/darkproxy/secrets/tz.txt
+333 -80
View File
@@ -1,31 +1,59 @@
services: services:
dark3proxy: dark3proxy:
container_name: dark3proxy container_name: dark3proxy
image: host60/darkproxy-dark3proxy:v1.0
build: build:
context: ./3proxy/. context: ./3proxy/.
platform: linux/amd64
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
dns: dns:
- "10.5.0.6" - "10.5.0.6"
dns_search: internal.namespace #namespace used in internal DNS dns_search: internal.namespace #namespace used in internal DNS
environment:
YGG_CONNECT_WAIT_SECONDS: "8"
PROXY_REQUIRE_AUTH: "true"
PROXY_USERS_FILE: /run/secrets/PROXY_USERS
ROUTER_REQUIRE_AUTH: "true"
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
security_opt:
- no-new-privileges:true
secrets:
- PROXY_USERS
volumes: volumes:
- "./3proxy/first-instanse.cfg:/etc/3proxy/first-instanse.cfg" - "./3proxy/first-instanse.cfg:/etc/3proxy/first-instanse.cfg"
- "./3proxy/second-instanse.cfg:/etc/3proxy/second-instanse.cfg" - "./3proxy/second-instanse.cfg:/etc/3proxy/second-instanse.cfg"
secrets: - "./3proxy/yggdrasil.conf:/etc/yggdrasil/yggdrasil.conf"
- PROXY_USERS - "i2p_dns_map:/var/lib/i2pdns:ro"
restart: unless-stopped restart: unless-stopped
cpus: "1.0"
mem_reservation: 64m
mem_limit: 256m
healthcheck:
test: ["CMD", "bash", "-lc", "pgrep -f 'yggdrasil -useconffile' >/dev/null && pgrep -f '3proxy /etc/3proxy/second-instanse.cfg' >/dev/null && pgrep -f '3proxy /etc/3proxy/first-instanse.cfg' >/dev/null && pgrep -f 'python3 /usr/local/bin/socks_router.py' >/dev/null"]
interval: 30s
timeout: 10s
retries: 3
start_period: 45s
sysctls: sysctls:
- "net.ipv6.conf.all.disable_ipv6=0" - "net.ipv6.conf.all.disable_ipv6=0"
ports: ports:
- "127.0.0.1:2000:1080" # socks port (localhost only) - "2000:1080" # primary host SOCKS via dark3proxy (externally reachable)
- "127.0.0.1:2002:8161" # 3proxy-eagle web page (localhost only) - "127.0.0.1:2002:8161" # 3proxy admin page (host-local only)
networks: networks:
darkproxy: darkproxy:
ipv4_address: 10.5.0.8 ipv4_address: 10.5.0.8
i2pd_yggdrasil: i2pd_yggdrasil:
container_name: darki2p container_name: darki2p
image: host60/i2pd_yggdrasil:v1.3 build:
# image: i2pd_yggdrasil:latest context: ./i2pd_yggdrasil_docker/src
dockerfile: Dockerfile
platform: linux/amd64
privileged: true privileged: true
cap_add: cap_add:
- NET_ADMIN - NET_ADMIN
@@ -52,13 +80,20 @@ services:
tz: /run/secrets/tz tz: /run/secrets/tz
secrets: secrets:
- tz - tz
volumes:
- i2pd_state:/var/lib/i2pd
- yggdrasil_state:/var/lib/yggdrasil
restart: unless-stopped restart: unless-stopped
cpus: "4.0"
mem_reservation: 512m
mem_limit: 2g
healthcheck: 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 interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
start_period: 60s # Key generation + peer discovery can take several minutes on emulated ARM.
start_period: 15m
sysctls: sysctls:
- "net.ipv6.conf.all.disable_ipv6=0" - "net.ipv6.conf.all.disable_ipv6=0"
mac_address: ce:22:b8:0e:6e:78 mac_address: ce:22:b8:0e:6e:78
@@ -66,26 +101,58 @@ services:
darkproxy: darkproxy:
ipv4_address: 10.5.0.2 ipv4_address: 10.5.0.2
i2pdns:
container_name: darki2pdns
build:
context: ./i2pdns
dockerfile: Dockerfile
platform: linux/amd64
security_opt:
- no-new-privileges:true
restart: unless-stopped
cpus: "0.5"
mem_reservation: 64m
mem_limit: 128m
environment:
I2PDNS_LISTEN_HOST: 0.0.0.0
I2PDNS_LISTEN_PORT: 53
I2PDNS_TTL: 60
I2PDNS_POOL_CIDR: 172.31.0.0/16
I2PDNS_MAP_FILE: /var/lib/i2pdns/map.json
volumes:
- "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
tor_yggdrasil: tor_yggdrasil:
image: host60/tor_yggdrasil:v1.0 build:
# image: tor_yggdrasil:latest context: ./tor_yggdrasil_docker
dockerfile: Dockerfile
platform: linux/amd64
# container_name: tor_over_yggdrasil # container_name: tor_over_yggdrasil
container_name: darktor container_name: darktor
entrypoint: ["tor"]
command: ["-f", "/etc/tor/torrc"]
# platform removed; image is amd64-only # platform removed; image is amd64-only
cap_add:
- NET_ADMIN # Required for network tunnel management
security_opt: security_opt:
- no-new-privileges:true # Security enhancement - prevent privilege esca> - no-new-privileges:true # Security enhancement - prevent privilege esca>
devices: volumes:
- /dev/net/tun:/dev/net/tun # TUN device for Yggdrasil network interface - "./tor_yggdrasil_docker/torrc:/etc/tor/torrc:ro"
ports: ports:
# - "127.0.0.1:9050:9050/tcp" # SOCKS5 proxy port (Tor) for application-l> - "127.0.0.1:2006:9053/udp" # direct Tor DNSPort
- "10655:10655/tcp" # Yggdrasil peer connections
secrets:
- YGGDRASIL_GENERATE_KEYS
environment:
YGGDRASIL_GENERATE_KEYS: /run/secrets/YGGDRASIL_GENERATE_KEYS # Generate new Yggdrasil keys on startup
restart: unless-stopped # Auto-restart unless manually stopped restart: unless-stopped # Auto-restart unless manually stopped
cpus: "2.0"
mem_reservation: 256m
mem_limit: 1g
dns: dns:
- "10.5.0.6" - "10.5.0.6"
sysctls: sysctls:
@@ -95,9 +162,12 @@ services:
ipv4_address: 10.5.0.7 ipv4_address: 10.5.0.7
alfis: alfis:
image: cofob/alfis image: cofob/alfis@sha256:0c5788b1e409557bb814dc2d055d584db8c48b3d42b375bf13feb59583af0585
platform: linux/amd64
# platform removed; image is amd64 only # platform removed; image is amd64 only
container_name: darkalfis container_name: darkalfis
security_opt:
- no-new-privileges:true
volumes: volumes:
- darkalfis_data:/storage - darkalfis_data:/storage
restart: unless-stopped restart: unless-stopped
@@ -111,13 +181,17 @@ services:
ipv4_address: 10.5.0.3 ipv4_address: 10.5.0.3
coredns: coredns:
# image: darkdns
image: host60/darkdns:v1.0
build: build:
context: ./PopuraDNS context: ./PopuraDNS
dockerfile: Dockerfile dockerfile: Dockerfile
platform: linux/amd64
container_name: darkdns container_name: darkdns
security_opt:
- no-new-privileges:true
restart: unless-stopped restart: unless-stopped
cpus: "1.0"
mem_reservation: 64m
mem_limit: 256m
sysctls: sysctls:
- "net.ipv6.conf.all.disable_ipv6=0" - "net.ipv6.conf.all.disable_ipv6=0"
logging: logging:
@@ -131,8 +205,11 @@ services:
unbound: unbound:
container_name: darkunbound container_name: darkunbound
image: mvance/unbound:latest image: mvance/unbound@sha256:76906da36d1806f3387338f15dcf8b357c51ce6897fb6450d6ce010460927e90
platform: linux/amd64
# platform removed; image is amd64-only # platform removed; image is amd64-only
security_opt:
- no-new-privileges:true
sysctls: sysctls:
- "net.ipv6.conf.all.disable_ipv6=0" - "net.ipv6.conf.all.disable_ipv6=0"
networks: networks:
@@ -145,22 +222,35 @@ services:
target: /opt/unbound/etc/unbound/unbound.conf target: /opt/unbound/etc/unbound/unbound.conf
- "./unbound/forward-records.conf:/opt/unbound/etc/unbound/forward-records.conf" - "./unbound/forward-records.conf:/opt/unbound/etc/unbound/forward-records.conf"
- "./unbound/a-records.conf:/opt/unbound/etc/unbound/a-records.conf" - "./unbound/a-records.conf:/opt/unbound/etc/unbound/a-records.conf"
- "./unbound/srv-records.conf:/opt/unbound/etc/unbound/srv-records.conf"
# ports: # ports:
# - "5053:5053/tcp" # - "5053:5053/tcp"
# - "5053:5053/udp" # - "5053:5053/udp"
# healthcheck: # healthcheck:
# disable: true # disable: true
restart: unless-stopped restart: unless-stopped
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: pihole:
container_name: darkpihole container_name: darkpihole
image: pihole/pihole:latest image: pihole/pihole@sha256:712b39f1fdb55121cef509813dfbe02d2bdef9c28e07404fa1d422f5157323b2
platform: linux/arm64 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" # For DHCP it is recommended to remove these ports and instead add: network_mode: "host"
ports: ports:
# DNS Ports # DNS Ports
# - "53:53/tcp" - "53:53/tcp"
# - "53:53/udp" - "53:53/udp"
# Default HTTP Port # Default HTTP Port
# - "80:80/tcp" # - "80:80/tcp"
# Default HTTPs Port. FTL will generate a self-signed certificate # Default HTTPs Port. FTL will generate a self-signed certificate
@@ -169,19 +259,21 @@ services:
# - "67:67/udp" # - "67:67/udp"
# Uncomment the line below if you are using Pi-hole as your NTP server # Uncomment the line below if you are using Pi-hole as your NTP server
# - "123:123/udp" # - "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: environment:
# Set the appropriate timezone for your location (https://en.wikipedia.org/wiki/List_of_tz_database_time_zones), e.g: # Set the appropriate timezone for your location (https://en.wikipedia.org/wiki/List_of_tz_database_time_zones), e.g:
TZ: 'America/Detroit' TZ: 'America/Detroit'
# WEBPASSWORD: 'set a secure password here or it will be random' # WEBPASSWORD: 'set a secure password here or it will be random'
# FTLCONF_webserver_api_password: 'darkproxy' # FTLCONF_webserver_api_password: 'darkproxy'
# If using Docker's default `bridge` network setting the dns listening mode should be set to 'all' # If using Docker's default `bridge` network setting the dns listening mode should be set to 'all'
# FTLCONF_dns_listeningMode: 'all' FTLCONF_dns_listeningMode: 'all'
PIHOLE_DNS_: 10.5.0.4 PIHOLE_DNS_1: 10.5.0.4
TEMPERATUREUNIT: f TEMPERATUREUNIT: f
WEBTHEME: lcars WEBTHEME: lcars
WEBPASSWORD_FILE: /run/secrets/PIHOLE_WEBPASSWORD WEBPASSWORD_FILE: /run/secrets/PIHOLE_WEBPASSWORD
FTLCONF_dns_upstreams: coredns # Pin upstream to IPv4 CoreDNS to avoid noisy IPv6 recursion-refused warnings.
FTLCONF_dns_upstreams: 10.5.0.4#53
FTLCONF_database_network_parseARPcache: 'false'
secrets: secrets:
- PIHOLE_WEBPASSWORD - PIHOLE_WEBPASSWORD
# Volumes store your data between container upgrades # Volumes store your data between container upgrades
@@ -190,6 +282,7 @@ services:
- './pihole/etc-pihole:/etc/pihole' - './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' # 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' - './pihole/etc-dnsmasq.d:/etc/dnsmasq.d'
- './scripts/pihole-entrypoint.sh:/usr/local/bin/darkproxy-pihole-entrypoint.sh:ro'
cap_add: cap_add:
# See https://github.com/pi-hole/docker-pi-hole#note-on-capabilities # 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 # Required if you are using Pi-hole as your DHCP server, else not needed
@@ -199,6 +292,9 @@ services:
# Optional, if Pi-hole should get some more processing time # Optional, if Pi-hole should get some more processing time
- SYS_NICE - SYS_NICE
restart: unless-stopped # Recommended but not required (DHCP needs NET_ADMIN) restart: unless-stopped # Recommended but not required (DHCP needs NET_ADMIN)
cpus: "1.5"
mem_reservation: 128m
mem_limit: 512m
sysctls: sysctls:
- "net.ipv6.conf.all.disable_ipv6=0" - "net.ipv6.conf.all.disable_ipv6=0"
healthcheck: healthcheck:
@@ -212,49 +308,161 @@ services:
ipv4_address: 10.5.0.6 ipv4_address: 10.5.0.6
emc: emc:
image: wg00/emercoin:0.8.4 image: wg00/emercoin:0.8.4@sha256:b890987fb4b158305040dc76b32cd24ed5173dd7bc4aba983141ea0fe82e2996
platform: linux/arm64 platform: linux/amd64
container_name: darkemer container_name: darkemer
security_opt:
- no-new-privileges:true
volumes: volumes:
- emc_data:/emc - emc_data:/emc/data
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
command:
- -datadir=/emc/data
- -conf=/emc/emercoin.conf
- -printtoconsole
networks: networks:
darkproxy: darkproxy:
ipv4_address: 10.5.0.9 ipv4_address: 10.5.0.9
restart: unless-stopped restart: unless-stopped
cpus: "1.0"
mem_reservation: 256m
mem_limit: 1g
stop_grace_period: 30s stop_grace_period: 30s
ensdns: ensdns:
build: build:
context: ./ensdns context: ./ensdns
dockerfile: Dockerfile dockerfile: Dockerfile
platform: linux/amd64
container_name: darkens container_name: darkens
security_opt:
- no-new-privileges:true
restart: unless-stopped restart: unless-stopped
cpus: "0.5"
mem_reservation: 64m
mem_limit: 256m
environment: environment:
ENS_RPC_URL: https://ethereum-rpc.publicnode.com ENS_RPC_URL: https://ethereum-rpc.publicnode.com
ENSDNS_TTL: 60 ENSDNS_TTL: 60
dns:
- "10.5.0.4"
sysctls: sysctls:
- "net.ipv6.conf.all.disable_ipv6=0" - "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: networks:
darkproxy: darkproxy:
ipv4_address: 10.5.0.11 ipv4_address: 10.5.0.11
namecoind: zildns:
image: ukd1/namecoind:latest build:
container_name: darknamecoind context: ./zildns
dockerfile: Dockerfile
platform: linux/amd64
container_name: darkzil
security_opt:
- no-new-privileges:true
restart: unless-stopped restart: unless-stopped
command: cpus: "0.5"
- "-server=1" mem_reservation: 64m
- "-rpcbind=0.0.0.0" mem_limit: 256m
- "-rpcallowip=10.5.0.0/16" environment:
- "-rpcuser=namecoinrpc" ZILDNS_ZNS_URL: https://api.zilliqa.com
- "-rpcpassword=CHANGE_ME_NAMECOIN_RPC_PASSWORD" ZILDNS_ZNS_NETWORK: mainnet
- "-printtoconsole" ZILDNS_TTL: 120
- "-txindex=1" ZILDNS_CACHE_SECONDS: 300
volumes: ZILDNS_PREWARM_DOMAIN: brad.zil
- namecoin_data:/namecoin dns:
- "1.1.1.1"
- "8.8.8.8"
sysctls: sysctls:
- "net.ipv6.conf.all.disable_ipv6=0" - "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
lokinet:
build:
context: ./lokinet
dockerfile: Dockerfile
platform: linux/amd64
container_name: darklokinet
security_opt:
- no-new-privileges:true
restart: unless-stopped
cpus: "1.0"
mem_reservation: 64m
mem_limit: 256m
cap_add:
- NET_ADMIN
- NET_BIND_SERVICE
devices:
- /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
namecoind:
image: ukd1/namecoind@sha256:6fdf63f61f687ee639893a8cdcd910bb24c5eb3a3493ff72142c911d7019200b
platform: linux/amd64
container_name: darknamecoind
entrypoint:
- /bin/sh
- -ec
- |
exec /usr/local/bin/namecoind \
-server=1 \
-daemon=0 \
-datadir=/data \
-rpcbind=0.0.0.0 \
-rpcallowip=10.5.0.0/16 \
-rpcuser=namecoinrpc \
"-rpcpassword=$(cat /run/secrets/NAMECOIN_RPC_PASSWORD)" \
-printtoconsole \
-txindex=1 \
-dnsseed=1 \
-addnode=162.212.154.52:8334 \
-addnode=162.210.196.27:8334 \
-addnode=23.108.191.178:8334 \
-addnode=3.228.193.128:8334 \
-addnode=3.66.245.44:8334 \
-addnode=212.51.144.42:8334
security_opt:
- no-new-privileges:true
restart: unless-stopped
cpus: "2.0"
mem_reservation: 512m
mem_limit: 2g
secrets:
- NAMECOIN_RPC_PASSWORD
volumes:
- 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: networks:
darkproxy: darkproxy:
ipv4_address: 10.5.0.13 ipv4_address: 10.5.0.13
@@ -263,17 +471,32 @@ services:
build: build:
context: ./namecoindns context: ./namecoindns
dockerfile: Dockerfile dockerfile: Dockerfile
platform: linux/amd64
container_name: darknamecoin container_name: darknamecoin
security_opt:
- no-new-privileges:true
restart: unless-stopped restart: unless-stopped
cpus: "0.5"
mem_reservation: 64m
mem_limit: 256m
depends_on: depends_on:
- namecoind namecoind:
condition: service_healthy
environment: environment:
NAMECOIN_RPC_URL: http://darknamecoind:8336/ NAMECOIN_RPC_URL: http://darknamecoind:8336/
NAMECOIN_RPC_USER: namecoinrpc NAMECOIN_RPC_USER: namecoinrpc
NAMECOIN_RPC_PASSWORD: CHANGE_ME_NAMECOIN_RPC_PASSWORD NAMECOIN_RPC_PASSWORD_FILE: /run/secrets/NAMECOIN_RPC_PASSWORD
NAMECOIN_RPC_TIMEOUT: 8 NAMECOIN_RPC_TIMEOUT: 8
secrets:
- NAMECOIN_RPC_PASSWORD
sysctls: sysctls:
- "net.ipv6.conf.all.disable_ipv6=0" - "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: networks:
darkproxy: darkproxy:
ipv4_address: 10.5.0.12 ipv4_address: 10.5.0.12
@@ -282,16 +505,19 @@ services:
build: build:
context: ./tailscale context: ./tailscale
dockerfile: Dockerfile dockerfile: Dockerfile
platform: linux/amd64
profiles: ["tailscale"]
container_name: darkscale container_name: darkscale
hostname: darkproxy-exit hostname: darkproxy-exit
security_opt:
- no-new-privileges:true
cap_add: cap_add:
- NET_ADMIN - NET_ADMIN
- SYS_MODULE - SYS_MODULE
devices: devices:
- /dev/net/tun:/dev/net/tun - /dev/net/tun:/dev/net/tun
environment: environment:
# ⚠️ Get your own auth key from https://login.tailscale.com/admin/settings/keys - TS_AUTHKEY=${TS_AUTHKEY:-}
- TS_AUTHKEY=${TS_AUTHKEY:-tskey-YOUR-AUTH-KEY-HERE}
- TS_EXTRA_ARGS=--accept-dns=false --advertise-exit-node - TS_EXTRA_ARGS=--accept-dns=false --advertise-exit-node
- TS_STATE_DIR=/var/lib/tailscale - TS_STATE_DIR=/var/lib/tailscale
volumes: volumes:
@@ -309,37 +535,65 @@ services:
container_name: dark3proxy-exporter container_name: dark3proxy-exporter
build: build:
context: ./monitor context: ./monitor
image: darkproxy-3proxy-exporter:latest image: darkproxy-3proxy-exporter:local
platform: linux/amd64
security_opt:
- no-new-privileges:true
depends_on: depends_on:
- dark3proxy dark3proxy:
condition: service_healthy
networks: networks:
- darkproxy darkproxy:
ipv4_address: 10.5.0.20
prometheus: status_dashboard:
image: prom/prometheus:latest container_name: darkstatus
container_name: darkprom build:
volumes: context: ./monitor
- ./monitor/prometheus.yml:/etc/prometheus/prometheus.yml:ro dockerfile: status.Dockerfile
ports: image: darkproxy-status-dashboard:local
- "127.0.0.1:9090:9090" platform: linux/amd64
networks: security_opt:
- darkproxy - no-new-privileges:true
depends_on:
grafana: dark3proxy:
image: grafana/grafana:latest condition: service_healthy
container_name: darkgraf pihole:
ports: condition: service_healthy
- "127.0.0.1:2005:3000"
environment: environment:
- GF_SECURITY_ADMIN_PASSWORD__FILE=/run/secrets/GRAFANA_ADMIN_PASSWORD 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: secrets:
- GRAFANA_ADMIN_PASSWORD - PROXY_USERS
volumes: volumes:
- grafana-data:/var/lib/grafana - /var/run/docker.sock:/var/run/docker.sock:ro
ports:
- "${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: networks:
- darkproxy darkproxy:
ipv4_address: 10.5.0.21
restart: unless-stopped
volumes: volumes:
i2pd_state:
name: i2pd_state
yggdrasil_state:
name: yggdrasil_state
darkalfis_data: darkalfis_data:
name: darkalfis_data name: darkalfis_data
emc_data: emc_data:
@@ -350,8 +604,8 @@ volumes:
driver: local driver: local
tailscale_data: tailscale_data:
name: tailscale_data name: tailscale_data
grafana-data: i2p_dns_map:
name: grafana-data name: i2p_dns_map
networks: networks:
darkproxy: darkproxy:
@@ -372,8 +626,7 @@ secrets:
file: ./secrets/YGGDRASIL_GENERATE_KEYS.txt file: ./secrets/YGGDRASIL_GENERATE_KEYS.txt
PIHOLE_WEBPASSWORD: PIHOLE_WEBPASSWORD:
file: ./secrets/pihole_webpassword.txt file: ./secrets/pihole_webpassword.txt
GRAFANA_ADMIN_PASSWORD:
file: ./secrets/grafana_admin_password.txt
PROXY_USERS: PROXY_USERS:
file: ./secrets/3proxy_users.txt file: ./secrets/3proxy_users.txt
NAMECOIN_RPC_PASSWORD:
file: ./secrets/namecoin_rpc_password.txt
+61 -8
View File
@@ -2,6 +2,7 @@
import os import os
import socket import socket
import threading import threading
import time
from dnslib import A, AAAA, CNAME, QTYPE, RR, TXT, DNSHeader, DNSRecord, RCODE from dnslib import A, AAAA, CNAME, QTYPE, RR, TXT, DNSHeader, DNSRecord, RCODE
from web3 import HTTPProvider, Web3 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_HOST = os.getenv("ENSDNS_LISTEN_HOST", "0.0.0.0")
LISTEN_PORT = int(os.getenv("ENSDNS_LISTEN_PORT", "53")) LISTEN_PORT = int(os.getenv("ENSDNS_LISTEN_PORT", "53"))
DEFAULT_TTL = int(os.getenv("ENSDNS_TTL", "60")) 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") ENS_REGISTRY_ADDRESS = Web3.to_checksum_address("0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e")
@@ -44,6 +46,10 @@ PUBLIC_RESOLVER_ABI = [
EMPTY_ADDRESS = "0x0000000000000000000000000000000000000000" EMPTY_ADDRESS = "0x0000000000000000000000000000000000000000"
class ENSBackendUnavailable(RuntimeError):
pass
def namehash(name: str) -> bytes: def namehash(name: str) -> bytes:
node = b"\x00" * 32 node = b"\x00" * 32
labels = [label for label in name.strip().lower().split(".") if label] labels = [label for label in name.strip().lower().split(".") if label]
@@ -59,18 +65,58 @@ def normalize_qname(qname: str) -> str:
class ENSResolver: class ENSResolver:
def __init__(self, rpc_url: str): def __init__(self, rpc_url: str):
self.web3 = Web3(HTTPProvider(rpc_url, request_kwargs={"timeout": 10})) self.rpc_url = rpc_url
if not self.web3.is_connected(): self._lock = threading.Lock()
raise RuntimeError(f"Could not connect to Ethereum RPC: {rpc_url}") self._web3 = None
self.registry = self.web3.eth.contract(address=ENS_REGISTRY_ADDRESS, abi=ENS_REGISTRY_ABI) 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): def resolve(self, name: str):
web3, registry = self._ensure_backend()
node = namehash(name) 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: if not resolver_addr or resolver_addr == EMPTY_ADDRESS:
return None 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 addr = None
contenthash = None contenthash = None
@@ -105,7 +151,13 @@ def answer_query(record: DNSRecord, ens: ENSResolver) -> DNSRecord:
reply.header.rcode = RCODE.NXDOMAIN reply.header.rcode = RCODE.NXDOMAIN
return reply 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: if resolved is None:
reply.header.rcode = RCODE.NXDOMAIN reply.header.rcode = RCODE.NXDOMAIN
return reply return reply
@@ -163,8 +215,9 @@ def serve_tcp(sock: socket.socket, ens: ENSResolver):
def main(): def main():
ens = ENSResolver(RPC_URL) 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) 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 = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
udp_sock.bind((LISTEN_HOST, LISTEN_PORT)) udp_sock.bind((LISTEN_HOST, LISTEN_PORT))
+148
View File
@@ -0,0 +1,148 @@
# syntax=docker/dockerfile:1
ARG UBUNTU_VERSION=24.04
# I2P build stage
# Build on target platform so produced binaries match runtime architecture.
FROM --platform=$TARGETPLATFORM ubuntu:${UBUNTU_VERSION} AS builder_i2pd
ARG I2PD_VERSION=2.58.0
ARG I2PD_COMPILER=gcc
ARG BUILDPLATFORM
ARG TARGETPLATFORM
ARG TARGETARCH
RUN DEBIAN_FRONTEND=noninteractive apt-get update && \
apt-get -y upgrade && \
apt-get install -y \
$I2PD_COMPILER \
git make cmake debhelper \
libboost-date-time-dev \
libboost-filesystem-dev \
libboost-program-options-dev \
libboost-system-dev \
libssl-dev \
zlib1g-dev \
libminiupnpc-dev && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
WORKDIR /BUILD_I2PD/
RUN git config --global advice.detachedHead false && \
git clone --depth 1 --branch $I2PD_VERSION https://github.com/PurpleI2P/i2pd.git
WORKDIR /BUILD_I2PD/i2pd/build
RUN cmake -DCMAKE_BUILD_TYPE=Release -DWITH_AESNI=${AESNI_SUPPORT:-OFF} -DWITH_UPNP=ON . && \
make -j$(nproc)
# Yggdrasil build stage
# Build on target platform so produced binaries match runtime architecture.
FROM --platform=$TARGETPLATFORM ubuntu:${UBUNTU_VERSION} AS builder_yggdrasil
ARG YGGDRASIL_VERSION=v0.5.12
ARG BUILDPLATFORM
ARG TARGETPLATFORM
ARG TARGETARCH
RUN DEBIAN_FRONTEND=noninteractive apt-get update && \
apt-get -y upgrade && \
apt-get install -y git golang && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
WORKDIR /BUILD_YGGDRASIL/
RUN git config --global advice.detachedHead false && \
git clone --depth 1 --branch $YGGDRASIL_VERSION https://github.com/yggdrasil-network/yggdrasil-go.git
ENV CGO_ENABLED=0
WORKDIR /BUILD_YGGDRASIL/yggdrasil-go
# Add genkeys to build and build binaries
RUN sed -i 's/yggdrasil yggdrasilctl/yggdrasil yggdrasilctl genkeys/g' build && \
./build
# Utility scripts for enhanced Yggdrasil functionality
RUN git clone --depth 1 --branch v0 https://github.com/oldnick85/yggdrasil_get_keys.git /UTILS/yggdrasil_get_keys && \
git clone --depth 1 --branch v4 https://github.com/oldnick85/yggdrasil_find_public_peers.git /UTILS/yggdrasil_find_public_peers && \
rm -rf /UTILS/yggdrasil_get_keys/.git /UTILS/yggdrasil_find_public_peers/.git
# Final runtime image
FROM ubuntu:${UBUNTU_VERSION}
ARG TARGETARCH
RUN DEBIAN_FRONTEND=noninteractive apt-get update && \
apt-get -y upgrade && \
apt-get install -y \
libboost-date-time-dev \
libboost-filesystem-dev \
libboost-program-options-dev \
libboost-system-dev \
libssl3 \
zlib1g \
libminiupnpc17 \
git \
python3 \
python3-pip \
iputils-ping \
ca-certificates && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* && \
update-ca-certificates
# ==== UTILS ====
WORKDIR /UTILS/
COPY --from=builder_yggdrasil /UTILS/ /UTILS/
# Install Python dependencies securely
RUN python3 -m pip install --no-cache-dir --break-system-packages \
-r /UTILS/yggdrasil_get_keys/requirements.txt \
-r /UTILS/yggdrasil_find_public_peers/requirements.txt
# Cache public peers during build for fallback
RUN python3 /UTILS/yggdrasil_find_public_peers/yggdrasil_find_public_peers.py \
--yggdrasil-conf="" \
--yggdrasil-peers-json="/UTILS/yggdrasil_find_public_peers/public_peers.json" || true
# ==== I2P CONFIGURATION ====
# I2P Service Ports
# Web console
EXPOSE 7070
# HTTP proxy
EXPOSE 4444
# SOCKS proxy
EXPOSE 4447
# SAM bridge
EXPOSE 7656
# BOB bridge
EXPOSE 2827
# I2CP
EXPOSE 7654
# I2PControl
EXPOSE 7650
# Main listener
EXPOSE 10765
WORKDIR /I2PD/
COPY --from=builder_i2pd /BUILD_I2PD/i2pd/build/i2pd .
COPY --from=builder_i2pd /BUILD_I2PD/i2pd/contrib/certificates ./certificates
COPY ./i2pd.conf .
# Increase file descriptor limit for better performance
RUN ulimit -n 4096
# ==== YGGDRASIL CONFIGURATION ====
# Yggdrasil listener
EXPOSE 10654
WORKDIR /YGGDRASIL/
COPY --from=builder_yggdrasil /BUILD_YGGDRASIL/yggdrasil-go/yggdrasil .
COPY --from=builder_yggdrasil /BUILD_YGGDRASIL/yggdrasil-go/yggdrasilctl .
COPY --from=builder_yggdrasil /BUILD_YGGDRASIL/yggdrasil-go/genkeys .
COPY ./yggdrasil.conf .
# ==== FINAL SETUP ====
WORKDIR /
COPY ./entrypoint.sh .
RUN chmod +x /entrypoint.sh
# Health check to ensure services are running
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD bash -c 'pgrep i2pd && pgrep yggdrasil'
CMD ["bash", "/entrypoint.sh"]
+65
View File
@@ -0,0 +1,65 @@
#!/bin/bash
set -e # Exit on any error
echo "Starting I2P over Yggdrasil setup..."
# Function to handle graceful shutdown
cleanup() {
echo "Received shutdown signal, stopping services..."
kill -TERM $i2pd_pid $yggdrasil_pid 2>/dev/null
wait
echo "Services stopped gracefully."
exit 0
}
trap cleanup SIGTERM SIGINT
# Generate strong Yggdrasil address (https://yggdrasil-network.github.io/configuration.html#generating-stronger-addresses-and-prefixes)
echo "Generating Yggdrasil keys..."
python3 /UTILS/yggdrasil_get_keys/yggdrasil_get_keys.py \
--genkeys="/YGGDRASIL/genkeys" \
--yggdrasil-conf="/YGGDRASIL/yggdrasil.conf" \
--timeout=60 \
--environment
# Find and configure public Yggdrasil peers
echo "Discovering Yggdrasil public peers..."
python3 /UTILS/yggdrasil_find_public_peers/yggdrasil_find_public_peers.py \
--yggdrasil-conf="/YGGDRASIL/yggdrasil.conf" \
--yggdrasil-peers-json="/UTILS/yggdrasil_find_public_peers/public_peers.json" \
--parallel=4 \
--pings=10 \
--best=6 \
--max-from-country=2 \
--ping-interval=0.5
# Enable IPv6 (required for Yggdrasil)
echo "Enabling IPv6..."
sysctl net.ipv6.conf.all.disable_ipv6=0 || true
# Start Yggdrasil in background
echo "Starting Yggdrasil..."
/YGGDRASIL/yggdrasil -useconffile /YGGDRASIL/yggdrasil.conf &
yggdrasil_pid=$!
# Wait for Yggdrasil to establish connections
echo "Waiting for Yggdrasil network connectivity (60 seconds)..."
sleep 60
# Start I2P in background
echo "Starting I2P..."
/I2PD/i2pd --datadir /I2PD --conf /I2PD/i2pd.conf &
i2pd_pid=$!
echo "Both services started successfully!"
echo "I2P Web Console: http://localhost:7070"
echo "Yggdrasil status: check container logs"
# Wait for any process to exit and handle restart if needed
wait -n
# If we reach here, one process died
echo "One of the services stopped unexpectedly, shutting down..."
kill -TERM $i2pd_pid $yggdrasil_pid 2>/dev/null
wait
exit 1
+300
View File
@@ -0,0 +1,300 @@
## Configuration file for a typical i2pd user
## See https://i2pd.readthedocs.io/en/latest/user-guide/configuration/
## for more options you can use in this file.
## Lines that begin with "## " try to explain what's going on. Lines
## that begin with just "#" are disabled commands: you can enable them
## by removing the "#" symbol.
## Tunnels config file
## Default: ~/.i2pd/tunnels.conf or /var/lib/i2pd/tunnels.conf
# tunconf = /var/lib/i2pd/tunnels.conf
## Tunnels config files path
## Use that path to store separated tunnels in different config files.
## Default: ~/.i2pd/tunnels.d or /var/lib/i2pd/tunnels.d
# tunnelsdir = /var/lib/i2pd/tunnels.d
## Path to certificates used for verifying .su3, families
## Default: ~/.i2pd/certificates or /var/lib/i2pd/certificates
# certsdir = /var/lib/i2pd/certificates
## Where to write pidfile (default: i2pd.pid, not used in Windows)
# pidfile = /run/i2pd.pid
## Logging configuration section
## By default logs go to stdout with level 'info' and higher
## For Windows OS by default logs go to file with level 'warn' and higher
##
## Logs destination (valid values: stdout, file, syslog)
## * stdout - print log entries to stdout
## * file - log entries to a file
## * syslog - use syslog, see man 3 syslog
# log = file
## Path to logfile (default - autodetect)
# logfile = /var/log/i2pd/i2pd.log
## Log messages above this level (debug, info, *warn, error, none)
## If you set it to none, logging will be disabled
loglevel = info # show informational messages (including SOCKS startup)
## Write full CLF-formatted date and time to log (default: write only time)
# logclftime = true
## Daemon mode. Router will go to background after start. Ignored on Windows
# daemon = true
## Specify a family, router belongs to (default - none)
# family =
## Network interface to bind to
## Updates address4/6 options if they are not set
# ifname =
## You can specify different interfaces for IPv4 and IPv6
# ifname4 =
# ifname6 =
## Local address to bind transport sockets to
## Overrides host option if:
## For ipv4: if ipv4 = true and nat = false
## For ipv6: if 'host' is not set or ipv4 = true
# address4 =
# address6 =
## External IPv4 or IPv6 address to listen for connections
## By default i2pd sets IP automatically
## Sets published NTCP2v4/SSUv4 address to 'host' value if nat = true
## Sets published NTCP2v6/SSUv6 address to 'host' value if ipv4 = false
# host = 1.2.3.4
## Port to listen for connections
## By default i2pd picks random port. You MUST pick a random number too,
## don't just uncomment this
port = 10765
## Enable communication through ipv4
# IPv4 networking is required for I2P router connectivity and reseeding.
# Earlier we disabled it to restrict traffic to Yggdrasil only, but that
# prevented the router from ever contacting peers or downloading its
# initial NetDB. Enable at least for bootstrap; once the router has
# peers it will happily tunnel them over Yggdrasil if configured.
ipv4 = true
## Enable communication through ipv6
# This MUST be true so i2pd can talk over the Yggdrasil mesh without it
# Yggdrasil networking is broken, and the container will not be able to
# reach the I2P network at all.
ipv6 = true
## Enable SSU transport (default = true)
ssu = false
## Bandwidth configuration
## L limit bandwidth to 32KBs/sec, O - to 256KBs/sec, P - to 2048KBs/sec,
## X - unlimited
## Default is L (regular node) and X if floodfill mode enabled. If you want to
## share more bandwidth without floodfill mode, uncomment that line and adjust
## value to your possibilities
# bandwidth = L
## Max % of bandwidth limit for transit. 0-100. 100 by default
# share = 100
## Router will not accept transit tunnels, disabling transit traffic completely
## (default = false)
# notransit = true
## Router will be floodfill
## Note: that mode uses much more network connections and CPU!
floodfill = true
[ntcp2]
## Enable NTCP2 transport (default = true)
# enabled = true
## Publish address in RouterInfo (default = true)
# published = true
## Port for incoming connections (default is global port option value)
# port = 4567
[ssu2]
## Enable SSU2 transport (default = false for 2.43.0)
enabled = true
## Publish address in RouterInfo (default = false for 2.43.0)
published = true
## Port for incoming connections (default is global port option value or port + 1 if SSU is enabled)
# port = 4567
[http]
## Web Console settings
## Uncomment and set to 'false' to disable Web Console
# enabled = true
## Address and port service will listen on
address = 0.0.0.0
port = 7070
## Path to web console, default "/"
# webroot = /
## Uncomment following lines to enable Web Console authentication
# auth = true
# user = i2pd
# pass = changeme
## Select webconsole language
## Currently supported english (default), afrikaans, armenian, chinese, french,
## german, russian, turkmen, ukrainian and uzbek languages
# lang = english
hostname = localhost
#strictheaders = false
[httpproxy]
## Uncomment and set to 'false' to disable HTTP Proxy
# enabled = true
## Address and port service will listen on
address = 0.0.0.0
port = 4444
## Optional keys file for proxy local destination
# keys = http-proxy-keys.dat
## Enable address helper for adding .i2p domains with "jump URLs" (default: true)
# addresshelper = true
## Address of a proxy server inside I2P, which is used to visit regular Internet
# outproxy = http://false.i2p
## httpproxy section also accepts I2CP parameters, like "inbound.length" etc.
# [dns] section removed because the I2PD version used in this image
# does not support a built-in DNS server. Name resolution for .i2p domains
# is performed internally by the SOCKS proxy, so the external DNS listener
# is unnecessary. If you upgrade to a future I2PD release that adds this
# feature, you can restore the section.
#
# [dns]
# ## enable DNS server for resolving *.i2p names
# enabled = true
# address = 0.0.0.0
# port = 53
[socksproxy]
## Uncomment and set to 'false' to disable SOCKS Proxy
enabled = true
## Address and port service will listen on
address = 0.0.0.0
port = 4447
## Optional keys file for proxy local destination
# keys = socks-proxy-keys.dat
## Socks outproxy. Example below is set to use Tor for all connections except i2p
## Uncomment and set to 'true' to enable using of SOCKS outproxy
# outproxy.enabled = false
## Address and port of outproxy
# outproxy = 127.0.0.1
# outproxyport = 9050
## socksproxy section also accepts I2CP parameters, like "inbound.length" etc.
[sam]
## Comment or set to 'false' to disable SAM Bridge
enabled = true
## Address and port service will listen on
address = 0.0.0.0
port = 7656
[bob]
## Uncomment and set to 'true' to enable BOB command channel
# enabled = false
## Address and port service will listen on
# address = 127.0.0.1
# port = 2827
[i2cp]
## Uncomment and set to 'true' to enable I2CP protocol
enabled = true
## Address and port service will listen on
address = 0.0.0.0
port = 7654
[i2pcontrol]
## Uncomment and set to 'true' to enable I2PControl protocol
# enabled = false
## Address and port service will listen on
# address = 127.0.0.1
# port = 7650
## Authentication password. "itoopie" by default
# password = itoopie
[precomputation]
## Enable or disable elgamal precomputation table
## By default, enabled on i386 hosts
# elgamal = true
[upnp]
## Enable or disable UPnP: automatic port forwarding (enabled by default in WINDOWS, ANDROID)
# enabled = false
## Name i2pd appears in UPnP forwardings list (default = I2Pd)
# name = I2Pd
[meshnets]
## Enable connectivity over the Yggdrasil network
yggdrasil = true
## You can bind address from your Yggdrasil subnet 300::/64
## The address must first be added to the network interface
# yggaddress =
[reseed]
## Options for bootstrapping into I2P network, aka reseeding
## Enable or disable reseed data verification.
verify = true
## URLs to request reseed data from, separated by comma
## Default: "mainline" I2P Network reseeds
# urls = https://reseed.i2p-projekt.de/,https://i2p.mooo.com/netDb/,https://netdb.i2p2.no/
## Reseed URLs through the Yggdrasil, separated by comma
# yggurls = http://[324:9de3:fea4:f6ac::ace]:7070/
## Path to local reseed data file (.su3) for manual reseeding
# file = /path/to/i2pseeds.su3
## or HTTPS URL to reseed from
# file = https://legit-website.com/i2pseeds.su3
## Path to local ZIP file or HTTPS URL to reseed from
# zipfile = /path/to/netDb.zip
## If you run i2pd behind a proxy server, set proxy server for reseeding here
## Should be http://address:port or socks://address:port
# proxy = http://127.0.0.1:8118
## Minimum number of known routers, below which i2pd triggers reseeding. 25 by default
# threshold = 25
[addressbook]
## AddressBook subscription URL for initial setup
## Default: reg.i2p at "mainline" I2P Network
# defaulturl = http://shx5vqsw7usdaunyzr2qmes2fq37oumybpudrd4jjj4e4vk4uusa.b32.i2p/hosts.txt
## Optional subscriptions URLs, separated by comma
# subscriptions = http://reg.i2p/hosts.txt,http://identiguy.i2p/hosts.txt,http://stats.i2p/cgi-bin/newhosts.txt,http://rus.i2p/hosts.txt
[limits]
## Maximum active transit sessions (default:2500)
# transittunnels = 2500
## Limit number of open file descriptors (0 - use system limit)
# openfiles = 0
## Maximum size of corefile in Kb (0 - use system limit)
# coresize = 0
[trust]
## Enable explicit trust options. false by default
# enabled = true
## Make direct I2P connections only to routers in specified Family.
# family = MyFamily
## Make direct I2P connections only to routers specified here. Comma separated list of base64 identities.
# routers =
## Should we hide our router from other routers? false by default
# hidden = true
[exploratory]
## Exploratory tunnels settings with default values
# inbound.length = 2
# inbound.quantity = 3
# outbound.length = 2
# outbound.quantity = 3
[persist]
## Save peer profiles on disk (default: true)
# profiles = true
## Save full addresses on disk (default: true)
# addressbook = true
[cpuext]
## Use CPU AES-NI instructions set when work with cryptography when available (default: true)
# aesni = true
## Use CPU AVX instructions set when work with cryptography when available (default: true)
# avx = true
## Force usage of CPU instructions set, even if they not found
## DO NOT TOUCH that option if you really don't know what are you doing!
# force = false
+82
View File
@@ -0,0 +1,82 @@
{
# List of connection strings for outbound peer connections in URI format,
# e.g. tls://a.b.c.d:e or socks://a.b.c.d:e/f.g.h.i:j. These connections
# will obey the operating system routing table, therefore you should
# use this section when you may connect via different interfaces.
Peers: []
# List of connection strings for outbound peer connections in URI format,
# arranged by source interface, e.g. { "eth0": [ "tls://a.b.c.d:e" ] }.
# Note that SOCKS peerings will NOT be affected by this option and should
# go in the "Peers" section instead.
InterfacePeers: {}
# Listen addresses for incoming connections. You will need to add
# listeners in order to accept incoming peerings from non-local nodes.
# Multicast peer discovery will work regardless of any listeners set
# here. Each listener should be specified in URI format as above, e.g.
# tls://0.0.0.0:0 or tls://[::]:0 to listen on all interfaces.
Listen: [
tls://[::]:10654
]
# Listen address for admin connections. Default is to listen for local
# connections either on TCP/9001 or a UNIX socket depending on your
# platform. Use this value for yggdrasilctl -endpoint=X. To disable
# the admin socket, use the value "none" instead.
AdminListen: unix:///var/run/yggdrasil.sock
# Configuration for which interfaces multicast peer discovery should be
# enabled on. Each entry in the list should be a json object which may
# contain Regex, Beacon, Listen, and Port. Regex is a regular expression
# which is matched against an interface name, and interfaces use the
# first configuration that they match gainst. Beacon configures whether
# or not the node should send link-local multicast beacons to advertise
# their presence, while listening for incoming connections on Port.
# Listen controls whether or not the node listens for multicast beacons
# and opens outgoing connections.
MulticastInterfaces:
[
{
Regex: .*
Beacon: true
Listen: true
Port: 0
Priority: 0
}
]
# List of peer public keys to allow incoming peering connections
# from. If left empty/undefined then all connections will be allowed
# by default. This does not affect outgoing peerings, nor does it
# affect link-local peers discovered via multicast.
AllowedPublicKeys: []
# Your public key. Your peers may ask you for this to put
# into their AllowedPublicKeys configuration.
PublicKey:
# Your private key. DO NOT share this with anyone!
PrivateKey:
# Local network interface name for TUN adapter, or "auto" to select
# an interface automatically, or "none" to run without TUN.
IfName: auto
# Maximum Transmission Unit (MTU) size for your local TUN interface.
# Default is the largest supported size for your platform. The lowest
# possible value is 1280.
IfMTU: 65535
# By default, nodeinfo contains some defaults including the platform,
# architecture and Yggdrasil version. These can help when surveying
# the network and diagnosing network routing problems. Enabling
# nodeinfo privacy prevents this, so that only items specified in
# "NodeInfo" are sent back if specified.
NodeInfoPrivacy: false
# Optional node info. This must be a { "key": "value", ... } map
# or set as null. This is entirely optional but, if set, is visible
# to the whole network on request.
NodeInfo: {}
}
+12
View File
@@ -0,0 +1,12 @@
FROM python:3.11-alpine
WORKDIR /app
RUN pip install --no-cache-dir "dnslib>=0.9.24"
COPY server.py /app/server.py
EXPOSE 53/udp
EXPOSE 53/tcp
CMD ["python", "/app/server.py"]
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env python3
import hashlib
import ipaddress
import json
import os
import socket
import threading
from typing import Dict, Tuple
from dnslib import A, DNSHeader, DNSRecord, QTYPE, RCODE, RR
LISTEN_HOST = os.getenv("I2PDNS_LISTEN_HOST", "0.0.0.0")
LISTEN_PORT = int(os.getenv("I2PDNS_LISTEN_PORT", "53"))
DEFAULT_TTL = int(os.getenv("I2PDNS_TTL", "60"))
POOL_CIDR = os.getenv("I2PDNS_POOL_CIDR", "172.31.0.0/16")
MAP_FILE = os.getenv("I2PDNS_MAP_FILE", "/var/lib/i2pdns/map.json")
POOL = ipaddress.ip_network(POOL_CIDR, strict=True)
if POOL.version != 4 or POOL.num_addresses < 4:
raise RuntimeError(f"I2PDNS_POOL_CIDR must be an IPv4 network with >=4 addresses: {POOL_CIDR}")
_lock = threading.Lock()
def _load_map() -> Dict[str, Dict[str, str]]:
try:
with open(MAP_FILE, "r", encoding="utf-8") as fh:
data = json.load(fh)
except FileNotFoundError:
data = {}
except Exception:
data = {}
host_to_ip = data.get("host_to_ip") if isinstance(data, dict) else None
ip_to_host = data.get("ip_to_host") if isinstance(data, dict) else None
if not isinstance(host_to_ip, dict):
host_to_ip = {}
if not isinstance(ip_to_host, dict):
ip_to_host = {}
return {"host_to_ip": host_to_ip, "ip_to_host": ip_to_host}
def _save_map(data: Dict[str, Dict[str, str]]) -> None:
os.makedirs(os.path.dirname(MAP_FILE), exist_ok=True)
tmp = MAP_FILE + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(data, fh, sort_keys=True)
os.replace(tmp, MAP_FILE)
def _candidate_index(host: str, host_space: int) -> int:
digest = hashlib.blake2b(host.encode("utf-8"), digest_size=8).digest()
return (int.from_bytes(digest, "big") % host_space) + 1
def _index_to_ip(idx: int) -> str:
return str(ipaddress.IPv4Address(int(POOL.network_address) + idx))
def _allocate_ip_for_host(host: str) -> str:
# Exclude network and broadcast addresses in the pool.
host_space = int(POOL.num_addresses) - 2
if host_space <= 0:
raise RuntimeError("I2P DNS pool has no usable host addresses")
with _lock:
data = _load_map()
host_to_ip = data["host_to_ip"]
ip_to_host = data["ip_to_host"]
existing = host_to_ip.get(host)
if existing:
return existing
start = _candidate_index(host, host_space)
idx = start
while True:
ip = _index_to_ip(idx)
current = ip_to_host.get(ip)
if current is None or current == host:
host_to_ip[host] = ip
ip_to_host[ip] = host
_save_map(data)
return ip
idx += 1
if idx > host_space:
idx = 1
if idx == start:
raise RuntimeError("I2P DNS pool exhausted")
def _is_i2p_name(name: str) -> bool:
return name.endswith(".i2p")
def _answer(req: DNSRecord) -> DNSRecord:
reply = DNSRecord(DNSHeader(id=req.header.id, qr=1, aa=1, ra=1), q=req.q)
qname = str(req.q.qname).rstrip(".").lower()
qtype = QTYPE[req.q.qtype]
if not _is_i2p_name(qname):
reply.header.rcode = RCODE.NXDOMAIN
return reply
if qtype in ("A", "ANY"):
ip = _allocate_ip_for_host(qname)
reply.add_answer(RR(str(req.q.qname), QTYPE.A, rdata=A(ip), ttl=DEFAULT_TTL))
return reply
if qtype == "AAAA":
# We only synthesize IPv4 addresses for SOCKS routing compatibility.
return reply
# Unsupported RR types get NOERROR with empty answer section.
return reply
def _serve_udp(sock: socket.socket) -> None:
while True:
data, addr = sock.recvfrom(4096)
try:
req = DNSRecord.parse(data)
resp = _answer(req).pack()
sock.sendto(resp, addr)
except Exception:
continue
def _handle_tcp_conn(conn: socket.socket) -> None:
try:
hdr = conn.recv(2)
if len(hdr) != 2:
return
size = int.from_bytes(hdr, "big")
payload = conn.recv(size)
if len(payload) != size:
return
req = DNSRecord.parse(payload)
resp = _answer(req).pack()
conn.sendall(len(resp).to_bytes(2, "big") + resp)
except Exception:
pass
finally:
conn.close()
def _serve_tcp(sock: socket.socket) -> None:
while True:
conn, _ = sock.accept()
threading.Thread(target=_handle_tcp_conn, args=(conn,), daemon=True).start()
def main() -> None:
print(f"[i2pdns] listening on {LISTEN_HOST}:{LISTEN_PORT}", flush=True)
print(f"[i2pdns] pool={POOL} map_file={MAP_FILE}", flush=True)
udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
udp.bind((LISTEN_HOST, LISTEN_PORT))
tcp = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
tcp.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
tcp.bind((LISTEN_HOST, LISTEN_PORT))
tcp.listen(128)
threading.Thread(target=_serve_udp, args=(udp,), daemon=True).start()
_serve_tcp(tcp)
if __name__ == "__main__":
main()
+15
View File
@@ -0,0 +1,15 @@
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl gnupg iproute2 iptables \
&& mkdir -p /usr/share/keyrings \
&& curl -fsSL https://deb.session.foundation/pub.gpg -o /usr/share/keyrings/session-foundation.gpg \
&& printf 'Types: deb\nURIs: https://deb.session.foundation\nSuites: bookworm\nComponents: main\nSigned-By: /usr/share/keyrings/session-foundation.gpg\n' > /etc/apt/sources.list.d/session.sources \
&& apt-get update \
&& apt-get install -y --no-install-recommends lokinet-bin \
&& mkdir -p /etc/lokinet /var/lib/lokinet \
&& rm -rf /var/lib/apt/lists/*
COPY lokinet.ini /etc/lokinet/lokinet.ini
CMD ["/usr/bin/lokinet", "--config", "/etc/lokinet/lokinet.ini"]
+19
View File
@@ -0,0 +1,19 @@
[router]
data-dir=/var/lib/lokinet
[network]
auto-routing=false
blackhole-routes=false
ifname=lokinet0
[dns]
bind=0.0.0.0:53
upstream=10.5.0.5:53
no-resolvconf=1
[bootstrap]
add-node=/etc/lokinet/bootstrap.signed
[logging]
type=print
level=info
+19
View File
@@ -0,0 +1,19 @@
{
"title": "3proxy overview",
"schemaVersion": 38,
"version": 1,
"panels": [
{
"type": "timeseries",
"title": "Active proxy connections",
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 0 },
"targets": [
{
"expr": "proxy_proxy_conns",
"legendFormat": "connections",
"refId": "A"
}
]
}
]
}
+12
View File
@@ -0,0 +1,12 @@
FROM python:3.12-alpine
WORKDIR /app
COPY exporter.py /app/exporter.py
ENV EXPORTER_PORT=9100 \
MONITOR_HOST=dark3proxy \
MONITOR_PORT=6800 \
SOCKET_TIMEOUT=2
EXPOSE 9100
CMD ["python", "/app/exporter.py"]
+84
View File
@@ -0,0 +1,84 @@
import os
import re
import socket
from http.server import BaseHTTPRequestHandler, HTTPServer
MONITOR_HOST = os.getenv("MONITOR_HOST", "dark3proxy")
MONITOR_PORT = int(os.getenv("MONITOR_PORT", "6800"))
SOCKET_TIMEOUT = float(os.getenv("SOCKET_TIMEOUT", "2"))
EXPORTER_PORT = int(os.getenv("EXPORTER_PORT", "9100"))
def fetch_monitor_data() -> str:
with socket.create_connection((MONITOR_HOST, MONITOR_PORT), timeout=SOCKET_TIMEOUT) as sock:
sock.settimeout(SOCKET_TIMEOUT)
chunks = []
while True:
data = sock.recv(4096)
if not data:
break
chunks.append(data)
return b"".join(chunks).decode("utf-8", errors="replace")
def metric_name(raw_parts: list[str]) -> str:
name = "_".join(raw_parts).lower()
name = re.sub(r"[^a-z0-9_]", "_", name)
name = re.sub(r"_+", "_", name).strip("_")
if not name or not name[0].isalpha():
name = f"m_{name}" if name else "m_value"
return f"proxy_{name}"
def parse_metrics(text: str) -> dict[str, float]:
metrics: dict[str, float] = {}
for line in text.splitlines():
parts = line.strip().split()
if len(parts) < 2:
continue
value_token = parts[-1].replace(",", ".")
try:
value = float(value_token)
except ValueError:
continue
name = metric_name(parts[:-1])
metrics[name] = value
return metrics
def render_metrics() -> bytes:
body_lines = []
try:
raw = fetch_monitor_data()
metrics = parse_metrics(raw)
body_lines.append("proxy_exporter_up 1")
for name in sorted(metrics):
body_lines.append(f"{name} {metrics[name]}")
except Exception:
body_lines.append("proxy_exporter_up 0")
body = "\n".join(body_lines) + "\n"
return body.encode("utf-8")
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path not in ("/metrics", "/"):
self.send_response(404)
self.end_headers()
return
payload = render_metrics()
self.send_response(200)
self.send_header("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def log_message(self, format, *args):
return
if __name__ == "__main__":
server = HTTPServer(("0.0.0.0", EXPORTER_PORT), Handler)
server.serve_forever()
+8
View File
@@ -0,0 +1,8 @@
global:
scrape_interval: 5s
scrape_configs:
- job_name: 3proxy_exporter
static_configs:
- targets:
- dark3proxy-exporter:9100
+19
View File
@@ -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"]
+880
View File
@@ -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) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;"
}[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()
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
COMPOSE=(docker compose -f "$ROOT_DIR/docker-compose.yml" -f "$ROOT_DIR/docker-compose.arm.yml")
services=(dark3proxy darkdns darkzil darkscale darkpihole darki2p)
dns_queries=(google.com torproject.org github.com wikipedia.org example.onion example.i2p)
echo "[smoke] stack status"
"${COMPOSE[@]}" ps
echo
echo "[smoke] restart/health counters"
for c in "${services[@]}"; do
if [[ "$c" == "darkpihole" ]]; then
docker inspect -f "$c status={{.State.Status}} health={{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}} restart={{.RestartCount}}" "$c"
elif [[ "$c" == "darki2p" ]]; then
docker inspect -f "$c status={{.State.Status}} health={{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}} restart={{.RestartCount}}" "$c"
else
docker inspect -f "$c status={{.State.Status}} restart={{.RestartCount}}" "$c"
fi
done
echo
echo "[smoke] dns timings via pihole"
for pass in 1 2; do
echo "-- pass $pass --"
for q in "${dns_queries[@]}"; do
out="$(docker exec darkpihole sh -lc "dig @127.0.0.1 +time=2 +tries=1 '$q' 2>&1" || true)"
status="$(printf "%s\n" "$out" | sed -n 's/.*status: \([A-Z]*\).*/\1/p' | head -n1)"
qtime="$(printf "%s\n" "$out" | sed -n 's/;; Query time: \([0-9]*\).*/\1/p' | head -n1)"
[[ -n "$status" ]] || status="NO_RESPONSE"
[[ -n "$qtime" ]] || qtime="-1"
printf '%s status=%s query_ms=%s\n' "$q" "$status" "$qtime"
done
echo
done
echo
echo "[smoke] .zil path checks via coredns"
for q in brad.zil not-a-real-name-xyz12345.zil; do
out="$(docker exec darkpihole sh -lc "dig @10.5.0.4 +time=8 +tries=1 '$q' TXT 2>&1" || true)"
status="$(printf "%s\n" "$out" | sed -n 's/.*status: \([A-Z]*\).*/\1/p' | head -n1)"
qtime="$(printf "%s\n" "$out" | sed -n 's/;; Query time: \([0-9]*\).*/\1/p' | head -n1)"
answers="$(printf "%s\n" "$out" | awk '/^;; ANSWER SECTION:/{flag=1;next}/^;;/{if(flag)exit}flag' | wc -l | tr -d ' ')"
[[ -n "$status" ]] || status="NO_RESPONSE"
[[ -n "$qtime" ]] || qtime="-1"
[[ -n "$answers" ]] || answers="0"
printf '%s status=%s query_ms=%s answers=%s\n' "$q" "$status" "$qtime" "$answers"
done
echo "[smoke] key error scan"
for c in "${services[@]}"; do
echo "=== $c ==="
case "$c" in
darkscale)
docker logs --tail 120 "$c" 2>&1 \
| grep -Ei "error|failed|not found|panic|traceback|unhealthy|refused|parse" \
| grep -Ev "tpmrm0|Tailscale is stopped" \
| tail -n 20 || echo "(no matching patterns)"
;;
darkpihole)
docker logs --tail 120 "$c" 2>&1 \
| grep -Ei "error|failed|not found|panic|traceback|unhealthy|refused|parse" \
| grep -Ev "refused to do a recursive query" \
| tail -n 20 || echo "(no matching patterns)"
;;
darki2p)
docker logs --tail 120 "$c" 2>&1 \
| grep -Ei "error|failed|not found|panic|traceback|unhealthy|refused|parse" \
| grep -Ev "SessionCreated read error: End of file|Connect error Operation canceled|Connect error Network is unreachable|RouterInfo for .* not found|RouterInfo not found, failed to send messages|NetDbReq: .* not found after 5 attempts" \
| tail -n 20 || echo "(no matching patterns)"
;;
*)
docker logs --tail 120 "$c" 2>&1 | grep -Ei "error|failed|not found|panic|traceback|unhealthy|refused|parse" | tail -n 20 || echo "(no matching patterns)"
;;
esac
echo
done
echo "[smoke] done"
+128 -31
View File
@@ -29,16 +29,52 @@ else
echo "[validate-config] docker binary not found; skipping compose validation" echo "[validate-config] docker binary not found; skipping compose validation"
fi fi
# CoreDNS syntax check (use local binary or container fallback) # CoreDNS syntax check must use the compiled image because the Corefile relies
if command -v coredns >/dev/null 2>&1; then # on custom plugins (meshname/meship) that are not present in the stock binary.
echo "[validate-config] checking PopuraDNS/Corefile with local coredns" if command -v docker >/dev/null 2>&1; then
coredns -conf PopuraDNS/Corefile -dns.port=0 echo "[validate-config] checking PopuraDNS/Corefile with compiled coredns image"
elif command -v docker >/dev/null 2>&1; then docker compose build coredns >/dev/null
echo "[validate-config] checking PopuraDNS/Corefile with container" COREDNS_IMAGE="$(docker compose images -q coredns | head -n1)"
docker run --rm -v "${PWD}/PopuraDNS/Corefile:/etc/coredns/Corefile:ro" \ if [ -z "$COREDNS_IMAGE" ]; then
coredns/coredns:latest -conf /etc/coredns/Corefile -dns.port=0 echo "[validate-config] error: failed to resolve compiled coredns image" >&2
exit 1
fi
set +e
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
echo "[validate-config] error: compiled coredns failed to load PopuraDNS/Corefile" >&2
exit "$COREDNS_STATUS"
fi
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 -k 2s 8s coredns -conf PopuraDNS/Corefile -dns.port=0
COREDNS_STATUS=$?
set -e
if [ "$COREDNS_STATUS" -ne 0 ] && [ "$COREDNS_STATUS" -ne 124 ]; then
echo "[validate-config] error: local compiled coredns failed to load PopuraDNS/Corefile" >&2
exit "$COREDNS_STATUS"
fi
else else
echo "[validate-config] cannot check Corefile syntax (no coredns or docker)" 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 fi
# ensure onion and i2p zones are present in Corefile # ensure onion and i2p zones are present in Corefile
@@ -49,13 +85,18 @@ for zone in "onion.:53" "i2p.:53" "eth.:53" "bit.:53" "alt.:53" "loki.:53" "zil.
fi fi
done done
# verify i2pd DNS server is enabled in its config if ! grep -A6 '^\.:53 {' PopuraDNS/Corefile | grep -q 'acl {'; then
if grep -q "\[dns\]" i2pd_yggdrasil_docker/src/i2pd.conf; then echo "[validate-config] error: Corefile catch-all zone must include an ACL to avoid open recursion" >&2
if ! awk '/\[dns\]/,/\[/{if($0~/enabled/ && $0~/true/) ok=1} END{exit !ok}' i2pd_yggdrasil_docker/src/i2pd.conf; then exit 1
echo "[validate-config] warning: i2pd DNS section present but not enabled" fi
# verify the current .i2p DNS architecture is documented in config
if grep -q '^\[dns\]' i2pd_yggdrasil_docker/src/i2pd.conf; then
if ! awk '/^\[dns\]/,/^\[/{if($0~/enabled/ && $0~/true/) ok=1} END{exit !ok}' i2pd_yggdrasil_docker/src/i2pd.conf; then
echo "[validate-config] warning: i2pd [dns] section exists but is disabled; the stack currently expects the separate i2pdns bridge service"
fi fi
else elif ! grep -qE 'i2pdns bridge|local i2pdns bridge service' i2pd_yggdrasil_docker/src/i2pd.conf; then
echo "[validate-config] warning: i2pd.conf missing [dns] section" echo "[validate-config] warning: i2pd.conf should document that .i2p DNS is served by the separate i2pdns bridge service"
fi fi
# tor configuration should expose DNSPort for onion resolution # tor configuration should expose DNSPort for onion resolution
@@ -75,20 +116,35 @@ if command -v dig >/dev/null 2>&1; then
echo "[validate-config] i2p query failed" >&2 echo "[validate-config] i2p query failed" >&2
exit 1 exit 1
fi 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 fi
# ensure referenced secrets exist # ensure referenced secrets exist
for s in ./secrets/tz.txt ./secrets/YGGDRASIL_GENERATE_KEYS.txt; do for s in \
./secrets/tz.txt \
./secrets/YGGDRASIL_GENERATE_KEYS.txt \
./secrets/pihole_webpassword.txt \
./secrets/3proxy_users.txt \
./secrets/namecoin_rpc_password.txt; do
if [ ! -f "$s" ]; then if [ ! -f "$s" ]; then
echo "[validate-config] error: secret file $s is missing" >&2 echo "[validate-config] error: secret file $s is missing" >&2
exit 1 exit 1
fi fi
done done
# check for :latest tags (discouraged in prod) # check for mutable :latest tags in deployment compose files
if grep -qE 'image: .*:latest' docker-compose.yml; then if grep -qE 'image: .*:latest' docker-compose.yml; then
echo "[validate-config] warning: some services use the ':latest' image tag;" \ echo "[validate-config] error: mutable ':latest' image tags are not allowed in the release compose file" >&2
"pin to a specific version before deploying to production." exit 1
fi fi
# warn if there are no healthchecks at all # warn if there are no healthchecks at all
@@ -96,10 +152,6 @@ if ! grep -q 'healthcheck:' docker-compose.yml; then
echo "[validate-config] warning: no healthcheck definitions found in docker-compose.yml" echo "[validate-config] warning: no healthcheck definitions found in docker-compose.yml"
fi fi
echo "[validate-config] warning: some services use the ':latest' image tag;" \
"pin to a specific version before deploying to production."
fi
# verify every service has a restart policy # verify every service has a restart policy
if ! grep -q "restart:" docker-compose.yml; then if ! grep -q "restart:" docker-compose.yml; then
echo "[validate-config] warning: some services lack a restart policy." echo "[validate-config] warning: some services lack a restart policy."
@@ -112,13 +164,23 @@ if [ -n "$(echo "$ports" | uniq -d)" ]; then
fi fi
# unbound configuration syntax check # unbound configuration syntax check
if command -v unbound-checkconf >/dev/null 2>&1; then if command -v docker >/dev/null 2>&1; then
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; 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" echo "[validate-config] checking unbound configuration locally"
unbound-checkconf -c unbound/unbound.conf unbound-checkconf unbound/unbound.conf
elif command -v docker >/dev/null 2>&1; then
echo "[validate-config] checking unbound configuration in container"
docker run --rm -v "${PWD}/unbound/unbound.conf:/etc/unbound/unbound.conf:ro" \
mvance/unbound:latest unbound-checkconf -c /etc/unbound/unbound.conf
else else
echo "[validate-config] cannot check unbound config (no unbound-checkconf or docker)" echo "[validate-config] cannot check unbound config (no unbound-checkconf or docker)"
fi fi
@@ -134,6 +196,26 @@ for cfg in 3proxy/first-instanse.cfg 3proxy/second-instanse.cfg; do
fi fi
done done
if grep -q '^auth none$' 3proxy/first-instanse.cfg; then
echo "[validate-config] error: dark3proxy must not expose a public proxy with 'auth none'" >&2
exit 1
fi
if grep -q 'CHANGE_ME_NAMECOIN_RPC_PASSWORD' docker-compose.yml; then
echo "[validate-config] error: replace inline Namecoin RPC password placeholders with Docker secrets" >&2
exit 1
fi
if grep -q 'tskey-YOUR-AUTH-KEY-HERE' docker-compose.yml; then
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 # simple sanity checks
if grep -q "container_name: darkproxy" docker-compose.yml; then if grep -q "container_name: darkproxy" docker-compose.yml; then
echo "[validate-config] warning: 'container_name: darkproxy' appears in compose;" \ echo "[validate-config] warning: 'container_name: darkproxy' appears in compose;" \
@@ -141,9 +223,24 @@ if grep -q "container_name: darkproxy" docker-compose.yml; then
fi fi
# ensure Pi-hole DNS environment is correctly formatted # 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;" \ 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 fi
echo "[validate-config] all validations passed" echo "[validate-config] all validations passed"
+16
View File
@@ -0,0 +1,16 @@
#syntax=docker/dockerfile:1
FROM tailscale/tailscale@sha256:dbeff02d2337344b351afac203427218c4d0a06c43fc10a865184063498472a6
RUN apk add --no-cache \
iptables \
socat \
curl \
bash \
redsocks
COPY ./post-rules.sh /post-rules.sh
COPY ./redsocks.conf /etc/redsocks.conf
RUN chmod +x /post-rules.sh
# Entrypoint to configure and start Tailscale + traffic redirection
ENTRYPOINT ["/post-rules.sh"]
+50
View File
@@ -0,0 +1,50 @@
#!/bin/bash
set -e
# Start Tailscale in background
/usr/local/bin/tailscaled --tun=userspace-networking --encrypt-state=false --hardware-attestation=false &
TAILSCALED_PID=$!
# Wait for Tailscale to initialize
sleep 2
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
# Configure iptables to redirect all traffic through redsocks
echo "Setting up iptables rules for traffic redirection..."
# Enable IP forwarding for exit node
sysctl -w net.ipv4.ip_forward=1 2>/dev/null || true
sysctl -w net.ipv6.conf.all.forwarding=1 2>/dev/null || true
# Create a chain for darkproxy traffic
iptables -t nat -N DARKPROXY 2>/dev/null || iptables -t nat -F DARKPROXY
# Whitelist local traffic and Tailscale
iptables -t nat -A DARKPROXY -d 127.0.0.0/8 -j RETURN
iptables -t nat -A DARKPROXY -d 10.5.0.0/16 -j RETURN
iptables -t nat -A DARKPROXY -d 100.64.0.0/10 -j RETURN # Tailscale IP range
# Redirect all other TCP to redsocks
iptables -t nat -A DARKPROXY -p tcp -j REDIRECT --to-ports 12345
# Apply chain to all output
iptables -t nat -I OUTPUT 1 -j DARKPROXY 2>/dev/null || true
iptables -t nat -I PREROUTING 1 -j DARKPROXY 2>/dev/null || true
echo "Starting redsocks..."
redsocks -c /etc/redsocks.conf
# Keep container alive
wait $TAILSCALED_PID
+25
View File
@@ -0,0 +1,25 @@
base {
log_debug = off;
log_info = on;
log = "stderr";
daemon = on;
redirector = iptables;
}
redsocks {
local_ip = 0.0.0.0;
local_port = 12345;
ip = 10.5.0.8;
port = 1080;
type = socks5;
}
redudp {
local_ip = 0.0.0.0;
local_port = 10053;
ip = 10.5.0.8;
port = 1080;
dest_ip = 10.5.0.6;
dest_port = 53;
}
+5
View File
@@ -0,0 +1,5 @@
# A Record
#local-data: "somecomputer.local. A 192.168.1.1"
# PTR Record
#local-data-ptr: "192.168.1.1 somecomputer.local."
+54
View File
@@ -0,0 +1,54 @@
forward-zone:
# Forward all queries (except those in cache and local zone) to
# upstream recursive servers
name: "."
# Queries to this forward zone use TLS
forward-tls-upstream: yes
# https://dnsprivacy.org/wiki/display/DP/DNS+Privacy+Test+Servers
## Cloudflare
forward-addr: 1.1.1.1@853#cloudflare-dns.com
forward-addr: 1.0.0.1@853#cloudflare-dns.com
#forward-addr: 2606:4700:4700::1111@853#cloudflare-dns.com
#forward-addr: 2606:4700:4700::1001@853#cloudflare-dns.com
## Cloudflare Malware
# forward-addr: 1.1.1.2@853#security.cloudflare-dns.com
# forward-addr: 1.0.0.2@853#security.cloudflare-dns.com
# forward-addr: 2606:4700:4700::1112@853#security.cloudflare-dns.com
# forward-addr: 2606:4700:4700::1002@853#security.cloudflare-dns.com
## Cloudflare Malware and Adult Content
# forward-addr: 1.1.1.3@853#family.cloudflare-dns.com
# forward-addr: 1.0.0.3@853#family.cloudflare-dns.com
# forward-addr: 2606:4700:4700::1113@853#family.cloudflare-dns.com
# forward-addr: 2606:4700:4700::1003@853#family.cloudflare-dns.com
## CleanBrowsing Security Filter
# forward-addr: 185.228.168.9@853#security-filter-dns.cleanbrowsing.org
# forward-addr: 185.228.169.9@853#security-filter-dns.cleanbrowsing.org
# forward-addr: 2a0d:2a00:1::2@853#security-filter-dns.cleanbrowsing.org
# forward-addr: 2a0d:2a00:2::2@853#security-filter-dns.cleanbrowsing.org
## CleanBrowsing Adult Filter
# forward-addr: 185.228.168.10@853#adult-filter-dns.cleanbrowsing.org
# forward-addr: 185.228.169.11@853#adult-filter-dns.cleanbrowsing.org
# forward-addr: 2a0d:2a00:1::1@853#adult-filter-dns.cleanbrowsing.org
# forward-addr: 2a0d:2a00:2::1@853#adult-filter-dns.cleanbrowsing.org
## CleanBrowsing Family Filter
# forward-addr: 185.228.168.168@853#family-filter-dns.cleanbrowsing.org
# forward-addr: 185.228.169.168@853#family-filter-dns.cleanbrowsing.org
# forward-addr: 2a0d:2a00:1::@853#family-filter-dns.cleanbrowsing.org
# forward-addr: 2a0d:2a00:2::@853#family-filter-dns.cleanbrowsing.org
## Quad9
# forward-addr: 9.9.9.9@853#dns.quad9.net
# forward-addr: 149.112.112.112@853#dns.quad9.net
# forward-addr: 2620:fe::fe@853#dns.quad9.net
# forward-addr: 2620:fe::9@853#dns.quad9.net
## getdnsapi.net
# forward-addr: 185.49.141.37@853#getdnsapi.net
# forward-addr: 2a04:b900:0:100::37@853#getdnsapi.net
+3
View File
@@ -0,0 +1,3 @@
# SRV records for local DNS overrides
# Example:
# local-data: "_service._tcp.example.local. 300 IN SRV 10 5 8080 host.example.local."
+387
View File
@@ -0,0 +1,387 @@
server:
###########################################################################
# BASIC SETTINGS
###########################################################################
# Time to live maximum for RRsets and messages in the cache. If the maximum
# kicks in, responses to clients still get decrementing TTLs based on the
# original (larger) values. When the internal TTL expires, the cache item
# has expired. Can be set lower to force the resolver to query for data
# often, and not trust (very large) TTL values.
cache-max-ttl: 86400
# Time to live minimum for RRsets and messages in the cache. If the minimum
# kicks in, the data is cached for longer than the domain owner intended,
# and thus less queries are made to look up the data. Zero makes sure the
# data in the cache is as the domain owner intended, higher values,
# especially more than an hour or so, can lead to trouble as the data in
# the cache does not match up with the actual data any more.
cache-min-ttl: 300
# Set the working directory for the program.
directory: "/opt/unbound/etc/unbound"
# Enable or disable whether IPv4 queries are answered or issued.
# Default: yes
do-ip4: yes
# Enable or disable whether IPv6 queries are answered or issued.
# If disabled, queries are not answered on IPv6, and queries are not sent
# on IPv6 to the internet nameservers. With this option you can disable the
# IPv6 transport for sending DNS traffic, it does not impact the contents
# of the DNS traffic, which may have IPv4 (A) and IPv6 (AAAA) addresses in
# it.
# Default: yes
# May be set to yes if you have IPv6 connectivity
do-ip6: yes
# Enable or disable whether TCP queries are answered or issued.
# Default: yes
do-tcp: yes
# Enable or disable whether UDP queries are answered or issued.
# Default: yes
do-udp: yes
# RFC 6891. Number of bytes size to advertise as the EDNS reassembly buffer
# size. This is the value put into datagrams over UDP towards peers.
# The actual buffer size is determined by msg-buffer-size (both for TCP and
# UDP). Do not set higher than that value.
# Default is 1232 which is the DNS Flag Day 2020 recommendation.
# Setting to 512 bypasses even the most stringent path MTU problems, but
# is seen as extreme, since the amount of TCP fallback generated is
# excessive (probably also for this resolver, consider tuning the outgoing
# tcp number).
edns-buffer-size: 1232
# Listen to for queries from clients and answer from this network interface
# and port.
interface: 0.0.0.0@53
# interface: ::0
port: 53
# If enabled, prefer IPv6 transport for sending DNS queries to internet
# nameservers.
# Default: yes
# You want to leave this to no unless you have *native* IPv6. With 6to4 and
# Terredo tunnels your web browser should favor IPv4 for the same reasons
prefer-ip6: no
# Rotates RRSet order in response (the pseudo-random number is taken from
# the query ID, for speed and thread safety).
rrset-roundrobin: yes
# Drop user privileges after binding the port.
username: "unbound"
###########################################################################
# LOGGING
###########################################################################
# Do not print log lines to inform about local zone actions
log-local-actions: no
# Do not print one line per query to the log
log-queries: no
# Do not print one line per reply to the log
log-replies: no
# Do not print log lines that say why queries return SERVFAIL to clients
log-servfail: no
# If you want to log to a file, use:
# logfile: /opt/unbound/etc/unbound/unbound.log
# Set log location (using /dev/null further limits logging)
logfile: /dev/null
# Set logging level
# Level 0: No verbosity, only errors.
# Level 1: Gives operational information.
# Level 2: Gives detailed operational information including short information per query.
# Level 3: Gives query level information, output per query.
# Level 4: Gives algorithm level information.
# Level 5: Logs client identification for cache misses.
verbosity: 0
###########################################################################
# PERFORMANCE SETTINGS
###########################################################################
# https://nlnetlabs.nl/documentation/unbound/howto-optimise/
# https://nlnetlabs.nl/news/2019/Feb/05/unbound-1.9.0-released/
# Number of slabs in the infrastructure cache. Slabs reduce lock contention
# by threads. Must be set to a power of 2.
infra-cache-slabs: 4
# Number of incoming TCP buffers to allocate per thread. Default
# is 10. If set to 0, or if do-tcp is "no", no TCP queries from
# clients are accepted. For larger installations increasing this
# value is a good idea.
incoming-num-tcp: 10
# Number of slabs in the key cache. Slabs reduce lock contention by
# threads. Must be set to a power of 2. Setting (close) to the number
# of cpus is a reasonable guess.
key-cache-slabs: 4
# Number of bytes size of the message cache.
# Unbound recommendation is to Use roughly twice as much rrset cache memory
# as you use msg cache memory.
msg-cache-size: 142768128
# Number of slabs in the message cache. Slabs reduce lock contention by
# threads. Must be set to a power of 2. Setting (close) to the number of
# cpus is a reasonable guess.
msg-cache-slabs: 4
# The number of queries that every thread will service simultaneously. If
# more queries arrive that need servicing, and no queries can be jostled
# out (see jostle-timeout), then the queries are dropped.
# This is best set at half the number of the outgoing-range.
# This Unbound instance was compiled with libevent so it can efficiently
# use more than 1024 file descriptors.
num-queries-per-thread: 4096
# The number of threads to create to serve clients.
# This is set dynamically at run time to effectively use available CPUs
# resources
num-threads: 3
# Number of ports to open. This number of file descriptors can be opened
# per thread.
# This Unbound instance was compiled with libevent so it can efficiently
# use more than 1024 file descriptors.
outgoing-range: 8192
# Number of bytes size of the RRset cache.
# Use roughly twice as much rrset cache memory as msg cache memory
rrset-cache-size: 285536256
# Number of slabs in the RRset cache. Slabs reduce lock contention by
# threads. Must be set to a power of 2.
rrset-cache-slabs: 4
# Do no insert authority/additional sections into response messages when
# those sections are not required. This reduces response size
# significantly, and may avoid TCP fallback for some responses. This may
# cause a slight speedup.
minimal-responses: yes
# # Fetch the DNSKEYs earlier in the validation process, when a DS record
# is encountered. This lowers the latency of requests at the expense of
# little more CPU usage.
prefetch: yes
# Fetch the DNSKEYs earlier in the validation process, when a DS record is
# encountered. This lowers the latency of requests at the expense of little
# more CPU usage.
prefetch-key: yes
# Have unbound attempt to serve old responses from cache with a TTL of 0 in
# the response without waiting for the actual resolution to finish. The
# actual resolution answer ends up in the cache later on.
serve-expired: yes
# If not 0, then set the SO_RCVBUF socket option to get more buffer space on
# UDP port 53 incoming queries. So that short spikes on busy servers do not
# drop packets (see counter in netstat -su). Otherwise, the number of bytes
# to ask for, try “4m” on a busy server.
# The OS caps it at a maximum, on linux Unbound needs root permission to
# bypass the limit, or the admin can use sysctl net.core.rmem_max.
# Default: 0 (use system value)
# For example: sysctl -w net.core.rmem_max=4194304
# To persist reboots, edit /etc/sysctl.conf to include:
# net.core.rmem_max=4194304
# Larger socket buffer. OS may need config.
# Ensure kernel buffer is large enough to not lose messages in traffic spikes
#so-rcvbuf: 4m
# Open dedicated listening sockets for incoming queries for each thread and
# try to set the SO_REUSEPORT socket option on each socket. May distribute
# incoming queries to threads more evenly.
so-reuseport: yes
# If not 0, then set the SO_SNDBUF socket option to get more buffer space
# on UDP port 53 outgoing queries.
# Specify the number of bytes to ask for, try “4m” on a very busy server.
# The OS caps it at a maximum, on linux Unbound needs root permission to
# bypass the limit, or the admin can use sysctl net.core.wmem_max.
# For example: sysctl -w net.core.wmem_max=4194304
# To persist reboots, edit /etc/sysctl.conf to include:
# net.core.wmem_max=4194304
# Default: 0 (use system value)
# Larger socket buffer. OS may need config.
# Ensure kernel buffer is large enough to not lose messages in traffic spikes
#so-sndbuf: 4m
###########################################################################
# PRIVACY SETTINGS
###########################################################################
# RFC 8198. Use the DNSSEC NSEC chain to synthesize NXDO-MAIN and other
# denials, using information from previous NXDO-MAINs answers. In other
# words, use cached NSEC records to generate negative answers within a
# range and positive answers from wildcards. This increases performance,
# decreases latency and resource utilization on both authoritative and
# recursive servers, and increases privacy. Also, it may help increase
# resilience to certain DoS attacks in some circumstances.
aggressive-nsec: yes
# Extra delay for timeouted UDP ports before they are closed, in msec.
# This prevents very delayed answer packets from the upstream (recursive)
# servers from bouncing against closed ports and setting off all sort of
# close-port counters, with eg. 1500 msec. When timeouts happen you need
# extra sockets, it checks the ID and remote IP of packets, and unwanted
# packets are added to the unwanted packet counter.
delay-close: 10000
# Prevent the unbound server from forking into the background as a daemon
do-daemonize: no
# Add localhost to the do-not-query-address list.
do-not-query-localhost: no
# Number of bytes size of the aggressive negative cache.
neg-cache-size: 4M
# Send minimum amount of information to upstream servers to enhance
# privacy (best privacy).
qname-minimisation: yes
###########################################################################
# SECURITY SETTINGS
###########################################################################
# Only give access to recursion clients from LAN IPs
access-control: 127.0.0.1/32 allow
access-control: 192.168.0.0/16 allow
access-control: 172.16.0.0/12 allow
access-control: 10.0.0.0/8 allow
access-control: fc00::/7 allow
access-control: ::1/128 allow
# File with trust anchor for one zone, which is tracked with RFC5011
# probes.
auto-trust-anchor-file: "var/root.key"
# Enable chroot (i.e, change apparent root directory for the current
# running process and its children)
chroot: "/opt/unbound/etc/unbound"
# Deny queries of type ANY with an empty response.
deny-any: yes
# Harden against algorithm downgrade when multiple algorithms are
# advertised in the DS record.
harden-algo-downgrade: yes
# RFC 8020. returns nxdomain to queries for a name below another name that
# is already known to be nxdomain.
harden-below-nxdomain: yes
# Require DNSSEC data for trust-anchored zones, if such data is absent, the
# zone becomes bogus. If turned off you run the risk of a downgrade attack
# that disables security for a zone.
harden-dnssec-stripped: yes
# Only trust glue if it is within the servers authority.
harden-glue: yes
# Ignore very large queries.
harden-large-queries: yes
# Perform additional queries for infrastructure data to harden the referral
# path. Validates the replies if trust anchors are configured and the zones
# are signed. This enforces DNSSEC validation on nameserver NS sets and the
# nameserver addresses that are encountered on the referral path to the
# answer. Experimental option.
harden-referral-path: no
# Ignore very small EDNS buffer sizes from queries.
harden-short-bufsize: yes
# If enabled the HTTP header User-Agent is not set. Use with caution
# as some webserver configurations may reject HTTP requests lacking
# this header. If needed, it is better to explicitly set the
# the http-user-agent.
hide-http-user-agent: no
# Refuse id.server and hostname.bind queries
hide-identity: yes
# Refuse version.server and version.bind queries
hide-version: yes
# Set the HTTP User-Agent header for outgoing HTTP requests. If
# set to "", the default, then the package name and version are
# used.
http-user-agent: "DNS"
# Report this identity rather than the hostname of the server.
identity: "DNS"
# These private network addresses are not allowed to be returned for public
# internet names. Any occurrence of such addresses are removed from DNS
# answers. Additionally, the DNSSEC validator may mark the answers bogus.
# This protects against DNS Rebinding
private-address: 10.0.0.0/8
private-address: 172.16.0.0/12
private-address: 192.168.0.0/16
private-address: 169.254.0.0/16
private-address: fd00::/8
private-address: fe80::/10
private-address: ::ffff:0:0/96
# Enable ratelimiting of queries (per second) sent to nameserver for
# performing recursion. More queries are turned away with an error
# (servfail). This stops recursive floods (e.g., random query names), but
# not spoofed reflection floods. Cached responses are not rate limited by
# this setting. Experimental option.
ratelimit: 1000
# Use this certificate bundle for authenticating connections made to
# outside peers (e.g., auth-zone urls, DNS over TLS connections).
tls-cert-bundle: /etc/ssl/certs/ca-certificates.crt
# Set the total number of unwanted replies to eep track of in every thread.
# When it reaches the threshold, a defensive action of clearing the rrset
# and message caches is taken, hopefully flushing away any poison.
# Unbound suggests a value of 10 million.
unwanted-reply-threshold: 10000
# Use 0x20-encoded random bits in the query to foil spoof attempts. This
# perturbs the lowercase and uppercase of query names sent to authority
# servers and checks if the reply still has the correct casing.
# This feature is an experimental implementation of draft dns-0x20.
# Experimental option.
# Don't use Capitalization randomization as it known to cause DNSSEC issues
# see https://discourse.pi-hole.net/t/unbound-stubby-or-dnscrypt-proxy/9378
use-caps-for-id: no
# Help protect users that rely on this validator for authentication from
# potentially bad data in the additional section. Instruct the validator to
# remove data from the additional section of secure messages that are not
# signed properly. Messages that are insecure, bogus, indeterminate or
# unchecked are not affected.
val-clean-additional: yes
###########################################################################
# FORWARD ZONE
###########################################################################
# include: /opt/unbound/etc/unbound/forward-records.conf
###########################################################################
# LOCAL ZONE
###########################################################################
# Include file for local-data and local-data-ptr
include: /opt/unbound/etc/unbound/a-records.conf
include: /opt/unbound/etc/unbound/srv-records.conf
###########################################################################
# WILDCARD INCLUDE
###########################################################################
#include: "/opt/unbound/etc/unbound/*.conf"
remote-control:
control-enable: no
+25
View File
@@ -0,0 +1,25 @@
FROM debian:stable-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
unbound \
unbound-anchor \
dnsutils \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Keep path compatibility with existing bind mounts in docker-compose.yml
RUN mkdir -p /opt/unbound/etc/unbound /opt/unbound/etc/unbound/dev /opt/unbound/etc/unbound/var \
&& : > /opt/unbound/etc/unbound/dev/null
# Pre-create DNSSEC trust anchor used by the mounted unbound.conf.
RUN unbound-anchor -a /opt/unbound/etc/unbound/var/root.key || true
# Allow unbound daemon user to update trust anchors and write to chrooted /dev/null.
RUN chown -R unbound:unbound /opt/unbound/etc/unbound/var /opt/unbound/etc/unbound/dev \
&& chmod 666 /opt/unbound/etc/unbound/dev/null
EXPOSE 53/tcp 53/udp
CMD ["/bin/sh", "-c", "[ -s /opt/unbound/etc/unbound/var/root.key ] || unbound-anchor -a /opt/unbound/etc/unbound/var/root.key || true; exec unbound -d -c /opt/unbound/etc/unbound/unbound.conf"]
+15
View File
@@ -0,0 +1,15 @@
FROM node:16-alpine
WORKDIR /app
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 ["/app/entrypoint.sh"]
+9
View File
@@ -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
+12
View File
@@ -0,0 +1,12 @@
{
"name": "zildns",
"version": "1.0.0",
"private": true,
"description": "Blockchain-backed DNS resolver for .zil domains",
"license": "MIT",
"main": "server.js",
"dependencies": {
"@unstoppabledomains/resolution": "7.1.4",
"dns2": "^2.1.0"
}
}
+248
View File
@@ -0,0 +1,248 @@
"use strict";
const dns2 = require("dns2");
const { default: Resolution } = require("@unstoppabledomains/resolution");
const LISTEN_HOST = process.env.ZILDNS_LISTEN_HOST || "0.0.0.0";
const LISTEN_PORT = Number(process.env.ZILDNS_LISTEN_PORT || "53");
const DEFAULT_TTL = Number(process.env.ZILDNS_TTL || "120");
const CACHE_SECONDS = Number(process.env.ZILDNS_CACHE_SECONDS || "30");
const ZNS_URL = process.env.ZILDNS_ZNS_URL || "https://api.zilliqa.com";
const ZNS_NETWORK = process.env.ZILDNS_ZNS_NETWORK || "mainnet";
const PREWARM_DOMAIN = normalizePrewarmDomain(process.env.ZILDNS_PREWARM_DOMAIN || "brad.zil");
const { Packet, UDPServer, TCPServer } = dns2;
const RCODE = {
NOERROR: 0,
SERVFAIL: 2,
NXDOMAIN: 3,
};
const resolution = new Resolution({
sourceConfig: {
zns: {
url: ZNS_URL,
network: ZNS_NETWORK,
},
},
});
const cache = new Map();
function normalizeName(name) {
return String(name || "").replace(/\.$/, "").toLowerCase();
}
function normalizePrewarmDomain(name) {
const normalized = normalizeName(name);
return normalized.endsWith(".zil") ? normalized : "";
}
function cacheGet(key) {
const entry = cache.get(key);
if (!entry) {
return null;
}
if (Date.now() > entry.expiresAt) {
cache.delete(key);
return null;
}
return entry.value;
}
function cacheSet(key, value) {
cache.set(key, {
value,
expiresAt: Date.now() + CACHE_SECONDS * 1000,
});
}
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;
}
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 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;
}
function addTxtAnswers(response, name, resolved) {
if (resolved.zilAddress) {
response.answers.push({
name,
type: Packet.TYPE.TXT,
class: Packet.CLASS.IN,
ttl: DEFAULT_TTL,
data: `zil_address=${resolved.zilAddress}`,
});
}
if (resolved.ipfsHash) {
response.answers.push({
name,
type: Packet.TYPE.TXT,
class: Packet.CLASS.IN,
ttl: DEFAULT_TTL,
data: `ipfs_hash=${resolved.ipfsHash}`,
});
}
}
function addCnameAnswer(response, name, resolved) {
if (!resolved.ipfsHash) {
return;
}
response.answers.push({
name,
type: Packet.TYPE.CNAME,
class: Packet.CLASS.IN,
ttl: DEFAULT_TTL,
domain: `${resolved.ipfsHash}.ipfs.dweb.link.`,
});
}
async function buildResponse(request) {
const response = makeResponse(request);
const question = request.questions && request.questions[0];
if (!question) {
return response;
}
const qname = normalizeName(question.name);
const qtype = question.type;
const isType = (name) => qtype === name || qtype === Packet.TYPE[name];
if (!qname.endsWith(".zil")) {
response.header.rcode = RCODE.NXDOMAIN;
return response;
}
const resolved = await resolveZilDomain(qname);
if (!resolved) {
response.header.rcode = RCODE.NXDOMAIN;
return response;
}
if (isType("TXT") || isType("ANY")) {
addTxtAnswers(response, question.name, resolved);
}
if (isType("CNAME") || isType("A") || isType("AAAA") || isType("ANY")) {
addCnameAnswer(response, question.name, resolved);
}
return response;
}
async function maybePrewarm() {
if (!PREWARM_DOMAIN) {
console.log("[zildns] prewarm disabled");
return;
}
const started = Date.now();
try {
await resolveZilDomain(PREWARM_DOMAIN);
console.log(`[zildns] prewarm complete for ${PREWARM_DOMAIN} in ${Date.now() - started}ms`);
} catch (err) {
console.warn(
`[zildns] prewarm failed for ${PREWARM_DOMAIN}:`,
err && err.message ? err.message : String(err),
);
}
}
async function start() {
await maybePrewarm();
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) => {
console.error("[zildns] startup failed:", err);
process.exit(1);
});