dns: add i2pdns bridge for .i2p resolution
This commit is contained in:
+85
-8
@@ -1,10 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Optional, Tuple
|
||||
|
||||
@@ -38,6 +39,13 @@ 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()
|
||||
@@ -59,6 +67,59 @@ def is_blocked_domain(hostname: str) -> bool:
|
||||
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()
|
||||
@@ -155,14 +216,30 @@ async def handle_client(client_reader: asyncio.StreamReader, client_writer: asyn
|
||||
|
||||
dst_port = struct.unpack("!H", await read_exact(client_reader, 2))[0]
|
||||
|
||||
upstream = pick_upstream(dst_host, atyp)
|
||||
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
|
||||
log(f"route peer={peer} dst={dst_host}:{dst_port} via={up_host}:{up_port}")
|
||||
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)
|
||||
@@ -179,19 +256,19 @@ async def handle_client(client_reader: asyncio.StreamReader, client_writer: asyn
|
||||
return
|
||||
|
||||
connect_req = bytearray(b"\x05\x01\x00")
|
||||
connect_req.append(atyp)
|
||||
if atyp == ATYP_DOMAIN:
|
||||
connect_req.append(connect_atyp)
|
||||
if connect_atyp == ATYP_DOMAIN:
|
||||
try:
|
||||
host_bytes = dst_host.encode("idna")
|
||||
host_bytes = connect_host.encode("idna")
|
||||
except UnicodeError:
|
||||
host_bytes = dst_host.encode("ascii", errors="ignore")
|
||||
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(raw_addr)
|
||||
connect_req.extend(connect_raw_addr)
|
||||
connect_req.extend(struct.pack("!H", dst_port))
|
||||
|
||||
upstream_writer.write(connect_req)
|
||||
|
||||
+1
-1
Submodule PopuraDNS updated: 13731b16f8...350f6d3b5d
@@ -84,8 +84,9 @@ Local resolution model (important):
|
||||
|
||||
* `*.onion` – forwarded to Tor's DNSPort running in the `darktor` container at
|
||||
10.5.0.7:9053.
|
||||
* `*.i2p` – served by the built-in DNS server inside the `darki2p` container
|
||||
(10.5.0.2:53) which is enabled via the `[dns]` section in `i2pd.conf`.
|
||||
* `*.i2p` – resolved by local `i2pdns` bridge service (`10.5.0.16:53`).
|
||||
- `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
|
||||
example:
|
||||
@@ -135,7 +136,7 @@ The stack now provides DNS resolution for a comprehensive set of alternative TLD
|
||||
| TLD | Resolver | Purpose |
|
||||
|-----|----------|---------|
|
||||
| `.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) |
|
||||
| `.bit` | namecoindns (10.5.0.12) via CoreDNS | Namecoin names (local resolver service) |
|
||||
| `.alt` | local unbound (10.5.0.5) via CoreDNS | Alternative DNS root (local-only fallback mode) |
|
||||
|
||||
+28
-2
@@ -13,10 +13,13 @@ services:
|
||||
dns_search: internal.namespace #namespace used in internal DNS
|
||||
environment:
|
||||
YGG_CONNECT_WAIT_SECONDS: "8"
|
||||
ROUTER_I2P_MAP_FILE: /var/lib/i2pdns/map.json
|
||||
ROUTER_I2P_POOL_CIDR: 172.31.0.0/16
|
||||
volumes:
|
||||
- "./3proxy/first-instanse.cfg:/etc/3proxy/first-instanse.cfg"
|
||||
- "./3proxy/second-instanse.cfg:/etc/3proxy/second-instanse.cfg"
|
||||
- "./3proxy/yggdrasil.conf:/etc/yggdrasil/yggdrasil.conf"
|
||||
- "i2p_dns_map:/var/lib/i2pdns:ro"
|
||||
restart: unless-stopped
|
||||
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"]
|
||||
@@ -56,8 +59,8 @@ services:
|
||||
# - "7654:7654" # I2CP
|
||||
# - "7656:7656" # SAM Bridge (TCP)
|
||||
- "10765:10765" # Main I2P Listener
|
||||
# [dns] section enabled in i2pd.conf; DNS port is 53 (accessible internally at 10.5.0.2:53)
|
||||
# mapping to host is possible if desired, e.g. 10853:53/udp
|
||||
# i2pd does not expose a DNS listener in this build.
|
||||
# .i2p DNS is provided by the dedicated i2pdns bridge service (10.5.0.16:53).
|
||||
# Yggdrasil Ports
|
||||
- "10654:10654" # Yggdrasil Listener
|
||||
environment:
|
||||
@@ -85,6 +88,27 @@ services:
|
||||
darkproxy:
|
||||
ipv4_address: 10.5.0.2
|
||||
|
||||
i2pdns:
|
||||
container_name: darki2pdns
|
||||
build:
|
||||
context: ./i2pdns
|
||||
dockerfile: Dockerfile
|
||||
platform: linux/amd64
|
||||
restart: unless-stopped
|
||||
sysctls:
|
||||
- "net.ipv6.conf.all.disable_ipv6=0"
|
||||
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"
|
||||
networks:
|
||||
darkproxy:
|
||||
ipv4_address: 10.5.0.16
|
||||
|
||||
tor_yggdrasil:
|
||||
build:
|
||||
context: ./tor_yggdrasil_docker
|
||||
@@ -277,6 +301,8 @@ volumes:
|
||||
name: emc_data
|
||||
tor-data:
|
||||
driver: local
|
||||
i2p_dns_map:
|
||||
name: i2p_dns_map
|
||||
|
||||
networks:
|
||||
darkproxy:
|
||||
|
||||
@@ -13,10 +13,13 @@ services:
|
||||
dns_search: internal.namespace #namespace used in internal DNS
|
||||
environment:
|
||||
YGG_CONNECT_WAIT_SECONDS: "8"
|
||||
ROUTER_I2P_MAP_FILE: /var/lib/i2pdns/map.json
|
||||
ROUTER_I2P_POOL_CIDR: 172.31.0.0/16
|
||||
volumes:
|
||||
- "./3proxy/first-instanse.cfg:/etc/3proxy/first-instanse.cfg"
|
||||
- "./3proxy/second-instanse.cfg:/etc/3proxy/second-instanse.cfg"
|
||||
- "./3proxy/yggdrasil.conf:/etc/yggdrasil/yggdrasil.conf"
|
||||
- "i2p_dns_map:/var/lib/i2pdns:ro"
|
||||
restart: unless-stopped
|
||||
cpus: "1.0"
|
||||
mem_reservation: 64m
|
||||
@@ -86,6 +89,30 @@ services:
|
||||
darkproxy:
|
||||
ipv4_address: 10.5.0.2
|
||||
|
||||
i2pdns:
|
||||
container_name: darki2pdns
|
||||
build:
|
||||
context: ./i2pdns
|
||||
dockerfile: Dockerfile
|
||||
platform: linux/amd64
|
||||
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"
|
||||
networks:
|
||||
darkproxy:
|
||||
ipv4_address: 10.5.0.16
|
||||
|
||||
tor_yggdrasil:
|
||||
build:
|
||||
context: ./tor_yggdrasil_docker
|
||||
@@ -461,6 +488,8 @@ volumes:
|
||||
name: tailscale_data
|
||||
grafana-data:
|
||||
name: grafana-data
|
||||
i2p_dns_map:
|
||||
name: i2p_dns_map
|
||||
|
||||
networks:
|
||||
darkproxy:
|
||||
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user