881 lines
30 KiB
Python
881 lines
30 KiB
Python
import http.client
|
|
import json
|
|
import os
|
|
import random
|
|
import re
|
|
import socket
|
|
import struct
|
|
import threading
|
|
import time
|
|
import urllib.parse
|
|
from datetime import datetime, timezone
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
|
|
|
DASHBOARD_PORT = int(os.getenv("DASHBOARD_PORT", "8080"))
|
|
DOCKER_SOCKET = os.getenv("DOCKER_SOCKET", "/var/run/docker.sock")
|
|
DOCKER_PROJECT = os.getenv("DOCKER_PROJECT", "darkproxy")
|
|
DNS_SERVER = os.getenv("DNS_SERVER", "darkpihole")
|
|
DNS_PORT = int(os.getenv("DNS_PORT", "53"))
|
|
PROXY_HOST = os.getenv("PROXY_HOST", "dark3proxy")
|
|
PROXY_PORT = int(os.getenv("PROXY_PORT", "1080"))
|
|
PROXY_USERS_FILE = os.getenv("PROXY_USERS_FILE", "/run/secrets/PROXY_USERS")
|
|
SOCKET_TIMEOUT = float(os.getenv("SOCKET_TIMEOUT", "5"))
|
|
STATUS_CACHE_SECONDS = int(os.getenv("STATUS_CACHE_SECONDS", "15"))
|
|
LOG_TAIL_LINES = int(os.getenv("LOG_TAIL_LINES", "120"))
|
|
|
|
ERROR_RE = re.compile(r"error|failed|not found|panic|traceback|unhealthy|refused|parse", re.IGNORECASE)
|
|
IGNORE_RE = {
|
|
"darkscale": re.compile(r"tpmrm0|Tailscale is stopped", re.IGNORECASE),
|
|
"darkpihole": re.compile(r"refused to do a recursive query", re.IGNORECASE),
|
|
"darki2p": re.compile(
|
|
r"SessionCreated read error: End of file|"
|
|
r"Connect error Operation canceled|"
|
|
r"Connect error Network is unreachable|"
|
|
r"RouterInfo for .* not found|"
|
|
r"RouterInfo not found, failed to send messages|"
|
|
r"NetDbReq: .* not found after 5 attempts",
|
|
re.IGNORECASE,
|
|
),
|
|
}
|
|
RCODE_NAMES = {
|
|
0: "NOERROR",
|
|
1: "FORMERR",
|
|
2: "SERVFAIL",
|
|
3: "NXDOMAIN",
|
|
4: "NOTIMP",
|
|
5: "REFUSED",
|
|
}
|
|
DNS_CHECKS = [
|
|
{
|
|
"name": "google.com",
|
|
"record_type": "A",
|
|
"severity": "critical",
|
|
"label": "Public DNS path",
|
|
"note": "Pi-hole -> CoreDNS -> Unbound",
|
|
},
|
|
{
|
|
"name": "facebookcorewwwi.onion",
|
|
"record_type": "A",
|
|
"severity": "critical",
|
|
"label": ".onion resolution",
|
|
"note": "Tor DNS path",
|
|
},
|
|
{
|
|
"name": "stats.i2p",
|
|
"record_type": "A",
|
|
"severity": "critical",
|
|
"label": ".i2p resolution",
|
|
"note": "i2pdns bridge path",
|
|
},
|
|
{
|
|
"name": "vitalik.eth",
|
|
"record_type": "TXT",
|
|
"severity": "warning",
|
|
"label": ".eth resolution",
|
|
"note": "ENS resolver path",
|
|
},
|
|
{
|
|
"name": "brad.zil",
|
|
"record_type": "TXT",
|
|
"severity": "warning",
|
|
"label": ".zil resolution",
|
|
"note": "Zilliqa resolver path",
|
|
},
|
|
{
|
|
"name": "d.bit",
|
|
"record_type": "TXT",
|
|
"severity": "info",
|
|
"label": ".bit resolution",
|
|
"note": "Sync-dependent Namecoin path",
|
|
},
|
|
]
|
|
|
|
STATUS_CACHE = {"expires_at": 0.0, "payload": None}
|
|
CACHE_LOCK = threading.Lock()
|
|
|
|
|
|
class UnixSocketHTTPConnection(http.client.HTTPConnection):
|
|
def __init__(self, socket_path: str, timeout: float):
|
|
super().__init__("localhost", timeout=timeout)
|
|
self.socket_path = socket_path
|
|
|
|
def connect(self):
|
|
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
self.sock.settimeout(self.timeout)
|
|
self.sock.connect(self.socket_path)
|
|
|
|
|
|
def now_iso() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def docker_request(path: str) -> tuple[int, bytes]:
|
|
conn = UnixSocketHTTPConnection(DOCKER_SOCKET, timeout=SOCKET_TIMEOUT)
|
|
try:
|
|
conn.request("GET", path)
|
|
response = conn.getresponse()
|
|
return response.status, response.read()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def docker_json(path: str):
|
|
status, payload = docker_request(path)
|
|
if status >= 400:
|
|
raise RuntimeError(f"Docker API request failed for {path}: HTTP {status}")
|
|
return json.loads(payload.decode("utf-8"))
|
|
|
|
|
|
def docker_bytes(path: str) -> bytes:
|
|
status, payload = docker_request(path)
|
|
if status >= 400:
|
|
raise RuntimeError(f"Docker API request failed for {path}: HTTP {status}")
|
|
return payload
|
|
|
|
|
|
def decode_docker_stream(payload: bytes) -> str:
|
|
# Docker may frame stdout/stderr for non-TTY containers using an 8-byte header.
|
|
chunks = []
|
|
offset = 0
|
|
while offset + 8 <= len(payload):
|
|
if payload[offset + 1 : offset + 4] != b"\x00\x00\x00":
|
|
return payload.decode("utf-8", errors="replace")
|
|
frame_len = struct.unpack(">I", payload[offset + 4 : offset + 8])[0]
|
|
frame_start = offset + 8
|
|
frame_end = frame_start + frame_len
|
|
if frame_end > len(payload):
|
|
return payload.decode("utf-8", errors="replace")
|
|
chunks.append(payload[frame_start:frame_end])
|
|
offset = frame_end
|
|
if offset == len(payload):
|
|
return b"".join(chunks).decode("utf-8", errors="replace")
|
|
return payload.decode("utf-8", errors="replace")
|
|
|
|
|
|
def service_log_text(container_id: str, tail: int) -> str:
|
|
query = urllib.parse.urlencode({"stdout": 1, "stderr": 1, "tail": max(1, min(tail, 500))})
|
|
payload = docker_bytes(f"/containers/{container_id}/logs?{query}")
|
|
return decode_docker_stream(payload).strip()
|
|
|
|
|
|
def extract_error_excerpt(name: str, log_text: str) -> list[str]:
|
|
ignore = IGNORE_RE.get(name)
|
|
matches = []
|
|
for line in log_text.splitlines():
|
|
if not ERROR_RE.search(line):
|
|
continue
|
|
if ignore and ignore.search(line):
|
|
continue
|
|
matches.append(line)
|
|
return matches[-6:]
|
|
|
|
|
|
def read_proxy_users() -> list[dict[str, str]]:
|
|
users = []
|
|
try:
|
|
with open(PROXY_USERS_FILE, "r", encoding="utf-8") as handle:
|
|
for raw_line in handle:
|
|
line = raw_line.strip()
|
|
if not line or line.startswith("#") or ":" not in line:
|
|
continue
|
|
username, password = line.split(":", 1)
|
|
if username:
|
|
users.append({"username": username, "password": password})
|
|
except FileNotFoundError:
|
|
return []
|
|
return users
|
|
|
|
|
|
def encode_dns_name(name: str) -> bytes:
|
|
parts = name.rstrip(".").split(".")
|
|
encoded = bytearray()
|
|
for part in parts:
|
|
label = part.encode("idna")
|
|
encoded.append(len(label))
|
|
encoded.extend(label)
|
|
encoded.append(0)
|
|
return bytes(encoded)
|
|
|
|
|
|
def read_dns_name(payload: bytes, offset: int) -> tuple[str, int]:
|
|
labels = []
|
|
jumped = False
|
|
next_offset = offset
|
|
while True:
|
|
length = payload[offset]
|
|
if length == 0:
|
|
offset += 1
|
|
if not jumped:
|
|
next_offset = offset
|
|
break
|
|
if length & 0xC0 == 0xC0:
|
|
pointer = struct.unpack(">H", payload[offset : offset + 2])[0] & 0x3FFF
|
|
offset = pointer
|
|
if not jumped:
|
|
next_offset += 2
|
|
jumped = True
|
|
continue
|
|
offset += 1
|
|
labels.append(payload[offset : offset + length].decode("utf-8", errors="replace"))
|
|
offset += length
|
|
if not jumped:
|
|
next_offset = offset
|
|
return ".".join(labels), next_offset
|
|
|
|
|
|
def parse_dns_answers(payload: bytes, qtype: int) -> tuple[str, list[str]]:
|
|
_, _, _, answer_count, _, _ = struct.unpack(">HHHHHH", payload[:12])
|
|
offset = 12
|
|
for _ in range(1):
|
|
_, offset = read_dns_name(payload, offset)
|
|
offset += 4
|
|
answers = []
|
|
for _ in range(answer_count):
|
|
_, offset = read_dns_name(payload, offset)
|
|
record_type, _, _, rdlength = struct.unpack(">HHIH", payload[offset : offset + 10])
|
|
offset += 10
|
|
rdata = payload[offset : offset + rdlength]
|
|
offset += rdlength
|
|
if record_type == 1 and qtype == 1 and rdlength == 4:
|
|
answers.append(socket.inet_ntoa(rdata))
|
|
elif record_type == 16 and qtype == 16 and rdlength > 0:
|
|
strings = []
|
|
inner_offset = 0
|
|
while inner_offset < len(rdata):
|
|
chunk_len = rdata[inner_offset]
|
|
inner_offset += 1
|
|
strings.append(rdata[inner_offset : inner_offset + chunk_len].decode("utf-8", errors="replace"))
|
|
inner_offset += chunk_len
|
|
answers.append("".join(strings))
|
|
rcode = payload[3] & 0x0F
|
|
return RCODE_NAMES.get(rcode, f"RCODE_{rcode}"), answers
|
|
|
|
|
|
def dns_query(name: str, record_type: str) -> dict[str, object]:
|
|
qtype = 1 if record_type == "A" else 16
|
|
query_id = random.randint(0, 65535)
|
|
flags = 0x0100
|
|
header = struct.pack(">HHHHHH", query_id, flags, 1, 0, 0, 0)
|
|
question = encode_dns_name(name) + struct.pack(">HH", qtype, 1)
|
|
message = header + question
|
|
start = time.monotonic()
|
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
|
sock.settimeout(SOCKET_TIMEOUT)
|
|
sock.sendto(message, (DNS_SERVER, DNS_PORT))
|
|
payload, _ = sock.recvfrom(4096)
|
|
duration_ms = int((time.monotonic() - start) * 1000)
|
|
status, answers = parse_dns_answers(payload, qtype)
|
|
return {"status": status, "answers": answers, "latency_ms": duration_ms}
|
|
|
|
|
|
def run_dns_check(spec: dict[str, str]) -> dict[str, object]:
|
|
try:
|
|
result = dns_query(spec["name"], spec["record_type"])
|
|
outcome = "ok" if result["status"] == "NOERROR" and result["answers"] else "failed"
|
|
if spec["severity"] == "info" and result["status"] in {"NOERROR", "NXDOMAIN"}:
|
|
outcome = "ok"
|
|
detail = result["status"]
|
|
if result["answers"]:
|
|
detail = f"{result['status']} ({', '.join(result['answers'][:2])})"
|
|
return {
|
|
"kind": "dns",
|
|
"label": spec["label"],
|
|
"target": spec["name"],
|
|
"record_type": spec["record_type"],
|
|
"severity": spec["severity"],
|
|
"outcome": outcome,
|
|
"detail": detail,
|
|
"note": spec["note"],
|
|
"latency_ms": result["latency_ms"],
|
|
}
|
|
except Exception as exc:
|
|
return {
|
|
"kind": "dns",
|
|
"label": spec["label"],
|
|
"target": spec["name"],
|
|
"record_type": spec["record_type"],
|
|
"severity": spec["severity"],
|
|
"outcome": "failed",
|
|
"detail": str(exc),
|
|
"note": spec["note"],
|
|
"latency_ms": -1,
|
|
}
|
|
|
|
|
|
def recv_exact(sock: socket.socket, size: int) -> bytes:
|
|
chunks = []
|
|
remaining = size
|
|
while remaining > 0:
|
|
data = sock.recv(remaining)
|
|
if not data:
|
|
raise RuntimeError("unexpected EOF")
|
|
chunks.append(data)
|
|
remaining -= len(data)
|
|
return b"".join(chunks)
|
|
|
|
|
|
def socks_reply_message(code: int) -> str:
|
|
return {
|
|
0x00: "succeeded",
|
|
0x01: "general failure",
|
|
0x02: "connection not allowed",
|
|
0x03: "network unreachable",
|
|
0x04: "host unreachable",
|
|
0x05: "connection refused",
|
|
0x06: "TTL expired",
|
|
0x07: "command not supported",
|
|
0x08: "address type not supported",
|
|
}.get(code, f"reply {code}")
|
|
|
|
|
|
def run_proxy_check(users: list[dict[str, str]]) -> dict[str, object]:
|
|
if not users:
|
|
return {
|
|
"kind": "proxy",
|
|
"label": "SOCKS ingress",
|
|
"target": f"{PROXY_HOST}:{PROXY_PORT}",
|
|
"severity": "warning",
|
|
"outcome": "skipped",
|
|
"detail": "No proxy users found in secret",
|
|
"note": "Shows auth + CONNECT readiness",
|
|
"latency_ms": -1,
|
|
}
|
|
user = users[0]
|
|
host = "example.com".encode("idna")
|
|
start = time.monotonic()
|
|
try:
|
|
with socket.create_connection((PROXY_HOST, PROXY_PORT), timeout=SOCKET_TIMEOUT) as sock:
|
|
sock.settimeout(SOCKET_TIMEOUT)
|
|
sock.sendall(b"\x05\x01\x02")
|
|
greeting = recv_exact(sock, 2)
|
|
if greeting != b"\x05\x02":
|
|
raise RuntimeError(f"unexpected auth method {greeting!r}")
|
|
|
|
username = user["username"].encode("utf-8")
|
|
password = user["password"].encode("utf-8")
|
|
sock.sendall(bytes([0x01, len(username)]) + username + bytes([len(password)]) + password)
|
|
auth_reply = recv_exact(sock, 2)
|
|
if auth_reply[1] != 0x00:
|
|
raise RuntimeError("authentication failed")
|
|
|
|
request = b"\x05\x01\x00\x03" + bytes([len(host)]) + host + struct.pack(">H", 80)
|
|
sock.sendall(request)
|
|
header = recv_exact(sock, 4)
|
|
reply_code = header[1]
|
|
addr_type = header[3]
|
|
if addr_type == 0x01:
|
|
recv_exact(sock, 4)
|
|
elif addr_type == 0x03:
|
|
domain_len = recv_exact(sock, 1)[0]
|
|
recv_exact(sock, domain_len)
|
|
elif addr_type == 0x04:
|
|
recv_exact(sock, 16)
|
|
recv_exact(sock, 2)
|
|
if reply_code != 0x00:
|
|
raise RuntimeError(socks_reply_message(reply_code))
|
|
except Exception as exc:
|
|
return {
|
|
"kind": "proxy",
|
|
"label": "SOCKS ingress",
|
|
"target": f"{PROXY_HOST}:{PROXY_PORT}",
|
|
"severity": "critical",
|
|
"outcome": "failed",
|
|
"detail": str(exc),
|
|
"note": "Shows auth + CONNECT readiness",
|
|
"latency_ms": -1,
|
|
}
|
|
|
|
return {
|
|
"kind": "proxy",
|
|
"label": "SOCKS ingress",
|
|
"target": f"{PROXY_HOST}:{PROXY_PORT}",
|
|
"severity": "critical",
|
|
"outcome": "ok",
|
|
"detail": f"Authenticated as {user['username']} and opened CONNECT tunnel",
|
|
"note": "Shows auth + CONNECT readiness",
|
|
"latency_ms": int((time.monotonic() - start) * 1000),
|
|
}
|
|
|
|
|
|
def service_severity(state: str, health: str, restart_count: int, error_excerpt: list[str]) -> str:
|
|
if state in {"exited", "dead"} or health == "unhealthy":
|
|
return "critical"
|
|
if state != "running" or health == "starting" or restart_count > 0 or error_excerpt:
|
|
return "degraded"
|
|
return "healthy"
|
|
|
|
|
|
def list_project_containers() -> list[dict[str, object]]:
|
|
filters = urllib.parse.quote(json.dumps({"label": [f"com.docker.compose.project={DOCKER_PROJECT}"]}, separators=(",", ":")))
|
|
raw_containers = docker_json(f"/containers/json?all=1&filters={filters}")
|
|
services = []
|
|
for item in raw_containers:
|
|
name = item.get("Names", [item["Id"]])[0].lstrip("/")
|
|
inspect = docker_json(f"/containers/{item['Id']}/json")
|
|
state = inspect["State"]["Status"]
|
|
health = inspect["State"].get("Health", {}).get("Status", "none")
|
|
restart_count = inspect.get("RestartCount", 0)
|
|
logs = service_log_text(item["Id"], LOG_TAIL_LINES)
|
|
error_excerpt = extract_error_excerpt(name, logs)
|
|
services.append(
|
|
{
|
|
"id": item["Id"],
|
|
"service": item.get("Labels", {}).get("com.docker.compose.service", name),
|
|
"container_name": name,
|
|
"state": state,
|
|
"status_text": item.get("Status", state),
|
|
"health": health,
|
|
"restart_count": restart_count,
|
|
"image": item.get("Image", ""),
|
|
"ports": item.get("Ports", []),
|
|
"severity": service_severity(state, health, restart_count, error_excerpt),
|
|
"error_excerpt": error_excerpt,
|
|
}
|
|
)
|
|
return sorted(services, key=lambda svc: svc["service"])
|
|
|
|
|
|
def summarize_status(services: list[dict[str, object]], checks: list[dict[str, object]]) -> dict[str, object]:
|
|
critical_services = sum(1 for svc in services if svc["severity"] == "critical")
|
|
degraded_services = sum(1 for svc in services if svc["severity"] == "degraded")
|
|
failed_critical_checks = sum(1 for check in checks if check["severity"] == "critical" and check["outcome"] != "ok")
|
|
failed_warning_checks = sum(1 for check in checks if check["severity"] == "warning" and check["outcome"] != "ok")
|
|
|
|
if critical_services or failed_critical_checks:
|
|
overall = "critical"
|
|
elif degraded_services or failed_warning_checks:
|
|
overall = "degraded"
|
|
else:
|
|
overall = "healthy"
|
|
|
|
return {
|
|
"overall": overall,
|
|
"service_counts": {
|
|
"total": len(services),
|
|
"healthy": sum(1 for svc in services if svc["severity"] == "healthy"),
|
|
"degraded": degraded_services,
|
|
"critical": critical_services,
|
|
},
|
|
"check_counts": {
|
|
"total": len(checks),
|
|
"ok": sum(1 for check in checks if check["outcome"] == "ok"),
|
|
"failed": sum(1 for check in checks if check["outcome"] == "failed"),
|
|
"skipped": sum(1 for check in checks if check["outcome"] == "skipped"),
|
|
},
|
|
}
|
|
|
|
|
|
def build_status_payload() -> dict[str, object]:
|
|
services = list_project_containers()
|
|
users = read_proxy_users()
|
|
checks = [run_dns_check(spec) for spec in DNS_CHECKS]
|
|
checks.append(run_proxy_check(users))
|
|
summary = summarize_status(services, checks)
|
|
return {
|
|
"generated_at": now_iso(),
|
|
"project": DOCKER_PROJECT,
|
|
"summary": summary,
|
|
"services": services,
|
|
"checks": checks,
|
|
"users": [{"username": entry["username"]} for entry in users],
|
|
}
|
|
|
|
|
|
def get_status_payload(force_refresh: bool = False) -> dict[str, object]:
|
|
now = time.monotonic()
|
|
with CACHE_LOCK:
|
|
if not force_refresh and STATUS_CACHE["payload"] is not None and now < STATUS_CACHE["expires_at"]:
|
|
return STATUS_CACHE["payload"]
|
|
payload = build_status_payload()
|
|
STATUS_CACHE["payload"] = payload
|
|
STATUS_CACHE["expires_at"] = now + STATUS_CACHE_SECONDS
|
|
return payload
|
|
|
|
|
|
def find_service(identifier: str) -> dict[str, object] | None:
|
|
payload = get_status_payload()
|
|
for service in payload["services"]:
|
|
if service["service"] == identifier or service["container_name"] == identifier:
|
|
return service
|
|
return None
|
|
|
|
|
|
HTML = """<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>darkproxy status</title>
|
|
<style>
|
|
:root {
|
|
color-scheme: dark;
|
|
--bg: #0f172a;
|
|
--panel: #111827;
|
|
--panel-2: #1f2937;
|
|
--text: #e5e7eb;
|
|
--muted: #94a3b8;
|
|
--healthy: #22c55e;
|
|
--degraded: #f59e0b;
|
|
--critical: #ef4444;
|
|
--border: #334155;
|
|
}
|
|
body {
|
|
margin: 0;
|
|
font-family: system-ui, sans-serif;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
}
|
|
.wrap {
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
padding: 24px;
|
|
}
|
|
h1, h2 {
|
|
margin: 0 0 12px;
|
|
}
|
|
.topbar, .cards, .panel-grid {
|
|
display: grid;
|
|
gap: 16px;
|
|
}
|
|
.topbar {
|
|
grid-template-columns: 1fr auto auto;
|
|
align-items: center;
|
|
margin-bottom: 16px;
|
|
}
|
|
.cards {
|
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
margin-bottom: 16px;
|
|
}
|
|
.card, .panel {
|
|
background: var(--panel);
|
|
border: 1px solid var(--border);
|
|
border-radius: 12px;
|
|
padding: 16px;
|
|
}
|
|
.panel-grid {
|
|
grid-template-columns: 2fr 1fr;
|
|
margin-bottom: 16px;
|
|
}
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 14px;
|
|
}
|
|
th, td {
|
|
padding: 10px 8px;
|
|
border-bottom: 1px solid var(--border);
|
|
text-align: left;
|
|
vertical-align: top;
|
|
}
|
|
.badge {
|
|
display: inline-block;
|
|
padding: 2px 8px;
|
|
border-radius: 999px;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
}
|
|
.healthy { color: var(--healthy); }
|
|
.degraded { color: var(--degraded); }
|
|
.critical { color: var(--critical); }
|
|
.badge.healthy { background: rgba(34, 197, 94, 0.15); }
|
|
.badge.degraded { background: rgba(245, 158, 11, 0.15); }
|
|
.badge.critical { background: rgba(239, 68, 68, 0.15); }
|
|
.badge.info { background: rgba(148, 163, 184, 0.15); color: var(--muted); }
|
|
.muted {
|
|
color: var(--muted);
|
|
font-size: 13px;
|
|
}
|
|
button, select {
|
|
background: var(--panel-2);
|
|
color: var(--text);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 8px 12px;
|
|
}
|
|
pre {
|
|
background: #020617;
|
|
border: 1px solid var(--border);
|
|
border-radius: 12px;
|
|
padding: 16px;
|
|
overflow: auto;
|
|
min-height: 220px;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
}
|
|
ul {
|
|
margin: 0;
|
|
padding-left: 18px;
|
|
}
|
|
.error-list {
|
|
margin: 0;
|
|
padding-left: 16px;
|
|
}
|
|
@media (max-width: 960px) {
|
|
.panel-grid, .topbar {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
<div class="topbar">
|
|
<div>
|
|
<h1>darkproxy status</h1>
|
|
<div class="muted" id="updated-at">Loading…</div>
|
|
</div>
|
|
<button id="refresh-btn">Refresh now</button>
|
|
<a class="muted" href="/api/status" target="_blank" rel="noreferrer">JSON</a>
|
|
</div>
|
|
|
|
<div class="cards" id="summary-cards"></div>
|
|
|
|
<div class="panel-grid">
|
|
<div class="panel">
|
|
<h2>Services</h2>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Service</th>
|
|
<th>State</th>
|
|
<th>Health</th>
|
|
<th>Restarts</th>
|
|
<th>Errors</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="services-body"></tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<h2>Users</h2>
|
|
<div class="muted">Configured proxy usernames only. Passwords are never shown.</div>
|
|
<ul id="users-list"></ul>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel" style="margin-bottom: 16px;">
|
|
<h2>Checks</h2>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Check</th>
|
|
<th>Target</th>
|
|
<th>Severity</th>
|
|
<th>Result</th>
|
|
<th>Latency</th>
|
|
<th>Detail</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="checks-body"></tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<div style="display:flex; gap:12px; align-items:center; margin-bottom:12px; flex-wrap:wrap;">
|
|
<h2 style="margin:0;">Logs</h2>
|
|
<select id="logs-service"></select>
|
|
<button id="load-logs-btn">Load logs</button>
|
|
</div>
|
|
<pre id="logs-output">Select a service to view recent logs.</pre>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let lastStatus = null;
|
|
|
|
function badgeClass(value) {
|
|
return ["healthy", "degraded", "critical"].includes(value) ? value : "info";
|
|
}
|
|
|
|
function esc(value) {
|
|
return String(value ?? "").replace(/[&<>"]/g, (ch) => ({
|
|
"&": "&",
|
|
"<": "<",
|
|
">": ">",
|
|
'"': """
|
|
}[ch]));
|
|
}
|
|
|
|
function renderSummary(summary) {
|
|
const cards = [
|
|
["Overall", summary.overall],
|
|
["Services", `${summary.service_counts.healthy}/${summary.service_counts.total} healthy`],
|
|
["Degraded", `${summary.service_counts.degraded}`],
|
|
["Critical", `${summary.service_counts.critical}`],
|
|
["Checks OK", `${summary.check_counts.ok}/${summary.check_counts.total}`]
|
|
];
|
|
document.getElementById("summary-cards").innerHTML = cards.map(([label, value]) => `
|
|
<div class="card">
|
|
<div class="muted">${esc(label)}</div>
|
|
<div class="badge ${badgeClass(String(value).toLowerCase())}">${esc(value)}</div>
|
|
</div>
|
|
`).join("");
|
|
}
|
|
|
|
function renderServices(services) {
|
|
document.getElementById("services-body").innerHTML = services.map((service) => `
|
|
<tr>
|
|
<td>
|
|
<strong>${esc(service.service)}</strong><br>
|
|
<span class="muted">${esc(service.container_name)}</span>
|
|
</td>
|
|
<td><span class="badge ${badgeClass(service.severity)}">${esc(service.state)}</span><br><span class="muted">${esc(service.status_text)}</span></td>
|
|
<td>${esc(service.health)}</td>
|
|
<td>${esc(service.restart_count)}</td>
|
|
<td>
|
|
${service.error_excerpt.length ? `<ul class="error-list">${service.error_excerpt.map((line) => `<li>${esc(line)}</li>`).join("")}</ul>` : '<span class="muted">none</span>'}
|
|
</td>
|
|
<td><button data-service="${esc(service.service)}">Logs</button></td>
|
|
</tr>
|
|
`).join("");
|
|
|
|
const select = document.getElementById("logs-service");
|
|
const current = select.value;
|
|
select.innerHTML = services.map((service) => `<option value="${esc(service.service)}">${esc(service.service)}</option>`).join("");
|
|
if (services.some((service) => service.service === current)) {
|
|
select.value = current;
|
|
}
|
|
|
|
document.querySelectorAll("button[data-service]").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
select.value = button.dataset.service;
|
|
loadLogs();
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderChecks(checks) {
|
|
document.getElementById("checks-body").innerHTML = checks.map((check) => `
|
|
<tr>
|
|
<td><strong>${esc(check.label)}</strong><br><span class="muted">${esc(check.note)}</span></td>
|
|
<td>${esc(check.target)} ${check.record_type ? `<span class="muted">${esc(check.record_type)}</span>` : ""}</td>
|
|
<td><span class="badge ${badgeClass(check.severity === "warning" ? "degraded" : check.severity)}">${esc(check.severity)}</span></td>
|
|
<td><span class="badge ${badgeClass(check.outcome === "ok" ? "healthy" : (check.outcome === "skipped" ? "info" : "critical"))}">${esc(check.outcome)}</span></td>
|
|
<td>${check.latency_ms >= 0 ? `${esc(check.latency_ms)} ms` : '<span class="muted">n/a</span>'}</td>
|
|
<td>${esc(check.detail)}</td>
|
|
</tr>
|
|
`).join("");
|
|
}
|
|
|
|
function renderUsers(users) {
|
|
const list = document.getElementById("users-list");
|
|
if (!users.length) {
|
|
list.innerHTML = "<li class='muted'>No users found</li>";
|
|
return;
|
|
}
|
|
list.innerHTML = users.map((user) => `<li>${esc(user.username)}</li>`).join("");
|
|
}
|
|
|
|
async function loadStatus(force = false) {
|
|
const suffix = force ? "?refresh=1" : "";
|
|
const response = await fetch(`/api/status${suffix}`);
|
|
const payload = await response.json();
|
|
lastStatus = payload;
|
|
document.getElementById("updated-at").textContent = `Updated ${payload.generated_at}`;
|
|
renderSummary(payload.summary);
|
|
renderServices(payload.services);
|
|
renderChecks(payload.checks);
|
|
renderUsers(payload.users);
|
|
}
|
|
|
|
async function loadLogs() {
|
|
const service = document.getElementById("logs-service").value;
|
|
if (!service) {
|
|
return;
|
|
}
|
|
document.getElementById("logs-output").textContent = "Loading logs…";
|
|
const response = await fetch(`/api/logs?service=${encodeURIComponent(service)}`);
|
|
const payload = await response.json();
|
|
document.getElementById("logs-output").textContent = payload.logs || "(no log output)";
|
|
}
|
|
|
|
document.getElementById("refresh-btn").addEventListener("click", () => loadStatus(true));
|
|
document.getElementById("load-logs-btn").addEventListener("click", loadLogs);
|
|
|
|
loadStatus().catch((error) => {
|
|
document.getElementById("updated-at").textContent = `Failed to load status: ${error}`;
|
|
});
|
|
setInterval(() => loadStatus().catch(() => {}), 15000);
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def send_json(self, payload: dict[str, object], status: int = 200):
|
|
body = json.dumps(payload, indent=2).encode("utf-8")
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
try:
|
|
self.wfile.write(body)
|
|
except BrokenPipeError:
|
|
return
|
|
|
|
def send_html(self, body: str, status: int = 200):
|
|
payload = body.encode("utf-8")
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
try:
|
|
self.wfile.write(payload)
|
|
except BrokenPipeError:
|
|
return
|
|
|
|
def do_GET(self):
|
|
parsed = urllib.parse.urlparse(self.path)
|
|
params = urllib.parse.parse_qs(parsed.query)
|
|
try:
|
|
if parsed.path == "/":
|
|
self.send_html(HTML)
|
|
return
|
|
if parsed.path == "/api/status":
|
|
force_refresh = params.get("refresh", ["0"])[0] == "1"
|
|
self.send_json(get_status_payload(force_refresh=force_refresh))
|
|
return
|
|
if parsed.path == "/healthz":
|
|
self.send_json({"ok": True, "generated_at": now_iso()})
|
|
return
|
|
if parsed.path == "/api/users":
|
|
users = [{"username": entry["username"]} for entry in read_proxy_users()]
|
|
self.send_json({"users": users})
|
|
return
|
|
if parsed.path == "/api/logs":
|
|
service_name = params.get("service", [""])[0]
|
|
if not service_name:
|
|
self.send_json({"error": "missing service parameter"}, status=400)
|
|
return
|
|
service = find_service(service_name)
|
|
if service is None:
|
|
self.send_json({"error": f"unknown service {service_name}"}, status=404)
|
|
return
|
|
tail = int(params.get("tail", ["200"])[0])
|
|
self.send_json(
|
|
{
|
|
"service": service["service"],
|
|
"container_name": service["container_name"],
|
|
"logs": service_log_text(service["id"], tail),
|
|
}
|
|
)
|
|
return
|
|
self.send_json({"error": "not found"}, status=404)
|
|
except BrokenPipeError:
|
|
return
|
|
except Exception as exc:
|
|
self.send_json({"error": str(exc)}, status=500)
|
|
|
|
def log_message(self, format, *args):
|
|
return
|
|
|
|
|
|
if __name__ == "__main__":
|
|
server = ThreadingHTTPServer(("0.0.0.0", DASHBOARD_PORT), Handler)
|
|
server.serve_forever()
|