diff --git a/3proxy/socks_router.py b/3proxy/socks_router.py index 84f1f6f..5bffaee 100644 --- a/3proxy/socks_router.py +++ b/3proxy/socks_router.py @@ -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) diff --git a/PopuraDNS b/PopuraDNS index 13731b1..350f6d3 160000 --- a/PopuraDNS +++ b/PopuraDNS @@ -1 +1 @@ -Subproject commit 13731b16f863a0c78dbe0d237501f32801a37a8f +Subproject commit 350f6d3b5d1745c039e14de563729db7d20cafb1 diff --git a/README.md b/README.md index 65f3925..6ce8e7c 100644 --- a/README.md +++ b/README.md @@ -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) | diff --git a/docker-compose.lock.yml b/docker-compose.lock.yml index 2709a83..1355402 100644 --- a/docker-compose.lock.yml +++ b/docker-compose.lock.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index aa6c3a1..e3dc474 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/i2pdns/Dockerfile b/i2pdns/Dockerfile new file mode 100644 index 0000000..fbb1ceb --- /dev/null +++ b/i2pdns/Dockerfile @@ -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"] diff --git a/i2pdns/server.py b/i2pdns/server.py new file mode 100644 index 0000000..4650762 --- /dev/null +++ b/i2pdns/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()