chore(repo): move current KarlDNS source to Gitea

Preserve the GitHub history and the newer deployed source from ct_134_karldns. Exclude runtime data, credentials, AppleDouble files and local backup copies. Make Gitea the canonical source for documentation, package metadata and project links.
This commit is contained in:
StasonJatham committed 2026-10-28 08:47:52 +01:00
1 parent 4ecc8eb874
commit 5c46b47937
21 files changed
+1301 -593

No files matched your search

+72
View File
@@ -1,7 +1,13 @@
from __future__ import annotations
import logging
import datetime
import ipaddress
import json
import time
import re
import uuid
from urllib.parse import unquote_plus
from collections.abc import Awaitable, Callable
from fastapi import FastAPI, Request
@@ -15,6 +21,7 @@ from ..settings import DdnsSettings
from ..store import DdnsStore
ACCESS_LOGGER = logging.getLogger("karldns.access")
SECURITY_LOGGER = logging.getLogger("karldns.security")
SECRET_RESPONSE_PREFIXES = (
"/m/",
"/api/v1/hostnames",
@@ -59,6 +66,10 @@ def install_security_middleware(app: FastAPI, settings: DdnsSettings, store: Ddn
@app.middleware("http")
async def security_middleware(request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response:
started_at = time.perf_counter()
creation = request.method == "POST" and request.url.path in ("/", "/api/v1/hostnames")
if creation:
supplied_id = request.headers.get("x-request-id", "")
request.state.audit_request_id = supplied_id if re.fullmatch(r"[a-fA-F0-9]{32}", supplied_id) else uuid.uuid4().hex
try:
content_length = int(request.headers.get("content-length", "0") or "0")
except ValueError:
@@ -76,9 +87,24 @@ def install_security_middleware(app: FastAPI, settings: DdnsSettings, store: Ddn
allowed = await run_in_threadpool(store.allow_rate_limit, key, limit)
if not allowed:
await run_in_threadpool(store.increment_metric, "rate_limit_hits_total")
emit_security_event(request, settings, 429)
return PlainTextResponse("rate limit exceeded", status_code=429)
if creation:
body = await request.body()
decoded = unquote_plus(body.decode("utf-8", errors="replace")).lower()
# Indicators trigger review; arbitrary nonempty input alone is not an attack verdict.
request.state.creation_audit = {
"body_bytes": len(body),
"unexpected_body": bool(body),
"php_code_marker": "<?php" in decoded or "<?=" in decoded,
"shell_substitution_marker": "$(" in decoded or "`" in decoded,
"sql_union_marker": bool(re.search(r"\bunion\s+(?:all\s+)?select\b", decoded)),
}
response = await call_next(request)
if creation:
response.headers["X-Request-ID"] = request.state.audit_request_id
emit_security_event(request, settings, response.status_code)
if is_secret_response(request):
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
@@ -103,6 +129,52 @@ def install_security_middleware(app: FastAPI, settings: DdnsSettings, store: Ddn
return response
def emit_security_event(request: Request, settings: DdnsSettings, status: int) -> None:
"""Metadata only: never persist paths, bodies, account names or credential values."""
path = request.url.path
admin = is_admin_path(path, settings)
unlock = request.method == "POST" and path.startswith("/m/") and path.endswith("/unlock")
action, category, outcome = "", "authentication", "unknown"
if status == 429:
action, category, outcome = "request_rate_limited", "web", "failure"
elif status == 403:
action, category, outcome = "request_rejected", "web", "failure"
elif status == 401:
attempted = unlock or admin and bool(request.headers.get("authorization"))
action = "login_failure" if attempted else "authentication_required"
outcome = "failure" if attempted else "unknown"
elif admin and 200 <= status < 400:
action, outcome = "admin_authenticated_request", "success"
elif unlock and status == 303:
action, outcome = "dashboard_unlocked", "success"
creation = request.method == "POST" and path in ("/", "/api/v1/hostnames")
if creation:
action, category = "public_creation_request", "web"
outcome = "success" if getattr(request.state, "account_created", False) else "failure"
if not action:
return
row = {"@timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"event": {"dataset": "karldns.security", "kind": "event", "category": [category],
"type": ["access" if outcome == "success" else "info"],
"action": "karldns." + action, "outcome": outcome, "provider": "karldns-native"},
"http": {"response": {"status_code": status}, "request": {"method": request.method}},
"labels": {"auth_scope": "admin" if admin else "dashboard" if path.startswith("/m/") else "request",
"actor_identity": "not_available"}, "message": "KarlDNS " + action}
if creation:
row["http"]["request"]["id"] = request.state.audit_request_id
row["url"] = {"path": path}
row["labels"].update({"auth_scope": "public_creation", "account_created": getattr(request.state, "account_created", False),
"dns_activation": False, **getattr(request.state, "creation_audit", {})})
row["labels"]["indicator_verdict"] = "review_only"
try:
ip = str(ipaddress.ip_address(client_ip(request, settings)))
row["source"] = {"ip": ip}
row["labels"]["source_ip_basis"] = "trusted_proxy" if request.client and ip != request.client.host else "socket_peer"
except ValueError:
pass
SECURITY_LOGGER.warning("KARLDNSSEC %s", json.dumps(row, separators=(",", ":")))
def is_admin_path(path: str, settings: DdnsSettings) -> bool:
admin_path = settings.admin_path.rstrip("/")
return path == admin_path or path.startswith(f"{admin_path}/") or path.startswith("/api/v1/admin/")