feat: Add new status page

This commit is contained in:
2026-04-04 11:36:30 +11:00
parent ff3f40beaf
commit d66fd8f58e
43 changed files with 1342 additions and 2427 deletions

16
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,16 @@
repos:
- repo: https://github.com/astral-sh/uv-pre-commit
# uv version.
rev: 0.9.8
hooks:
- id: uv-lock
- id: uv-export
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.14.4
hooks:
# Run the linter.
- id: ruff-check
# Run the formatter.
- id: ruff-format

View File

@@ -1,17 +0,0 @@
FROM --platform=$BUILDPLATFORM python:3.10-alpine AS builder
WORKDIR /app
COPY requirements.txt /app
RUN --mount=type=cache,target=/root/.cache/pip \
python3 -m pip install -r requirements.txt
COPY . /app
# Mount /data to store the data
VOLUME /data
ENTRYPOINT ["python3"]
CMD ["main.py"]
FROM builder as dev-envs

View File

@@ -1,4 +1,52 @@
# HNSDoH Status # HNSDoH Status
This is a simple webserver to check the status of the Handshake DoH server.
It will check every 5 minutes to see if each node is up and running. It checks the node for plain dns, DNS over HTTPS, and DNS over TLS. For DNS over HTTPS and DNS over TLS, it will check the certificate to make sure it is valid. HNSDoH Status is a Flask service that discovers HNSDoH nodes from DNS A records for `hnsdoh.com` and continuously checks each node for:
- DNS over UDP on port 53
- DNS over TCP on port 53
- DNS over HTTPS (DoH) on port 443 at `/dns-query`
- DNS over TLS (DoT) on port 853
For DoH and DoT, TLS certificates are validated with hostname `hnsdoh.com`.
## How It Works
1. Resolve `hnsdoh.com` A records to discover active node IP addresses.
2. Probe each discovered node for all four protocols.
3. Keep current status and short in-memory history.
4. Expose results through a web dashboard and JSON API.
## Run
```bash
uv sync
uv run python main.py
```
The app runs on `0.0.0.0:8000` by default.
## API Endpoints
- `GET /`: HTML status page
- `GET /api/status`: current snapshot and history
- `GET /api/health`: service health (503 if stale or no checks yet)
## Configuration
Environment variables:
- `HNSDOH_DOMAIN` (default: `hnsdoh.com`)
- `HNSDOH_DOH_PATH` (default: `/dns-query`)
- `HNSDOH_CHECK_INTERVAL_SECONDS` (default: `300`)
- `HNSDOH_UI_REFRESH_SECONDS` (default: `30`)
- `HNSDOH_HISTORY_SIZE` (default: `12`)
- `HNSDOH_STALE_AFTER_SECONDS` (default: `900`)
- `HNSDOH_DNS_TIMEOUT_SECONDS` (default: `5`)
- `HNSDOH_DOH_TIMEOUT_SECONDS` (default: `10`)
- `HNSDOH_DOT_TIMEOUT_SECONDS` (default: `10`)
## Notes
- Discovery uses DNS A records only.
- DoH check uses RFC8484 DNS wireformat (`application/dns-message`) to each node IP while sending SNI/Host as `hnsdoh.com` for strict certificate hostname verification.
- History is in-memory and resets on process restart.

29
hnsdoh_status/__init__.py Normal file
View File

@@ -0,0 +1,29 @@
from __future__ import annotations
from flask import Flask
from hnsdoh_status.config import Settings
from hnsdoh_status.routes import create_routes
from hnsdoh_status.scheduler import create_scheduler
from hnsdoh_status.store import StatusStore
scheduler = None
def create_app() -> Flask:
app = Flask(__name__)
settings = Settings()
store = StatusStore(history_size=settings.history_size)
app.config["SETTINGS"] = settings
app.config["STORE"] = store
create_routes(app, settings, store)
global scheduler
if scheduler is None:
scheduler = create_scheduler(settings, store)
scheduler.start()
return app

250
hnsdoh_status/checks.py Normal file
View File

@@ -0,0 +1,250 @@
from __future__ import annotations
import socket
import ssl
import time
from datetime import datetime, timezone
import dns.message
import dns.query
import dns.rcode
import dns.rdatatype
import dns.resolver
from hnsdoh_status.models import CheckResult, NodeSnapshot, ProtocolName, Snapshot
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def discover_nodes(domain: str) -> tuple[list[str], str]:
resolver = dns.resolver.Resolver()
try:
answer = resolver.resolve(domain, "A")
return sorted({record.address for record in answer}), ""
except Exception as exc: # noqa: BLE001
return [], str(exc)
def _check_dns_udp(ip: str, timeout: float) -> CheckResult:
started = time.perf_counter()
checked_at = utcnow()
query = dns.message.make_query("hnsdoh.com", dns.rdatatype.A)
try:
response = dns.query.udp(query, ip, timeout=timeout, port=53)
latency = (time.perf_counter() - started) * 1000
return CheckResult(
protocol="dns_udp",
ok=bool(response.answer),
latency_ms=latency,
checked_at=checked_at,
reason="ok" if response.answer else "empty answer",
)
except Exception as exc: # noqa: BLE001
return CheckResult("dns_udp", False, None, checked_at, str(exc))
def _check_dns_tcp(ip: str, timeout: float) -> CheckResult:
started = time.perf_counter()
checked_at = utcnow()
query = dns.message.make_query("hnsdoh.com", dns.rdatatype.A)
try:
response = dns.query.tcp(query, ip, timeout=timeout, port=53)
latency = (time.perf_counter() - started) * 1000
return CheckResult(
protocol="dns_tcp",
ok=bool(response.answer),
latency_ms=latency,
checked_at=checked_at,
reason="ok" if response.answer else "empty answer",
)
except Exception as exc: # noqa: BLE001
return CheckResult("dns_tcp", False, None, checked_at, str(exc))
def _tls_connection(ip: str, port: int, hostname: str, timeout: float) -> ssl.SSLSocket:
context = ssl.create_default_context()
raw = socket.create_connection((ip, port), timeout=timeout)
try:
tls_socket = context.wrap_socket(raw, server_hostname=hostname)
return tls_socket
except Exception:
raw.close()
raise
def _decode_chunked_body(data: bytes) -> bytes:
output = bytearray()
cursor = 0
while True:
line_end = data.find(b"\r\n", cursor)
if line_end < 0:
raise ValueError("invalid chunk framing")
size_token = data[cursor:line_end].split(b";", maxsplit=1)[0].strip()
size = int(size_token or b"0", 16)
cursor = line_end + 2
if size == 0:
break
next_cursor = cursor + size
if next_cursor + 2 > len(data):
raise ValueError("truncated chunk payload")
output.extend(data[cursor:next_cursor])
if data[next_cursor : next_cursor + 2] != b"\r\n":
raise ValueError("invalid chunk terminator")
cursor = next_cursor + 2
return bytes(output)
def _parse_http_response(response: bytes) -> tuple[str, dict[str, str], bytes]:
head, separator, body = response.partition(b"\r\n\r\n")
if not separator:
raise ValueError("invalid HTTP response")
lines = head.split(b"\r\n")
status_line = lines[0].decode("latin-1", errors="replace")
headers: dict[str, str] = {}
for line in lines[1:]:
if b":" not in line:
continue
key, value = line.split(b":", maxsplit=1)
headers[key.decode("latin-1", errors="replace").lower()] = value.decode(
"latin-1", errors="replace"
).strip()
transfer_encoding = headers.get("transfer-encoding", "").lower()
if "chunked" in transfer_encoding:
body = _decode_chunked_body(body)
return status_line, headers, body
def _check_doh(ip: str, hostname: str, path: str, timeout: float) -> CheckResult:
started = time.perf_counter()
checked_at = utcnow()
query = dns.message.make_query(hostname, dns.rdatatype.A)
query_wire = query.to_wire()
request = (
f"POST {path} HTTP/1.1\r\n"
f"Host: {hostname}\r\n"
"Accept: application/dns-message\r\n"
"Content-Type: application/dns-message\r\n"
f"Content-Length: {len(query_wire)}\r\n"
"Connection: close\r\n\r\n"
).encode("ascii") + query_wire
try:
with _tls_connection(ip, 443, hostname, timeout) as conn:
conn.settimeout(timeout)
conn.sendall(request)
response = b""
while True:
chunk = conn.recv(4096)
if not chunk:
break
response += chunk
latency = (time.perf_counter() - started) * 1000
status_line, _, body = _parse_http_response(response)
status_ok = " 200 " in status_line
payload_ok = False
reason = ""
if status_ok and body:
try:
parsed_dns = dns.message.from_wire(body)
payload_ok = parsed_dns.rcode() == dns.rcode.NOERROR and bool(
parsed_dns.answer
)
if payload_ok:
reason = "ok"
elif parsed_dns.rcode() != dns.rcode.NOERROR:
reason = f"dns rcode {dns.rcode.to_text(parsed_dns.rcode())}"
else:
reason = "empty answer"
except Exception: # noqa: BLE001
reason = "invalid dns wireformat payload"
ok = status_ok and payload_ok
if not reason:
reason = f"http status failed: {status_line}"
return CheckResult("doh", ok, latency, checked_at, reason)
except ssl.SSLCertVerificationError as exc:
return CheckResult("doh", False, None, checked_at, f"tls verify failed: {exc}")
except Exception as exc: # noqa: BLE001
return CheckResult("doh", False, None, checked_at, str(exc))
def _check_dot(ip: str, hostname: str, timeout: float) -> CheckResult:
started = time.perf_counter()
checked_at = utcnow()
query = dns.message.make_query("hnsdoh.com", dns.rdatatype.A)
context = ssl.create_default_context()
try:
response = dns.query.tls(
query,
where=ip,
timeout=timeout,
port=853,
ssl_context=context,
server_hostname=hostname,
)
latency = (time.perf_counter() - started) * 1000
return CheckResult(
protocol="dot",
ok=bool(response.answer),
latency_ms=latency,
checked_at=checked_at,
reason="ok" if response.answer else "empty answer",
)
except ssl.SSLCertVerificationError as exc:
return CheckResult("dot", False, None, checked_at, f"tls verify failed: {exc}")
except Exception as exc: # noqa: BLE001
return CheckResult("dot", False, None, checked_at, str(exc))
def check_node(
ip: str,
hostname: str,
doh_path: str,
dns_timeout: float,
doh_timeout: float,
dot_timeout: float,
) -> NodeSnapshot:
results: dict[ProtocolName, CheckResult] = {
"dns_udp": _check_dns_udp(ip, dns_timeout),
"dns_tcp": _check_dns_tcp(ip, dns_timeout),
"doh": _check_doh(ip, hostname, doh_path, doh_timeout),
"dot": _check_dot(ip, hostname, dot_timeout),
}
return NodeSnapshot(ip=ip, results=results)
def run_full_check(
domain: str,
doh_path: str,
dns_timeout: float,
doh_timeout: float,
dot_timeout: float,
) -> Snapshot:
checked_at = utcnow()
nodes, discovery_error = discover_nodes(domain)
snapshots = [
check_node(ip, domain, doh_path, dns_timeout, doh_timeout, dot_timeout)
for ip in nodes
]
return Snapshot(
domain=domain,
checked_at=checked_at,
node_count=len(snapshots),
nodes=snapshots,
discovery_error=discovery_error,
)

25
hnsdoh_status/config.py Normal file
View File

@@ -0,0 +1,25 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from dotenv import load_dotenv
load_dotenv()
@dataclass(frozen=True)
class Settings:
domain: str = os.getenv("HNSDOH_DOMAIN", "hnsdoh.com")
doh_path: str = os.getenv("HNSDOH_DOH_PATH", "/dns-query")
check_interval_seconds: int = int(os.getenv("HNSDOH_CHECK_INTERVAL_SECONDS", "300"))
ui_refresh_seconds: int = int(os.getenv("HNSDOH_UI_REFRESH_SECONDS", "30"))
history_size: int = int(os.getenv("HNSDOH_HISTORY_SIZE", "12"))
stale_after_seconds: int = int(os.getenv("HNSDOH_STALE_AFTER_SECONDS", "900"))
dns_timeout_seconds: float = float(os.getenv("HNSDOH_DNS_TIMEOUT_SECONDS", "5"))
doh_timeout_seconds: float = float(os.getenv("HNSDOH_DOH_TIMEOUT_SECONDS", "10"))
dot_timeout_seconds: float = float(os.getenv("HNSDOH_DOT_TIMEOUT_SECONDS", "10"))
webhook_url: str = os.getenv("TMP_DISCORD_HOOK", "")

40
hnsdoh_status/models.py Normal file
View File

@@ -0,0 +1,40 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal
ProtocolName = Literal["dns_udp", "dns_tcp", "doh", "dot"]
@dataclass
class CheckResult:
protocol: ProtocolName
ok: bool
latency_ms: float | None
checked_at: datetime
reason: str = ""
@dataclass
class NodeSnapshot:
ip: str
results: dict[ProtocolName, CheckResult] = field(default_factory=dict)
@dataclass
class Snapshot:
domain: str
checked_at: datetime
node_count: int
nodes: list[NodeSnapshot]
discovery_error: str = ""
@dataclass
class StatusPayload:
generated_at: datetime
interval_seconds: int
stale_after_seconds: int
current: Snapshot | None
history: dict[str, list[dict[ProtocolName, CheckResult]]]

105
hnsdoh_status/routes.py Normal file
View File

@@ -0,0 +1,105 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from flask import Blueprint, Flask, Response, jsonify, render_template
from hnsdoh_status.config import Settings
from hnsdoh_status.models import CheckResult, ProtocolName, Snapshot
from hnsdoh_status.store import StatusStore
def _result_to_dict(result: CheckResult) -> dict[str, Any]:
return {
"protocol": result.protocol,
"ok": result.ok,
"latency_ms": round(result.latency_ms, 2)
if result.latency_ms is not None
else None,
"checked_at": result.checked_at.isoformat(),
"reason": result.reason,
}
def _snapshot_to_dict(snapshot: Snapshot | None) -> dict | None:
if snapshot is None:
return None
return {
"domain": snapshot.domain,
"checked_at": snapshot.checked_at.isoformat(),
"node_count": snapshot.node_count,
"discovery_error": snapshot.discovery_error,
"nodes": [
{
"ip": node.ip,
"results": {
protocol: _result_to_dict(result)
for protocol, result in node.results.items()
},
}
for node in snapshot.nodes
],
}
def _history_to_dict(
history: dict[str, list[dict[ProtocolName, CheckResult]]],
) -> dict[str, list[dict[str, dict]]]:
rendered: dict[str, list[dict[str, dict]]] = {}
for ip, entries in history.items():
rendered[ip] = []
for entry in entries:
rendered[ip].append(
{
protocol: _result_to_dict(result)
for protocol, result in entry.items()
}
)
return rendered
def create_routes(app: Flask, settings: Settings, store: StatusStore) -> Blueprint:
bp = Blueprint("status", __name__)
@bp.get("/")
def index() -> str:
return render_template(
"index.html",
domain=settings.domain,
ui_refresh_seconds=settings.ui_refresh_seconds,
)
@bp.get("/api/health")
def health() -> tuple[dict, int]:
now = datetime.now(timezone.utc)
current = store.current()
if current is None:
return {"ok": False, "reason": "No checks completed yet."}, 503
age_seconds = (now - current.checked_at).total_seconds()
stale = age_seconds > settings.stale_after_seconds
status_code = 200 if not stale else 503
return {
"ok": not stale,
"stale": stale,
"age_seconds": age_seconds,
"checked_at": current.checked_at.isoformat(),
}, status_code
@bp.get("/api/status")
def status() -> Response:
return jsonify(
{
"generated_at": store.generated_at().isoformat(),
"interval_seconds": settings.check_interval_seconds,
"stale_after_seconds": settings.stale_after_seconds,
"current": _snapshot_to_dict(store.current()),
"history": _history_to_dict(store.history()),
}
)
app.register_blueprint(bp)
return bp

View File

@@ -0,0 +1,34 @@
from __future__ import annotations
from apscheduler.schedulers.background import BackgroundScheduler
from hnsdoh_status.checks import run_full_check
from hnsdoh_status.config import Settings
from hnsdoh_status.store import StatusStore
def create_scheduler(settings: Settings, store: StatusStore) -> BackgroundScheduler:
scheduler = BackgroundScheduler(daemon=True)
def run_checks() -> None:
snapshot = run_full_check(
domain=settings.domain,
doh_path=settings.doh_path,
dns_timeout=settings.dns_timeout_seconds,
doh_timeout=settings.doh_timeout_seconds,
dot_timeout=settings.dot_timeout_seconds,
)
store.update(snapshot)
# Run once on startup so the UI/API is populated immediately.
run_checks()
scheduler.add_job(
run_checks,
"interval",
seconds=settings.check_interval_seconds,
id="node-health-check",
max_instances=1,
coalesce=True,
)
return scheduler

View File

@@ -0,0 +1,75 @@
const refreshSeconds = window.HNSDOH_UI_REFRESH_SECONDS || 30;
function badgeFor(result) {
const badgeClass = result.ok ? "badge badge-ok" : "badge badge-bad";
const label = result.ok ? "UP" : "DOWN";
const latency = result.latency_ms === null ? "" : ` (${result.latency_ms} ms)`;
const reason = result.reason || "";
return `
<span class="${badgeClass}">${label}${latency}</span>
<span class="hint">${reason}</span>
`;
}
function historyDots(history, protocol) {
if (!history || history.length === 0) {
return "";
}
const recent = history.slice(-10);
const dots = recent
.map((entry) => {
const r = entry[protocol];
if (!r) return '<span class="dot"></span>';
return `<span class="dot ${r.ok ? "ok" : "bad"}"></span>`;
})
.join("");
return `<div class="history">${dots}</div>`;
}
function rowForNode(node, history) {
const udp = node.results.dns_udp;
const tcp = node.results.dns_tcp;
const doh = node.results.doh;
const dot = node.results.dot;
return `
<tr>
<td>${node.ip}</td>
<td>${badgeFor(udp)}${historyDots(history, "dns_udp")}</td>
<td>${badgeFor(tcp)}${historyDots(history, "dns_tcp")}</td>
<td>${badgeFor(doh)}${historyDots(history, "doh")}</td>
<td>${badgeFor(dot)}${historyDots(history, "dot")}</td>
</tr>
`;
}
async function refreshStatus() {
try {
const response = await fetch("/api/status", { cache: "no-store" });
const data = await response.json();
const tableBody = document.getElementById("status-table-body");
const lastUpdated = document.getElementById("last-updated");
if (!data.current) {
tableBody.innerHTML = '<tr><td colspan="5">No data yet. Waiting for first check.</td></tr>';
return;
}
lastUpdated.textContent = `Last updated: ${data.current.checked_at}`;
const rows = data.current.nodes
.map((node) => rowForNode(node, data.history[node.ip] || []))
.join("");
tableBody.innerHTML = rows || '<tr><td colspan="5">No nodes discovered.</td></tr>';
} catch (error) {
const tableBody = document.getElementById("status-table-body");
tableBody.innerHTML = `<tr><td colspan="5">Failed to load status: ${error}</td></tr>`;
}
}
refreshStatus();
setInterval(refreshStatus, refreshSeconds * 1000);

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -0,0 +1,189 @@
:root {
--bg: #f0f7f4;
--paper: #ffffff;
--ink: #16302b;
--muted: #4b635d;
--accent: #1f7a8c;
--ok: #12824c;
--bad: #ba2d0b;
--border: #d6e8e1;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #09131a;
--paper: #10222c;
--ink: #e3f2f6;
--muted: #93acb6;
--accent: #69bfd6;
--ok: #62d387;
--bad: #ff8c73;
--border: #214250;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at 10% 10%, #cfeadf 0, transparent 32%),
radial-gradient(circle at 90% 0%, #b4d6e3 0, transparent 28%),
var(--bg);
}
@media (prefers-color-scheme: dark) {
body {
background:
radial-gradient(circle at 10% 10%, #143342 0, transparent 35%),
radial-gradient(circle at 90% 0%, #233a46 0, transparent 30%),
var(--bg);
}
}
.layout {
max-width: 1100px;
margin: 2rem auto;
padding: 0 1rem 2rem;
}
.hero {
background: linear-gradient(130deg, #e2f4eb 0%, #e7f0ff 100%);
border: 1px solid var(--border);
border-radius: 14px;
padding: 1rem 1.25rem;
}
@media (prefers-color-scheme: dark) {
.hero {
background: linear-gradient(135deg, #123342 0%, #182f3c 100%);
}
}
.hero h1 {
margin: 0;
font-size: 1.8rem;
}
.hero p {
margin-top: 0.3rem;
color: var(--muted);
}
.meta {
display: flex;
gap: 1rem;
font-size: 0.9rem;
color: var(--muted);
}
.panel {
margin-top: 1rem;
background: var(--paper);
border: 1px solid var(--border);
border-radius: 14px;
overflow: hidden;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
text-align: left;
padding: 0.75rem;
border-bottom: 1px solid var(--border);
}
th {
font-size: 0.9rem;
color: var(--muted);
letter-spacing: 0.02em;
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.3rem 0.55rem;
border-radius: 999px;
font-size: 0.8rem;
border: 1px solid transparent;
}
.badge-ok {
color: var(--ok);
background: #e7f7ef;
border-color: #b6e3ca;
}
@media (prefers-color-scheme: dark) {
.badge-ok {
background: #173b2a;
border-color: #2f6c4f;
}
}
.badge-bad {
color: var(--bad);
background: #fdece7;
border-color: #f3b9aa;
}
@media (prefers-color-scheme: dark) {
.badge-bad {
background: #472118;
border-color: #855040;
}
}
.hint {
display: block;
margin-top: 0.15rem;
color: var(--muted);
font-size: 0.75rem;
}
.history {
margin-top: 0.35rem;
display: flex;
gap: 0.2rem;
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #adbcb6;
}
.dot.ok {
background: var(--ok);
}
.dot.bad {
background: var(--bad);
}
@media (prefers-color-scheme: dark) {
th,
td {
border-bottom-color: #1f3f4c;
}
}
@media (max-width: 880px) {
.panel {
overflow-x: auto;
}
table {
min-width: 760px;
}
}

30
hnsdoh_status/store.py Normal file
View File

@@ -0,0 +1,30 @@
from __future__ import annotations
from collections import defaultdict, deque
from datetime import datetime, timezone
from hnsdoh_status.models import CheckResult, ProtocolName, Snapshot
class StatusStore:
def __init__(self, history_size: int):
self._history_size = history_size
self._current: Snapshot | None = None
self._history: dict[str, deque[dict[ProtocolName, CheckResult]]] = defaultdict(
lambda: deque(maxlen=self._history_size)
)
def update(self, snapshot: Snapshot) -> None:
self._current = snapshot
for node in snapshot.nodes:
# Each history entry stores one full protocol result set for the node.
self._history[node.ip].append(dict(node.results))
def current(self) -> Snapshot | None:
return self._current
def history(self) -> dict[str, list[dict[ProtocolName, CheckResult]]]:
return {ip: list(entries) for ip, entries in self._history.items()}
def generated_at(self) -> datetime:
return datetime.now(timezone.utc)

View File

@@ -0,0 +1,56 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HNSDoH Status</title>
<link
rel="icon"
type="image/png"
href="{{ url_for('static', filename='icons/HNS.png') }}"
/>
<link
rel="icon"
type="image/png"
media="(prefers-color-scheme: dark)"
href="{{ url_for('static', filename='icons/HNSW.png') }}"
/>
<link
rel="apple-touch-icon"
href="{{ url_for('static', filename='icons/HNS.png') }}"
/>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" />
</head>
<body>
<main class="layout">
<header class="hero">
<h1>HNSDoH Status</h1>
<p>Live checks for {{ domain }} nodes discovered from DNS A records.</p>
<div class="meta">
<span id="last-updated">Last updated: pending</span>
<span>Refresh: {{ ui_refresh_seconds }}s</span>
</div>
</header>
<section class="panel">
<table>
<thead>
<tr>
<th>Node IP</th>
<th>DNS UDP :53</th>
<th>DNS TCP :53</th>
<th>DoH :443</th>
<th>DoT :853</th>
</tr>
</thead>
<tbody id="status-table-body"></tbody>
</table>
</section>
</main>
<script>
window.HNSDOH_UI_REFRESH_SECONDS = {{ ui_refresh_seconds }};
</script>
<script src="{{ url_for('static', filename='app.js') }}"></script>
</body>
</html>

90
main.py
View File

@@ -1,90 +1,8 @@
import sys from hnsdoh_status import create_app
import signal
import threading
import server
from gunicorn.app.base import BaseApplication
import os
import dotenv
class GunicornApp(BaseApplication): app = create_app()
def __init__(self, app, options=None):
self.options = options or {}
self.application = app
super().__init__()
def load_config(self):
for key, value in self.options.items():
if key in self.cfg.settings and value is not None:
self.cfg.set(key.lower(), value)
def load(self):
return self.application
def run_gunicorn(): if __name__ == "__main__":
workers = os.getenv('WORKERS', 1) app.run(host="0.0.0.0", port=8000, debug=False)
threads = os.getenv('THREADS', 2)
try:
workers = int(workers)
except (ValueError, TypeError):
workers = 1
try:
threads = int(threads)
except (ValueError, TypeError):
threads = 2
options = {
'bind': '0.0.0.0:5000',
'workers': workers,
'threads': threads,
'timeout': 120,
}
gunicorn_app = GunicornApp(server.app, options)
print(f'Starting server with {workers} workers and {threads} threads', flush=True)
gunicorn_app.run()
def signal_handler(sig, frame):
print("Shutting down gracefully...", flush=True)
# Shutdown the scheduler
if server.scheduler.running:
print("Stopping scheduler...", flush=True)
server.scheduler.shutdown()
# Shutdown the node check executors
print("Shutting down thread pools...", flush=True)
server.node_check_executor.shutdown(wait=False)
server.sub_check_executor.shutdown(wait=False)
sys.exit(0)
if __name__ == '__main__':
dotenv.load_dotenv()
# Register signal handlers
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
# Start the scheduler from server.py
# This ensures we use the robust APScheduler defined there instead of the custom loop
print("Starting background scheduler...", flush=True)
with server.app.app_context():
server.start_scheduler()
# Run an immediate check in a background thread so we don't block startup
startup_thread = threading.Thread(target=server.scheduled_node_check)
startup_thread.daemon = True
startup_thread.start()
try:
# Run the Gunicorn server
run_gunicorn()
except KeyboardInterrupt:
print("Shutting down server...", flush=True)
signal_handler(signal.SIGINT, None)

View File

@@ -20,5 +20,6 @@ dependencies = [
[dependency-groups] [dependency-groups]
dev = [ dev = [
"pre-commit>=4.5.1",
"ruff>=0.14.5", "ruff>=0.14.5",
] ]

View File

@@ -1,11 +1,299 @@
flask # This file was autogenerated by uv via the following command:
gunicorn # uv export --frozen --output-file=requirements.txt
requests apscheduler==3.11.1 \
dnspython --hash=sha256:0db77af6400c84d1747fe98a04b8b58f0080c77d11d338c4f507a9752880f221 \
dnslib --hash=sha256:6162cb5683cb09923654fa9bdd3130c4be4bfda6ad8990971c9597ecd52965d2
python-dateutil # via hnsdoh-status
python-dotenv blinker==1.9.0 \
schedule --hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \
apscheduler>=3.9.1 --hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc
flask-caching # via flask
brotli brotli==1.2.0 \
--hash=sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c \
--hash=sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a \
--hash=sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6 \
--hash=sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac \
--hash=sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18 \
--hash=sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48 \
--hash=sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5 \
--hash=sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c \
--hash=sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21 \
--hash=sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b \
--hash=sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d \
--hash=sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7 \
--hash=sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e \
--hash=sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab \
--hash=sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8 \
--hash=sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f \
--hash=sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63 \
--hash=sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888 \
--hash=sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a \
--hash=sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3 \
--hash=sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361
# via hnsdoh-status
cachelib==0.13.0 \
--hash=sha256:209d8996e3c57595bee274ff97116d1d73c4980b2fd9a34c7846cd07fd2e1a48 \
--hash=sha256:8c8019e53b6302967d4e8329a504acf75e7bc46130291d30188a6e4e58162516
# via flask-caching
certifi==2025.11.12 \
--hash=sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b \
--hash=sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316
# via requests
cfgv==3.5.0 \
--hash=sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0 \
--hash=sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132
# via pre-commit
charset-normalizer==3.4.4 \
--hash=sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152 \
--hash=sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72 \
--hash=sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e \
--hash=sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c \
--hash=sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2 \
--hash=sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44 \
--hash=sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede \
--hash=sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed \
--hash=sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133 \
--hash=sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e \
--hash=sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14 \
--hash=sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828 \
--hash=sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f \
--hash=sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328 \
--hash=sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090 \
--hash=sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c \
--hash=sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb \
--hash=sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a \
--hash=sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec \
--hash=sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc \
--hash=sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac \
--hash=sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894 \
--hash=sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14 \
--hash=sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1 \
--hash=sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3 \
--hash=sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e \
--hash=sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6 \
--hash=sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191 \
--hash=sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd \
--hash=sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2 \
--hash=sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794 \
--hash=sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838 \
--hash=sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490 \
--hash=sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9
# via requests
click==8.3.1 \
--hash=sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a \
--hash=sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6
# via flask
colorama==0.4.6 ; sys_platform == 'win32' \
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
# via click
distlib==0.4.0 \
--hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \
--hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d
# via virtualenv
dnslib==0.9.26 \
--hash=sha256:be56857534390b2fbd02935270019bacc5e6b411d156cb3921ac55a7fb51f1a8 \
--hash=sha256:e68719e633d761747c7e91bd241019ef5a2b61a63f56025939e144c841a70e0d
# via hnsdoh-status
dnspython==2.8.0 \
--hash=sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af \
--hash=sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f
# via hnsdoh-status
filelock==3.25.2 \
--hash=sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694 \
--hash=sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70
# via
# python-discovery
# virtualenv
flask==3.1.2 \
--hash=sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87 \
--hash=sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c
# via
# flask-caching
# hnsdoh-status
flask-caching==2.3.1 \
--hash=sha256:65d7fd1b4eebf810f844de7de6258254b3248296ee429bdcb3f741bcbf7b98c9 \
--hash=sha256:d3efcf600e5925ea5a2fcb810f13b341ae984f5b52c00e9d9070392f3ca10761
# via hnsdoh-status
gunicorn==23.0.0 \
--hash=sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d \
--hash=sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec
# via hnsdoh-status
identify==2.6.18 \
--hash=sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd \
--hash=sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737
# via pre-commit
idna==3.11 \
--hash=sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea \
--hash=sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902
# via requests
itsdangerous==2.2.0 \
--hash=sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef \
--hash=sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173
# via flask
jinja2==3.1.6 \
--hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \
--hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67
# via flask
markupsafe==3.0.3 \
--hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \
--hash=sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175 \
--hash=sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 \
--hash=sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb \
--hash=sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6 \
--hash=sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab \
--hash=sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218 \
--hash=sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634 \
--hash=sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73 \
--hash=sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe \
--hash=sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa \
--hash=sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37 \
--hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 \
--hash=sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19 \
--hash=sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9 \
--hash=sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9 \
--hash=sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc \
--hash=sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4 \
--hash=sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354 \
--hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \
--hash=sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9 \
--hash=sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc \
--hash=sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485 \
--hash=sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12 \
--hash=sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025 \
--hash=sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009 \
--hash=sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d \
--hash=sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5 \
--hash=sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f \
--hash=sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1 \
--hash=sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287 \
--hash=sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6 \
--hash=sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581 \
--hash=sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed \
--hash=sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026 \
--hash=sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676 \
--hash=sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795 \
--hash=sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5 \
--hash=sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d \
--hash=sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe \
--hash=sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda \
--hash=sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e \
--hash=sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737 \
--hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \
--hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50
# via
# flask
# jinja2
# werkzeug
nodeenv==1.10.0 \
--hash=sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827 \
--hash=sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb
# via pre-commit
packaging==25.0 \
--hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \
--hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f
# via gunicorn
platformdirs==4.9.4 \
--hash=sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934 \
--hash=sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868
# via
# python-discovery
# virtualenv
pre-commit==4.5.1 \
--hash=sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77 \
--hash=sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61
python-dateutil==2.9.0.post0 \
--hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
--hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
# via hnsdoh-status
python-discovery==1.2.1 \
--hash=sha256:180c4d114bff1c32462537eac5d6a332b768242b76b69c0259c7d14b1b680c9e \
--hash=sha256:b6a957b24c1cd79252484d3566d1b49527581d46e789aaf43181005e56201502
# via virtualenv
python-dotenv==1.2.1 \
--hash=sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6 \
--hash=sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61
# via hnsdoh-status
pyyaml==6.0.3 \
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
--hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \
--hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \
--hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \
--hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \
--hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \
--hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \
--hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \
--hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \
--hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \
--hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \
--hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \
--hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \
--hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \
--hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \
--hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \
--hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \
--hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \
--hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \
--hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \
--hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \
--hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \
--hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \
--hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \
--hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \
--hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \
--hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \
--hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \
--hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6
# via pre-commit
requests==2.32.5 \
--hash=sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 \
--hash=sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf
# via hnsdoh-status
ruff==0.14.5 \
--hash=sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68 \
--hash=sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78 \
--hash=sha256:410e781f1122d6be4f446981dd479470af86537fb0b8857f27a6e872f65a38e4 \
--hash=sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4 \
--hash=sha256:6d146132d1ee115f8802356a2dc9a634dbf58184c51bff21f313e8cd1c74899a \
--hash=sha256:7497d19dce23976bdaca24345ae131a1d38dcfe1b0850ad8e9e6e4fa321a6e19 \
--hash=sha256:88f0770d42b7fa02bbefddde15d235ca3aa24e2f0137388cc15b2dcbb1f7c7a7 \
--hash=sha256:8d3b48d7d8aad423d3137af7ab6c8b1e38e4de104800f0d596990f6ada1a9fc1 \
--hash=sha256:9d55d7af7166f143c94eae1db3312f9ea8f95a4defef1979ed516dbb38c27621 \
--hash=sha256:b595bedf6bc9cab647c4a173a61acf4f1ac5f2b545203ba82f30fcb10b0318fb \
--hash=sha256:c01be527ef4c91a6d55e53b337bfe2c0f82af024cc1a33c44792d6844e2331e1 \
--hash=sha256:c135d4b681f7401fe0e7312017e41aba9b3160861105726b76cfa14bc25aa367 \
--hash=sha256:c83642e6fccfb6dea8b785eb9f456800dcd6a63f362238af5fc0c83d027dd08b \
--hash=sha256:d93be8f1fa01022337f1f8f3bcaa7ffee2d0b03f00922c45c2207954f351f465 \
--hash=sha256:e2380596653dcd20b057794d55681571a257a42327da8894b93bbd6111aa801f \
--hash=sha256:f3b8248123b586de44a8018bcc9fefe31d23dda57a34e6f0e1e53bd51fd63594 \
--hash=sha256:f55382725ad0bdb2e8ee2babcbbfb16f124f5a59496a2f6a46f1d9d99d93e6e2 \
--hash=sha256:f66e9bb762e68d66e48550b59c74314168ebb46199886c5c5aa0b0fbcc81b151 \
--hash=sha256:f7a75236570318c7a30edd7f5491945f0169de738d945ca8784500b517163a72
schedule==1.2.2 \
--hash=sha256:15fe9c75fe5fd9b9627f3f19cc0ef1420508f9f9a46f45cd0769ef75ede5f0b7 \
--hash=sha256:5bef4a2a0183abf44046ae0d164cadcac21b1db011bdd8102e4a0c1e91e06a7d
# via hnsdoh-status
six==1.17.0 \
--hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \
--hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
# via python-dateutil
tzdata==2025.2 ; sys_platform == 'win32' \
--hash=sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8 \
--hash=sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9
# via tzlocal
tzlocal==5.3.1 \
--hash=sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd \
--hash=sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d
# via apscheduler
urllib3==2.5.0 \
--hash=sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760 \
--hash=sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc
# via requests
virtualenv==21.2.0 \
--hash=sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098 \
--hash=sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f
# via pre-commit
werkzeug==3.1.3 \
--hash=sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e \
--hash=sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746
# via flask

1484
server.py

File diff suppressed because it is too large Load Diff

View File

@@ -1,67 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Page Not Found - HNSDoH Status</title>
<link rel="stylesheet" href="/assets/style.css">
<link rel="icon" type="image/png" href="/favicon.png">
<style>
.error-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
text-align: center;
}
.error-code {
font-size: 6rem;
font-weight: 800;
color: var(--bg-card-hover);
line-height: 1;
margin-bottom: 1rem;
}
.error-title {
font-size: 2rem;
font-weight: 700;
margin-bottom: 1rem;
}
.error-msg {
color: var(--text-muted);
margin-bottom: 2rem;
max-width: 400px;
}
.btn {
display: inline-block;
background-color: var(--accent);
color: white;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-weight: 600;
transition: opacity 0.2s;
}
.btn:hover {
opacity: 0.9;
text-decoration: none;
}
</style>
</head>
<body>
<div class="container">
<div class="error-container">
<div class="error-code">404</div>
<div class="error-title">Page Not Found</div>
<div class="error-msg">The page you are looking for might have been removed, had its name changed, or is temporarily unavailable.</div>
<a href="/" class="btn">Return Home</a>
</div>
<div class="footer">
<p>Powered by <a href="https://nathan.woodburn.au">Nathan.Woodburn/</a></p>
</div>
</div>
</body>
</html>

View File

@@ -1,104 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API Documentation - HNSDoH Status</title>
<link rel="stylesheet" href="/assets/style.css">
<link rel="icon" type="image/png" href="/favicon.png">
<style>
.endpoint-card {
background-color: var(--bg-card);
border: 1px solid var(--border);
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.endpoint-method {
display: inline-block;
background-color: rgba(59, 130, 246, 0.15);
color: var(--accent);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-weight: 700;
font-size: 0.75rem;
margin-right: 0.75rem;
}
.endpoint-route {
font-family: monospace;
font-size: 1.1rem;
color: var(--text-main);
}
.endpoint-desc {
margin-top: 0.75rem;
color: var(--text-muted);
}
.param-table {
width: 100%;
margin-top: 1rem;
border: 1px solid var(--border);
border-radius: 0.5rem;
overflow: hidden;
}
.param-table th { background-color: rgba(0,0,0,0.2); }
.param-table td, .param-table th { padding: 0.75rem; border-bottom: 1px solid var(--border); }
.param-table tr:last-child td { border-bottom: none; }
.back-link { display: inline-block; margin-bottom: 1rem; }
</style>
</head>
<body>
<div class="container">
<header>
<div>
<h1>API Documentation</h1>
<div class="meta">Programmatic access to status data</div>
</div>
<div>
<a href="/" class="back-link">← Back to Dashboard</a>
</div>
</header>
<div class="endpoints-list">
{% for endpoint in endpoints %}
<div class="endpoint-card">
<div>
<span class="endpoint-method">GET</span>
<a href="{{ endpoint.route }}" class="endpoint-route">{{ endpoint.route }}</a>
</div>
<div class="endpoint-desc">{{ endpoint.description }}</div>
{% if endpoint.parameters %}
<div class="param-table">
<table>
<thead>
<tr>
<th>Parameter</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{% for param in endpoint.parameters %}
<tr>
<td><code>{{ param.name }}</code></td>
<td><code>{{ param.type }}</code></td>
<td>{{ param.description }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
{% endfor %}
</div>
<div class="footer">
<p>Powered by <a href="https://nathan.woodburn.au">Nathan.Woodburn/</a></p>
</div>
</div>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,19 +0,0 @@
body {
background-color: #000000;
color: #ffffff;
}
.centre {
text-align: center;
margin-top: 20%;
}
a {
color: #ffffff;
}
a:hover {
color: #ff0000;
}
h1 {
font-size: 100px;
margin: 0;
padding: 0;
}

View File

@@ -1,57 +0,0 @@
.bs-icon {
--bs-icon-size: .75rem;
display: flex;
flex-shrink: 0;
justify-content: center;
align-items: center;
font-size: var(--bs-icon-size);
width: calc(var(--bs-icon-size) * 2);
height: calc(var(--bs-icon-size) * 2);
color: var(--bs-primary);
}
.bs-icon-xs {
--bs-icon-size: 1rem;
width: calc(var(--bs-icon-size) * 1.5);
height: calc(var(--bs-icon-size) * 1.5);
}
.bs-icon-sm {
--bs-icon-size: 1rem;
}
.bs-icon-md {
--bs-icon-size: 1.5rem;
}
.bs-icon-lg {
--bs-icon-size: 2rem;
}
.bs-icon-xl {
--bs-icon-size: 2.5rem;
}
.bs-icon.bs-icon-primary {
color: var(--bs-white);
background: var(--bs-primary);
}
.bs-icon.bs-icon-primary-light {
color: var(--bs-primary);
background: rgba(var(--bs-primary-rgb), .2);
}
.bs-icon.bs-icon-semi-white {
color: var(--bs-primary);
background: rgba(255, 255, 255, .5);
}
.bs-icon.bs-icon-rounded {
border-radius: .5rem;
}
.bs-icon.bs-icon-circle {
border-radius: 50%;
}

View File

@@ -1,4 +0,0 @@
.fit-cover {
object-fit: cover;
}

View File

@@ -1,66 +0,0 @@
body {
background-color: #000000;
color: #ffffff;
}
.centre {
text-align: center;
margin-top: 10px;
}
h1 {
font-size: 100px;
margin: 0;
padding: 0;
}
p {
font-size: 18px;
font-weight: bold;
color: #ffffff;
}
ul {
list-style-type: none; /* Remove bullet points */
padding: 0;
margin: 0;
}
li {
margin-bottom: 15px;
}
/* Style for the route names */
li strong {
color: #9ccdff; /* Darker shade for route names */
font-size: 16px;
}
/* Style for descriptions */
li em {
font-size: 14px;
color: #ffa44f; /* Lighter shade for parameter labels */
}
/* Nested parameter list */
li ul {
margin-left: 20px;
margin-top: 8px;
}
li ul li {
font-size: 14px;
color: #cfcfcf; /* Parameter details */
}
/* Styling parameter names */
li ul li strong {
color: #3498db; /* Light blue for parameter names */
}
/* Add a subtle hover effect */
li.top:hover {
/* Invert colours */
filter: invert(1);
background-color: #000000;
border-left: 4px solid #3498db;
padding-left: 10px;
transition: all 0.3s ease;
}

View File

@@ -1,39 +0,0 @@
[data-bs-theme=dark] {
--bs-primary: #002459;
--bs-primary-rgb: 0,36,89;
--bs-primary-text-emphasis: #667C9B;
--bs-primary-bg-subtle: #000712;
--bs-primary-border-subtle: #001635;
}
[data-bs-theme=dark] .btn-primary, .btn-primary[data-bs-theme=dark] {
--bs-btn-color: #fff;
--bs-btn-bg: #002459;
--bs-btn-border-color: #002459;
--bs-btn-hover-color: #fff;
--bs-btn-hover-bg: #001F4C;
--bs-btn-hover-border-color: #001D47;
--bs-btn-focus-shadow-rgb: 217,222,230;
--bs-btn-active-color: #fff;
--bs-btn-active-bg: #001D47;
--bs-btn-active-border-color: #001B43;
--bs-btn-disabled-color: #fff;
--bs-btn-disabled-bg: #002459;
--bs-btn-disabled-border-color: #002459;
}
[data-bs-theme=dark] .btn-outline-primary, .btn-outline-primary[data-bs-theme=dark] {
--bs-btn-color: #002459;
--bs-btn-border-color: #002459;
--bs-btn-focus-shadow-rgb: 0,36,89;
--bs-btn-hover-color: #fff;
--bs-btn-hover-bg: #002459;
--bs-btn-hover-border-color: #002459;
--bs-btn-active-color: #fff;
--bs-btn-active-bg: #002459;
--bs-btn-active-border-color: #002459;
--bs-btn-disabled-color: #002459;
--bs-btn-disabled-bg: transparent;
--bs-btn-disabled-border-color: #002459;
}

View File

@@ -1,30 +0,0 @@
.node {
margin: 25px;
border: solid;
border-radius: 20px;
padding: 20px;
}
.spacer {
margin: 25px;
display: block;
}
.warnings,.errors {
margin: auto;
width: 1000px;
max-width: 95%;
margin-top: 20px;
}
.node-info {
margin-top: 5px;
margin-bottom: 5px;
}
.node-info > p{
margin: 0;
}
.node.warning {
border-color: #FFA500;
}
.node.error {
border-color: #FF0000;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,7 +0,0 @@
document.addEventListener('DOMContentLoaded', function() {
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bss-tooltip]'));
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl);
})
}, false);

View File

@@ -1,133 +0,0 @@
:root {
--bg-body: #0f172a;
--bg-card: #1e293b;
--bg-card-hover: #334155;
--text-main: #f1f5f9;
--text-muted: #94a3b8;
--border: #334155;
--accent: #3b82f6;
--success: #22c55e;
--error: #ef4444;
--warning: #f59e0b;
--shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, sans-serif;
background-color: var(--bg-body);
color: var(--text-main);
line-height: 1.5;
padding: 2rem;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
header {
margin-bottom: 2rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border);
padding-bottom: 1rem;
}
h1 { font-size: 1.5rem; font-weight: 700; }
h2 { font-size: 1.25rem; margin-bottom: 1rem; color: var(--text-main); }
.meta { color: var(--text-muted); font-size: 0.875rem; }
/* Alerts */
.alerts-section { margin-bottom: 2rem; }
.alert-box {
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 0.5rem;
font-weight: 500;
}
.alert-box.error { background-color: rgba(239, 68, 68, 0.1); border: 1px solid var(--error); color: #fca5a5; }
.alert-box.warning { background-color: rgba(245, 158, 11, 0.1); border: 1px solid var(--warning); color: #fcd34d; }
/* Grid */
.node-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
margin-bottom: 3rem;
}
.card {
background-color: var(--bg-card);
border: 1px solid var(--border);
border-radius: 0.75rem;
padding: 1.5rem;
box-shadow: var(--shadow);
transition: transform 0.2s, border-color 0.2s;
}
.card:hover {
transform: translateY(-2px);
border-color: var(--text-muted);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border);
}
.node-name { font-weight: 600; font-size: 1.125rem; }
.node-location { font-size: 0.875rem; color: var(--text-muted); }
.node-ip { font-family: monospace; font-size: 0.75rem; color: var(--text-muted); background: rgba(0,0,0,0.2); padding: 2px 6px; border-radius: 4px; }
.status-list { display: flex; flex-direction: column; gap: 0.75rem; }
.status-item { display: flex; justify-content: space-between; align-items: center; font-size: 0.875rem; }
.badge {
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.badge.up { background-color: rgba(34, 197, 94, 0.15); color: var(--success); }
.badge.down { background-color: rgba(239, 68, 68, 0.15); color: var(--error); }
.cert-info { font-size: 0.75rem; color: var(--text-muted); margin-top: 0.25rem; text-align: right; }
/* History Table */
.table-container {
background-color: var(--bg-card);
border-radius: 0.75rem;
border: 1px solid var(--border);
overflow-x: auto;
}
table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
th, td { padding: 1rem; text-align: left; border-bottom: 1px solid var(--border); }
th { background-color: rgba(0,0,0,0.2); font-weight: 600; color: var(--text-muted); }
tr:last-child td { border-bottom: none; }
tr:hover td { background-color: var(--bg-card-hover); }
.uptime-bar {
height: 6px;
background-color: var(--bg-body);
border-radius: 3px;
overflow: hidden;
width: 100px;
margin-top: 4px;
}
.uptime-fill { height: 100%; background-color: var(--success); }
.uptime-fill.warn { background-color: var(--warning); }
.uptime-fill.bad { background-color: var(--error); }
.footer { margin-top: 4rem; text-align: center; color: var(--text-muted); font-size: 0.875rem; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }

View File

@@ -1,128 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HNSDoH Status</title>
<link rel="stylesheet" href="/assets/style.css">
<link rel="icon" type="image/png" href="/favicon.png">
</head>
<body>
<div class="container">
<header>
<div>
<h1>HNSDoH Status</h1>
<div class="meta">Handshake DNS over HTTPS/TLS</div>
</div>
<div class="meta">
Last checked: {{ last_check }}
</div>
</header>
{% if alerts or warnings %}
<div class="alerts-section">
{% for alert in alerts %}
<div class="alert-box error">{{ alert }}</div>
{% endfor %}
{% for warning in warnings %}
<div class="alert-box warning">{{ warning }}</div>
{% endfor %}
</div>
{% endif %}
<h2>Node Status</h2>
<div class="node-grid">
{% for node in nodes %}
<div class="card">
<div class="card-header">
<div>
<div class="node-name">{{ node.name }}</div>
<div class="node-location">{{ node.location }}</div>
</div>
<div class="node-ip">{{ node.ip }}</div>
</div>
<div class="status-list">
<div class="status-item">
<span>Plain DNS</span>
<span class="badge {{ 'up' if node.plain_dns else 'down' }}">
{{ 'UP' if node.plain_dns else 'DOWN' }}
</span>
</div>
<div class="status-item">
<span>DoH (443)</span>
<span class="badge {{ 'up' if node.doh else 'down' }}">
{{ 'UP' if node.doh else 'DOWN' }}
</span>
</div>
<div class="status-item">
<span>DoT (853)</span>
<span class="badge {{ 'up' if node.dot else 'down' }}">
{{ 'UP' if node.dot else 'DOWN' }}
</span>
</div>
<div class="status-item">
<span>Certificate</span>
<div style="text-align: right;">
<span class="badge {{ 'up' if node.cert.valid else 'down' }}">
{{ 'VALID' if node.cert.valid else 'INVALID' }}
</span>
{% if node.cert.valid %}
<div class="cert-info">Exp: {{ node.cert.expires }}</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<h2>30-Day History</h2>
<div class="table-container">
<table>
<thead>
<tr>
<th>Node</th>
<th style="text-align: center;">Plain DNS</th>
<th style="text-align: center;">DoH</th>
<th style="text-align: center;">DoT</th>
</tr>
</thead>
<tbody>
{% for node in history.nodes %}
<tr>
<td>
<div class="node-name">{{ node.name }}</div>
<div class="node-location">{{ node.location }}</div>
</td>
{% for type in ['plain_dns', 'doh', 'dot'] %}
<td style="text-align: center;">
<div>{{ node[type].percentage }}%</div>
<div class="uptime-bar" style="margin: 4px auto 0;">
<div class="uptime-fill {% if node[type].percentage < 90 %}warn{% endif %} {% if node[type].percentage < 70 %}bad{% endif %}"
style="width: {{ node[type].percentage }}%"></div>
</div>
{% if node[type].last_down != 'never' %}
<div class="cert-info" style="text-align: center;">Last outage: {{ node[type].last_down }}</div>
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="footer">
<p>Powered by <a href="https://nathan.woodburn.au">Nathan.Woodburn/</a></p>
<p><a href="/api">API Access</a></p>
</div>
</div>
</body>
</html>

View File

@@ -1,146 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HNSDoH Status</title>
<link rel="stylesheet" href="/assets/css/style.css">
<meta name="description" content="HNSDoH Status page - Monitoring the status of HNSDoH resolvers">
<link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/png" href="/favicon.png">
<style>
.loader {
border: 5px solid #f3f3f3;
border-radius: 50%;
border-top: 5px solid #3498db;
width: 40px;
height: 40px;
margin: 20px auto;
animation: spin 1.5s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.quick-status {
text-align: center;
padding: 20px;
margin: 20px;
border-radius: 5px;
background-color: #f5f5f5;
}
.status-ok {
color: green;
}
.status-issues {
color: orange;
}
.status-error {
color: red;
}
</style>
</head>
<body>
<header>
<h1>HNSDoH Status</h1>
<p>Monitoring the status of HNSDoH resolvers</p>
</header>
<div class="quick-status">
<h2>Current Status</h2>
<div id="quick-status-display">Loading...</div>
<div class="loader" id="status-loader"></div>
</div>
<main>
<div id="content">
<div class="loader"></div>
<p>Loading full status data...</p>
<p>This may take a few moments as we check all resolver nodes.</p>
</div>
</main>
<footer>
<p>Made by <a href="https://nathan.woodburn.au">Nathan.Woodburn/</a></p>
</footer>
<script>
// Load quick status first
fetch('/api/quick-status')
.then(response => response.json())
.then(data => {
const statusDisplay = document.getElementById('quick-status-display');
const statusLoader = document.getElementById('status-loader');
let statusClass = 'status-ok';
let statusMessage = 'All systems operational';
if (data.status === 'issues') {
statusClass = 'status-issues';
statusMessage = `${data.nodes_with_issues} out of ${data.total_nodes} nodes have issues`;
} else if (data.status === 'error' || data.status === 'unknown') {
statusClass = 'status-error';
statusMessage = 'Unable to determine system status';
}
statusDisplay.innerHTML = `
<h3 class="${statusClass}">${statusMessage}</h3>
<p>Last check: ${data.last_check}</p>
`;
statusLoader.style.display = 'none';
})
.catch(error => {
document.getElementById('quick-status-display').innerHTML = `
<h3 class="status-error">Error loading status</h3>
<p>${error}</p>
`;
document.getElementById('status-loader').style.display = 'none';
});
// Then load full page data
fetch('/api/nodes')
.then(response => response.json())
.then(nodeData => {
// Once we have node data, get history data
return Promise.all([
Promise.resolve(nodeData),
fetch('/api/history').then(res => res.json())
]);
})
.then(([nodeData, historyData]) => {
// Now we have both datasets, fetch the HTML with them
return fetch('/?' + new URLSearchParams({
_data_loaded: 'true' // Signal to the server we already have data
}));
})
.then(response => response.text())
.then(html => {
document.getElementById('content').innerHTML = html;
// Replace direct links with JS-enhanced versions
document.querySelectorAll('a').forEach(link => {
const href = link.getAttribute('href');
if (href && href.startsWith('/') && !href.includes('fast_load')) {
link.setAttribute('href', href + (href.includes('?') ? '&' : '?') + 'fast_load=true');
}
});
})
.catch(error => {
document.getElementById('content').innerHTML = `
<div class="error">
<h2>Error Loading Data</h2>
<p>There was a problem loading the full status data. Please try refreshing the page.</p>
<p>Error details: ${error}</p>
<a href="/" class="button">Refresh Page</a>
</div>
`;
});
</script>
</body>
</html>

View File

@@ -1,15 +0,0 @@
{
"name": "HNSDoH Status",
"short_name": "HNSDoH Status",
"start_url": "https://{{host}}/",
"display": "standalone",
"background_color": "#212529",
"theme_color": "#212529",
"orientation": "portrait-primary",
"icons": [
{
"src": "/favicon.png",
"sizes": "500x500"
}
]
}

140
uv.lock generated
View File

@@ -69,6 +69,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" },
] ]
[[package]]
name = "cfgv"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
]
[[package]] [[package]]
name = "charset-normalizer" name = "charset-normalizer"
version = "3.4.4" version = "3.4.4"
@@ -131,6 +140,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
] ]
[[package]]
name = "distlib"
version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
]
[[package]] [[package]]
name = "dnslib" name = "dnslib"
version = "0.9.26" version = "0.9.26"
@@ -149,6 +167,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
] ]
[[package]]
name = "filelock"
version = "3.25.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" },
]
[[package]] [[package]]
name = "flask" name = "flask"
version = "3.1.2" version = "3.1.2"
@@ -211,6 +238,7 @@ dependencies = [
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "pre-commit" },
{ name = "ruff" }, { name = "ruff" },
] ]
@@ -230,7 +258,19 @@ requires-dist = [
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [{ name = "ruff", specifier = ">=0.14.5" }] dev = [
{ name = "pre-commit", specifier = ">=4.5.1" },
{ name = "ruff", specifier = ">=0.14.5" },
]
[[package]]
name = "identify"
version = "2.6.18"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/46/c4/7fb4db12296cdb11893d61c92048fe617ee853f8523b9b296ac03b43757e/identify-2.6.18.tar.gz", hash = "sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd", size = 99580, upload-time = "2026-03-15T18:39:50.319Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl", hash = "sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737", size = 99394, upload-time = "2026-03-15T18:39:48.915Z" },
]
[[package]] [[package]]
name = "idna" name = "idna"
@@ -314,6 +354,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
] ]
[[package]]
name = "nodeenv"
version = "1.10.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "25.0" version = "25.0"
@@ -323,6 +372,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
] ]
[[package]]
name = "platformdirs"
version = "4.9.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" },
]
[[package]]
name = "pre-commit"
version = "4.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cfgv" },
{ name = "identify" },
{ name = "nodeenv" },
{ name = "pyyaml" },
{ name = "virtualenv" },
]
sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" },
]
[[package]] [[package]]
name = "python-dateutil" name = "python-dateutil"
version = "2.9.0.post0" version = "2.9.0.post0"
@@ -335,6 +409,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
] ]
[[package]]
name = "python-discovery"
version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "filelock" },
{ name = "platformdirs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b9/88/815e53084c5079a59df912825a279f41dd2e0df82281770eadc732f5352c/python_discovery-1.2.1.tar.gz", hash = "sha256:180c4d114bff1c32462537eac5d6a332b768242b76b69c0259c7d14b1b680c9e", size = 58457, upload-time = "2026-03-26T22:30:44.496Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/0f/019d3949a40280f6193b62bc010177d4ce702d0fce424322286488569cd3/python_discovery-1.2.1-py3-none-any.whl", hash = "sha256:b6a957b24c1cd79252484d3566d1b49527581d46e789aaf43181005e56201502", size = 31674, upload-time = "2026-03-26T22:30:43.396Z" },
]
[[package]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.2.1" version = "1.2.1"
@@ -344,6 +431,42 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
] ]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]] [[package]]
name = "requests" name = "requests"
version = "2.32.5" version = "2.32.5"
@@ -433,6 +556,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
] ]
[[package]]
name = "virtualenv"
version = "21.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "distlib" },
{ name = "filelock" },
{ name = "platformdirs" },
{ name = "python-discovery" },
]
sdist = { url = "https://files.pythonhosted.org/packages/aa/92/58199fe10049f9703c2666e809c4f686c54ef0a68b0f6afccf518c0b1eb9/virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098", size = 5840618, upload-time = "2026-03-09T17:24:38.013Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084, upload-time = "2026-03-09T17:24:35.378Z" },
]
[[package]] [[package]]
name = "werkzeug" name = "werkzeug"
version = "3.1.3" version = "3.1.3"