import http.client import json import os import random import re import socket import struct import threading import time import urllib.parse from datetime import datetime, timezone from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer DASHBOARD_PORT = int(os.getenv("DASHBOARD_PORT", "8080")) DOCKER_SOCKET = os.getenv("DOCKER_SOCKET", "/var/run/docker.sock") DOCKER_PROJECT = os.getenv("DOCKER_PROJECT", "darkproxy") DNS_SERVER = os.getenv("DNS_SERVER", "darkpihole") DNS_PORT = int(os.getenv("DNS_PORT", "53")) PROXY_HOST = os.getenv("PROXY_HOST", "dark3proxy") PROXY_PORT = int(os.getenv("PROXY_PORT", "1080")) PROXY_USERS_FILE = os.getenv("PROXY_USERS_FILE", "/run/secrets/PROXY_USERS") SOCKET_TIMEOUT = float(os.getenv("SOCKET_TIMEOUT", "5")) STATUS_CACHE_SECONDS = int(os.getenv("STATUS_CACHE_SECONDS", "15")) LOG_TAIL_LINES = int(os.getenv("LOG_TAIL_LINES", "120")) ERROR_RE = re.compile(r"error|failed|not found|panic|traceback|unhealthy|refused|parse", re.IGNORECASE) IGNORE_RE = { "darkscale": re.compile(r"tpmrm0|Tailscale is stopped", re.IGNORECASE), "darkpihole": re.compile(r"refused to do a recursive query", re.IGNORECASE), "darki2p": re.compile( r"SessionCreated read error: End of file|" r"Connect error Operation canceled|" r"Connect error Network is unreachable|" r"RouterInfo for .* not found|" r"RouterInfo not found, failed to send messages|" r"NetDbReq: .* not found after 5 attempts", re.IGNORECASE, ), } RCODE_NAMES = { 0: "NOERROR", 1: "FORMERR", 2: "SERVFAIL", 3: "NXDOMAIN", 4: "NOTIMP", 5: "REFUSED", } DNS_CHECKS = [ { "name": "google.com", "record_type": "A", "severity": "critical", "label": "Public DNS path", "note": "Pi-hole -> CoreDNS -> Unbound", }, { "name": "facebookcorewwwi.onion", "record_type": "A", "severity": "critical", "label": ".onion resolution", "note": "Tor DNS path", }, { "name": "stats.i2p", "record_type": "A", "severity": "critical", "label": ".i2p resolution", "note": "i2pdns bridge path", }, { "name": "vitalik.eth", "record_type": "TXT", "severity": "warning", "label": ".eth resolution", "note": "ENS resolver path", }, { "name": "brad.zil", "record_type": "TXT", "severity": "warning", "label": ".zil resolution", "note": "Zilliqa resolver path", }, { "name": "d.bit", "record_type": "TXT", "severity": "info", "label": ".bit resolution", "note": "Sync-dependent Namecoin path", }, ] STATUS_CACHE = {"expires_at": 0.0, "payload": None} CACHE_LOCK = threading.Lock() class UnixSocketHTTPConnection(http.client.HTTPConnection): def __init__(self, socket_path: str, timeout: float): super().__init__("localhost", timeout=timeout) self.socket_path = socket_path def connect(self): self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) self.sock.settimeout(self.timeout) self.sock.connect(self.socket_path) def now_iso() -> str: return datetime.now(timezone.utc).isoformat() def docker_request(path: str) -> tuple[int, bytes]: conn = UnixSocketHTTPConnection(DOCKER_SOCKET, timeout=SOCKET_TIMEOUT) try: conn.request("GET", path) response = conn.getresponse() return response.status, response.read() finally: conn.close() def docker_json(path: str): status, payload = docker_request(path) if status >= 400: raise RuntimeError(f"Docker API request failed for {path}: HTTP {status}") return json.loads(payload.decode("utf-8")) def docker_bytes(path: str) -> bytes: status, payload = docker_request(path) if status >= 400: raise RuntimeError(f"Docker API request failed for {path}: HTTP {status}") return payload def decode_docker_stream(payload: bytes) -> str: # Docker may frame stdout/stderr for non-TTY containers using an 8-byte header. chunks = [] offset = 0 while offset + 8 <= len(payload): if payload[offset + 1 : offset + 4] != b"\x00\x00\x00": return payload.decode("utf-8", errors="replace") frame_len = struct.unpack(">I", payload[offset + 4 : offset + 8])[0] frame_start = offset + 8 frame_end = frame_start + frame_len if frame_end > len(payload): return payload.decode("utf-8", errors="replace") chunks.append(payload[frame_start:frame_end]) offset = frame_end if offset == len(payload): return b"".join(chunks).decode("utf-8", errors="replace") return payload.decode("utf-8", errors="replace") def service_log_text(container_id: str, tail: int) -> str: query = urllib.parse.urlencode({"stdout": 1, "stderr": 1, "tail": max(1, min(tail, 500))}) payload = docker_bytes(f"/containers/{container_id}/logs?{query}") return decode_docker_stream(payload).strip() def extract_error_excerpt(name: str, log_text: str) -> list[str]: ignore = IGNORE_RE.get(name) matches = [] for line in log_text.splitlines(): if not ERROR_RE.search(line): continue if ignore and ignore.search(line): continue matches.append(line) return matches[-6:] def read_proxy_users() -> list[dict[str, str]]: users = [] try: with open(PROXY_USERS_FILE, "r", encoding="utf-8") as handle: for raw_line in handle: line = raw_line.strip() if not line or line.startswith("#") or ":" not in line: continue username, password = line.split(":", 1) if username: users.append({"username": username, "password": password}) except FileNotFoundError: return [] return users def encode_dns_name(name: str) -> bytes: parts = name.rstrip(".").split(".") encoded = bytearray() for part in parts: label = part.encode("idna") encoded.append(len(label)) encoded.extend(label) encoded.append(0) return bytes(encoded) def read_dns_name(payload: bytes, offset: int) -> tuple[str, int]: labels = [] jumped = False next_offset = offset while True: length = payload[offset] if length == 0: offset += 1 if not jumped: next_offset = offset break if length & 0xC0 == 0xC0: pointer = struct.unpack(">H", payload[offset : offset + 2])[0] & 0x3FFF offset = pointer if not jumped: next_offset += 2 jumped = True continue offset += 1 labels.append(payload[offset : offset + length].decode("utf-8", errors="replace")) offset += length if not jumped: next_offset = offset return ".".join(labels), next_offset def parse_dns_answers(payload: bytes, qtype: int) -> tuple[str, list[str]]: _, _, _, answer_count, _, _ = struct.unpack(">HHHHHH", payload[:12]) offset = 12 for _ in range(1): _, offset = read_dns_name(payload, offset) offset += 4 answers = [] for _ in range(answer_count): _, offset = read_dns_name(payload, offset) record_type, _, _, rdlength = struct.unpack(">HHIH", payload[offset : offset + 10]) offset += 10 rdata = payload[offset : offset + rdlength] offset += rdlength if record_type == 1 and qtype == 1 and rdlength == 4: answers.append(socket.inet_ntoa(rdata)) elif record_type == 16 and qtype == 16 and rdlength > 0: strings = [] inner_offset = 0 while inner_offset < len(rdata): chunk_len = rdata[inner_offset] inner_offset += 1 strings.append(rdata[inner_offset : inner_offset + chunk_len].decode("utf-8", errors="replace")) inner_offset += chunk_len answers.append("".join(strings)) rcode = payload[3] & 0x0F return RCODE_NAMES.get(rcode, f"RCODE_{rcode}"), answers def dns_query(name: str, record_type: str) -> dict[str, object]: qtype = 1 if record_type == "A" else 16 query_id = random.randint(0, 65535) flags = 0x0100 header = struct.pack(">HHHHHH", query_id, flags, 1, 0, 0, 0) question = encode_dns_name(name) + struct.pack(">HH", qtype, 1) message = header + question start = time.monotonic() with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: sock.settimeout(SOCKET_TIMEOUT) sock.sendto(message, (DNS_SERVER, DNS_PORT)) payload, _ = sock.recvfrom(4096) duration_ms = int((time.monotonic() - start) * 1000) status, answers = parse_dns_answers(payload, qtype) return {"status": status, "answers": answers, "latency_ms": duration_ms} def run_dns_check(spec: dict[str, str]) -> dict[str, object]: try: result = dns_query(spec["name"], spec["record_type"]) outcome = "ok" if result["status"] == "NOERROR" and result["answers"] else "failed" if spec["severity"] == "info" and result["status"] in {"NOERROR", "NXDOMAIN"}: outcome = "ok" detail = result["status"] if result["answers"]: detail = f"{result['status']} ({', '.join(result['answers'][:2])})" return { "kind": "dns", "label": spec["label"], "target": spec["name"], "record_type": spec["record_type"], "severity": spec["severity"], "outcome": outcome, "detail": detail, "note": spec["note"], "latency_ms": result["latency_ms"], } except Exception as exc: return { "kind": "dns", "label": spec["label"], "target": spec["name"], "record_type": spec["record_type"], "severity": spec["severity"], "outcome": "failed", "detail": str(exc), "note": spec["note"], "latency_ms": -1, } def recv_exact(sock: socket.socket, size: int) -> bytes: chunks = [] remaining = size while remaining > 0: data = sock.recv(remaining) if not data: raise RuntimeError("unexpected EOF") chunks.append(data) remaining -= len(data) return b"".join(chunks) def socks_reply_message(code: int) -> str: return { 0x00: "succeeded", 0x01: "general failure", 0x02: "connection not allowed", 0x03: "network unreachable", 0x04: "host unreachable", 0x05: "connection refused", 0x06: "TTL expired", 0x07: "command not supported", 0x08: "address type not supported", }.get(code, f"reply {code}") def run_proxy_check(users: list[dict[str, str]]) -> dict[str, object]: if not users: return { "kind": "proxy", "label": "SOCKS ingress", "target": f"{PROXY_HOST}:{PROXY_PORT}", "severity": "warning", "outcome": "skipped", "detail": "No proxy users found in secret", "note": "Shows auth + CONNECT readiness", "latency_ms": -1, } user = users[0] host = "example.com".encode("idna") start = time.monotonic() try: with socket.create_connection((PROXY_HOST, PROXY_PORT), timeout=SOCKET_TIMEOUT) as sock: sock.settimeout(SOCKET_TIMEOUT) sock.sendall(b"\x05\x01\x02") greeting = recv_exact(sock, 2) if greeting != b"\x05\x02": raise RuntimeError(f"unexpected auth method {greeting!r}") username = user["username"].encode("utf-8") password = user["password"].encode("utf-8") sock.sendall(bytes([0x01, len(username)]) + username + bytes([len(password)]) + password) auth_reply = recv_exact(sock, 2) if auth_reply[1] != 0x00: raise RuntimeError("authentication failed") request = b"\x05\x01\x00\x03" + bytes([len(host)]) + host + struct.pack(">H", 80) sock.sendall(request) header = recv_exact(sock, 4) reply_code = header[1] addr_type = header[3] if addr_type == 0x01: recv_exact(sock, 4) elif addr_type == 0x03: domain_len = recv_exact(sock, 1)[0] recv_exact(sock, domain_len) elif addr_type == 0x04: recv_exact(sock, 16) recv_exact(sock, 2) if reply_code != 0x00: raise RuntimeError(socks_reply_message(reply_code)) except Exception as exc: return { "kind": "proxy", "label": "SOCKS ingress", "target": f"{PROXY_HOST}:{PROXY_PORT}", "severity": "critical", "outcome": "failed", "detail": str(exc), "note": "Shows auth + CONNECT readiness", "latency_ms": -1, } return { "kind": "proxy", "label": "SOCKS ingress", "target": f"{PROXY_HOST}:{PROXY_PORT}", "severity": "critical", "outcome": "ok", "detail": f"Authenticated as {user['username']} and opened CONNECT tunnel", "note": "Shows auth + CONNECT readiness", "latency_ms": int((time.monotonic() - start) * 1000), } def service_severity(state: str, health: str, restart_count: int, error_excerpt: list[str]) -> str: if state in {"exited", "dead"} or health == "unhealthy": return "critical" if state != "running" or health == "starting" or restart_count > 0 or error_excerpt: return "degraded" return "healthy" def list_project_containers() -> list[dict[str, object]]: filters = urllib.parse.quote(json.dumps({"label": [f"com.docker.compose.project={DOCKER_PROJECT}"]}, separators=(",", ":"))) raw_containers = docker_json(f"/containers/json?all=1&filters={filters}") services = [] for item in raw_containers: name = item.get("Names", [item["Id"]])[0].lstrip("/") inspect = docker_json(f"/containers/{item['Id']}/json") state = inspect["State"]["Status"] health = inspect["State"].get("Health", {}).get("Status", "none") restart_count = inspect.get("RestartCount", 0) logs = service_log_text(item["Id"], LOG_TAIL_LINES) error_excerpt = extract_error_excerpt(name, logs) services.append( { "id": item["Id"], "service": item.get("Labels", {}).get("com.docker.compose.service", name), "container_name": name, "state": state, "status_text": item.get("Status", state), "health": health, "restart_count": restart_count, "image": item.get("Image", ""), "ports": item.get("Ports", []), "severity": service_severity(state, health, restart_count, error_excerpt), "error_excerpt": error_excerpt, } ) return sorted(services, key=lambda svc: svc["service"]) def summarize_status(services: list[dict[str, object]], checks: list[dict[str, object]]) -> dict[str, object]: critical_services = sum(1 for svc in services if svc["severity"] == "critical") degraded_services = sum(1 for svc in services if svc["severity"] == "degraded") failed_critical_checks = sum(1 for check in checks if check["severity"] == "critical" and check["outcome"] != "ok") failed_warning_checks = sum(1 for check in checks if check["severity"] == "warning" and check["outcome"] != "ok") if critical_services or failed_critical_checks: overall = "critical" elif degraded_services or failed_warning_checks: overall = "degraded" else: overall = "healthy" return { "overall": overall, "service_counts": { "total": len(services), "healthy": sum(1 for svc in services if svc["severity"] == "healthy"), "degraded": degraded_services, "critical": critical_services, }, "check_counts": { "total": len(checks), "ok": sum(1 for check in checks if check["outcome"] == "ok"), "failed": sum(1 for check in checks if check["outcome"] == "failed"), "skipped": sum(1 for check in checks if check["outcome"] == "skipped"), }, } def build_status_payload() -> dict[str, object]: services = list_project_containers() users = read_proxy_users() checks = [run_dns_check(spec) for spec in DNS_CHECKS] checks.append(run_proxy_check(users)) summary = summarize_status(services, checks) return { "generated_at": now_iso(), "project": DOCKER_PROJECT, "summary": summary, "services": services, "checks": checks, "users": [{"username": entry["username"]} for entry in users], } def get_status_payload(force_refresh: bool = False) -> dict[str, object]: now = time.monotonic() with CACHE_LOCK: if not force_refresh and STATUS_CACHE["payload"] is not None and now < STATUS_CACHE["expires_at"]: return STATUS_CACHE["payload"] payload = build_status_payload() STATUS_CACHE["payload"] = payload STATUS_CACHE["expires_at"] = now + STATUS_CACHE_SECONDS return payload def find_service(identifier: str) -> dict[str, object] | None: payload = get_status_payload() for service in payload["services"]: if service["service"] == identifier or service["container_name"] == identifier: return service return None HTML = """ darkproxy status

darkproxy status

Loading…
JSON

Services

Service State Health Restarts Errors

Users

Configured proxy usernames only. Passwords are never shown.

    Checks

    Check Target Severity Result Latency Detail

    Logs

    Select a service to view recent logs.
    """ class Handler(BaseHTTPRequestHandler): def send_json(self, payload: dict[str, object], status: int = 200): body = json.dumps(payload, indent=2).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() try: self.wfile.write(body) except BrokenPipeError: return def send_html(self, body: str, status: int = 200): payload = body.encode("utf-8") self.send_response(status) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(payload))) self.end_headers() try: self.wfile.write(payload) except BrokenPipeError: return def do_GET(self): parsed = urllib.parse.urlparse(self.path) params = urllib.parse.parse_qs(parsed.query) try: if parsed.path == "/": self.send_html(HTML) return if parsed.path == "/api/status": force_refresh = params.get("refresh", ["0"])[0] == "1" self.send_json(get_status_payload(force_refresh=force_refresh)) return if parsed.path == "/healthz": self.send_json({"ok": True, "generated_at": now_iso()}) return if parsed.path == "/api/users": users = [{"username": entry["username"]} for entry in read_proxy_users()] self.send_json({"users": users}) return if parsed.path == "/api/logs": service_name = params.get("service", [""])[0] if not service_name: self.send_json({"error": "missing service parameter"}, status=400) return service = find_service(service_name) if service is None: self.send_json({"error": f"unknown service {service_name}"}, status=404) return tail = int(params.get("tail", ["200"])[0]) self.send_json( { "service": service["service"], "container_name": service["container_name"], "logs": service_log_text(service["id"], tail), } ) return self.send_json({"error": "not found"}, status=404) except BrokenPipeError: return except Exception as exc: self.send_json({"error": str(exc)}, status=500) def log_message(self, format, *args): return if __name__ == "__main__": server = ThreadingHTTPServer(("0.0.0.0", DASHBOARD_PORT), Handler) server.serve_forever()