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

+2
View File
@@ -17,6 +17,7 @@ from .routers import admin, api, assets, management, public, updates
from .services.ddns import DdnsService
from .settings import DdnsSettings
from .store import DdnsStore
from .web.request_capture import PostCaptureMiddleware
from .web.security_headers import RequestBodyLimitMiddleware, install_security_middleware
__all__ = [
@@ -92,6 +93,7 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
app.state.store = store
app.state.service = service
install_security_middleware(app, settings, store)
app.add_middleware(PostCaptureMiddleware, settings=settings)
app.include_router(api.router)
app.include_router(assets.router)
app.include_router(public.router)
+2 -1
View File
@@ -51,8 +51,9 @@ AdminBeforeQuery = Annotated[str | None, Query(max_length=64)]
tags=["hostnames"],
responses={500: {"model": ErrorResponse}},
)
async def create_hostname(service: ServiceDep) -> HostnameCreateResponse:
async def create_hostname(request: Request, service: ServiceDep) -> HostnameCreateResponse:
account = await run_in_threadpool(service.create_managed_account)
request.state.account_created = True
return _create_response(service, account)
+10 -16
View File
@@ -3,9 +3,12 @@ from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Response
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.responses import FileResponse
ASSET_DIR = Path(__file__).parents[1] / "assets"
SCRIPT_DIR = ASSET_DIR / "scripts"
if not SCRIPT_DIR.is_dir():
SCRIPT_DIR = Path(__file__).parents[2] / "scripts"
router = APIRouter(include_in_schema=False)
ASSET_CACHE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"}
@@ -125,24 +128,15 @@ def admin_css() -> FileResponse:
@router.get("/install.sh")
def install_sh() -> RedirectResponse:
return RedirectResponse(
"https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/install-linux.sh",
status_code=302,
)
def install_sh() -> FileResponse:
return FileResponse(SCRIPT_DIR / "install-linux.sh", media_type="text/plain", headers={"Cache-Control": "no-cache"})
@router.get("/uninstall.sh")
def uninstall_sh() -> RedirectResponse:
return RedirectResponse(
"https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/uninstall-linux.sh",
status_code=302,
)
def uninstall_sh() -> FileResponse:
return FileResponse(SCRIPT_DIR / "uninstall-linux.sh", media_type="text/plain", headers={"Cache-Control": "no-cache"})
@router.get("/karldns-update.sh")
def karldns_update_sh() -> RedirectResponse:
return RedirectResponse(
"https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/karldns-update.sh",
status_code=302,
)
def karldns_update_sh() -> FileResponse:
return FileResponse(SCRIPT_DIR / "karldns-update.sh", media_type="text/plain", headers={"Cache-Control": "no-cache"})
+72
View File
@@ -21,6 +21,7 @@ def index(request: Request, settings: SettingsDep) -> str:
async def create_hostname(request: Request, settings: SettingsDep, service: ServiceDep) -> RedirectResponse:
await request_form(request, settings)
account = await run_in_threadpool(service.create_managed_account)
request.state.account_created = True
return RedirectResponse(service.dashboard_url(account), status_code=303)
@@ -29,6 +30,77 @@ def healthz() -> str:
return "ok"
@router.get("/llms.txt", response_class=PlainTextResponse, include_in_schema=False)
def llms_txt(settings: SettingsDep) -> str:
base_url = settings.public_base_url.rstrip("/")
return f"""# KarlDNS
> KarlDNS provides anonymous dynamic DNS hostnames for routers and other clients,
> with IPv4 (A) and IPv6 (AAAA) updates through an HTTP API and a private web dashboard.
The public service base URL is `{base_url}`. Hostnames are generated under
`{settings.hostname_suffix}`; clients cannot choose a custom hostname.
Use HTTPS for this public service. No account login or API key is needed to create
a hostname. Returned private URLs are bearer credentials: save them securely and
never publish them, put them in shared logs, or substitute them into documentation.
Examples below use placeholders, never working credentials.
Create and activate a hostname:
1. Send `POST {base_url}/api/v1/hostnames` with no request body. HTTP 201 JSON
includes `hostname`, `status` (initially `pending`), `dashboard_url`,
`registration_url`, and `update_url`. Save all three private URLs first.
2. Call the returned `registration_url` using GET or POST, supplying the public
address as `myip` (IPv4) and/or `myipv6` (IPv6). HTTP 200 `good registered`
means activation succeeded. For FRITZ!Box, let the router perform registration
using `?myip=<ipaddr>` so the router supplies its own WAN address.
3. After activation, configure the client with the returned `update_url`.
FRITZ!Box uses `?myip=<ipaddr>` and optionally `&myipv6=<ip6addr>`.
Do not send those router placeholders literally from a general HTTP client.
Router requests use `GET` or `POST /api/v1/router/register/{{dashboard_secret}}`
and `GET` or `POST /api/v1/router/update/{{update_slug}}`. They accept query
parameters `myip`/`ipaddr` for IPv4 and `myipv6`/`ip6addr` for IPv6. POST also
accepts JSON or URL-encoded bodies with string values. Prefer explicit public
addresses; otherwise source-address detection is used. Never use a health probe
to call these routes: even GET requests can change DNS.
Updates return plain text: `good <address>` means changed, `nochg <address>`
means already current. Invalid IPs return HTTP 400 `badip ...`; DNS-provider
failures return HTTP 500 `911 ...`. Unknown, disabled, or unregistered update
credentials return HTTP 404. Check HTTP status as well as the response body;
back off on HTTP 429 or transient server failures instead of retrying rapidly.
Read status with `GET /api/v1/hostnames/{{dashboard_secret}}`, using the last
path segment of `dashboard_url`. Optional `history_limit` is 0 through 100
(default 10). The response is JSON and includes lifecycle state and DNS addresses.
Rotate an active hostname's update credential with
`POST /api/v1/hostnames/{{dashboard_secret}}/rotate-update-url`; save and configure
the returned replacement `update_url` immediately because the old one stops working.
`DELETE /api/v1/hostnames/{{dashboard_secret}}` removes the hostname and its DNS
records and returns HTTP 204. Delete only when the owner requests it.
On the website, GET `/` displays the homepage; submitting its form creates a
hostname and redirects to the private dashboard. Keep that dashboard URL: it is
the management credential. Dashboard passwords protect only the web dashboard,
not API access using the dashboard secret. Use the API for automation instead of
scraping private pages. Unregistered hostnames are cleaned up automatically.
GET `/healthz` is a read-only health check returning HTTP 200 `ok`.
## API and Website
- [OpenAPI schema]({base_url}/openapi.json): Machine-readable request and response definitions.
- [API guide]({base_url}/docs): Human-readable API examples and router setup guidance.
- [Homepage]({base_url}/): Create a hostname through the browser form.
- [Health]({base_url}/healthz): Read-only service availability probe.
## Optional
- [Privacy]({base_url}/privacy): Data handling information.
- [Project README](https://tea.karlcom.de/karl/karldns.de/raw/branch/main/README.md): Self-hosting and clients.
"""
@router.get("/privacy", response_class=HTMLResponse, include_in_schema=False)
def privacy(settings: SettingsDep) -> str:
return _render_privacy_page(settings)
+1
View File
@@ -16,6 +16,7 @@ _RESERVED_PUBLIC_PATHS = {
"/favicon.ico",
"/healthz",
"/install.sh",
"/llms.txt",
"/bootstrap-subset.css",
"/logo.png",
"/logo-nav.webp",
+7 -3
View File
@@ -28,7 +28,10 @@ def _top_nav(label: str | None = None, links: SafeHtml | None = None) -> SafeHtm
if label:
account_html = f'<span class="navbar-text nav-label text-truncate">{escape(label)}</span>'
if links is None:
links = trusted('<a class="nav-link px-0" href="/docs">API</a>')
links = trusted(
'<a class="nav-link px-0" href="/docs">API</a>'
'<a class="nav-link px-0" href="/llms.txt" type="text/plain">llms.txt</a>'
)
theme_toggle = f"""
<button type="button" class="theme-toggle" data-theme-toggle aria-label="Switch to dark theme">
{_svg_icon("moon", "theme-icon theme-icon-moon")}
@@ -59,9 +62,9 @@ def _site_footer() -> SafeHtml:
<div class="container footer-inner">
<div class="footer-made">Made with <span aria-label="love">❤️</span> by karl</div>
<nav class="footer-links" aria-label="Footer links">
<a class="footer-link" href="https://github.com/StasonJatham/karldns.de" rel="noopener">
<a class="footer-link" href="https://tea.karlcom.de/karl/karldns.de" rel="noopener">
{_svg_icon("github", "footer-icon")}
<span>GitHub</span>
<span>Gitea</span>
</a>
<a class="footer-link" href="https://karl.fail" rel="noopener">
{_svg_icon("blog", "footer-icon")}
@@ -100,6 +103,7 @@ def _page(
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#ffffff">
<link rel="describedby" href="/llms.txt" type="text/plain">
<meta name="description" content="{attr(options.description)}">
{_canonical_tag(options.canonical_url)}
{_social_meta_tags(
+163
View File
@@ -0,0 +1,163 @@
"""Bounded inert request evidence for explicitly authorized cloud analysis; never execute it."""
import ipaddress,json,re
from urllib.parse import unquote,unquote_plus,parse_qsl,urlencode
VERSION=1
SENSITIVE=re.compile(r'password|passwd|pwd|secret|token|authorization|api.?key|auth.?key|session|cookie|credential|private.?key|client.?secret|csrf|xsrf|nonce|^(?:user(?:name)?|email|login|sid)$',re.I)
def clean(text,secrets=()):
text=str(text)
for secret in secrets:
if isinstance(secret,str) and len(secret)>=1:text=text.replace(secret,'[SECRET]')
text=re.sub(r'-----BEGIN [^-]*PRIVATE KEY-----.*?-----END [^-]*PRIVATE KEY-----','[PRIVATE_KEY]',text,flags=re.S)
text=re.sub(r'\b(?:Bearer|Basic)\s+[^\s"\'<>]+','[AUTH]',text,flags=re.I)
# Quoted JSON, forms, XML attributes and common key=value credentials, including malformed payloads.
text=re.sub(r'''(?ix)(["']?(?:password|passwd|pwd|credential|private[_-]?key|secret|[\w-]*token|authorization|(?:api|access|auth)[_-]?key|client[_-]?secret|session(?:[_-]?id)?|cookie|user(?:name)?|login|email|csrf|xsrf|nonce|sid)["']?\s*[:=]\s*)("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s&;,<>}\]]+)''',lambda m:m[1]+'"[SECRET]"',text)
text=re.sub(r'(?is)(<(?:password|secret|token|user(?:name)?|session)[^>]*>).*?(</[^>]+>)',r'\1[SECRET]\2',text)
text=re.sub(r'\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b','[JWT]',text)
text=re.sub(r'\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b','[EMAIL]',text,flags=re.I)
text=re.sub(r'(?i)(https?://)[^\s/"\'<>]+',r'\1[HOST]',text)
text=re.sub(r'(?i)\b(?:[a-z0-9-]+\.)+(?:com|net|org|de|fail|local|internal|io|xyz|ru|cn|top|test|invalid)\b','[HOST]',text)
text=re.sub(r'(?<![\w.])(?:\d{1,3}\.){3}\d{1,3}(?![\w.])','[IP]',text)
def ipv6(m):
try:ipaddress.IPv6Address(m[0]);return '[IP]'
except ValueError:return m[0]
text=re.sub(r'(?<!\w)(?:[0-9a-fA-F]{0,4}:){2,}[0-9a-fA-F:.]*(?!\w)',ipv6,text)
text=re.sub(r'(?<![\w])[A-Za-z0-9_+/=-]{32,}(?![\w])',lambda m:'[OPAQUE:'+str(len(m[0]))+']',text)
text=re.sub(r'(?i)(/(?:m|api/v1/(?:hostnames|router/(?:register|update))))/[^\s?\"\'<>]+',r'\1/[SECRET]',text)
return ''.join(c if c in '\n\r\t' or ord(c)>=32 else ' ' for c in text)
def private_clean(text,secrets=()):
original=str(text);decoded=original
for _ in range(4):
next_value=unquote(decoded)
if next_value==decoded:break
decoded=next_value
if unquote(decoded)!=decoded:return '[ENCODING_LIMIT]'
cleaned_original=clean(original,secrets);checked=cleaned_original
for _ in range(4):checked=unquote(checked)
return cleaned_original if clean(checked,secrets)==checked else clean(decoded,secrets)
def decoded_key(value):
for _ in range(4):value=unquote(value)
return value
def structured(value,secrets,depth=0):
if depth>12:return '[DEPTH_LIMIT]'
if isinstance(value,dict):return {private_clean(k,secrets)[:128]:('[SECRET]' if (SENSITIVE.search(decoded_key(k)) or unquote(decoded_key(k))!=decoded_key(k)) else structured(v,secrets,depth+1)) for k,v in list(value.items())[:80]}
if isinstance(value,list):return [structured(v,secrets,depth+1) for v in value[:80]]
return private_clean(value,secrets) if isinstance(value,str) else value
def structure_truncated(value,depth=0):
if depth>12:return True
if isinstance(value,(dict,list)):
values=list(value.values()) if isinstance(value,dict) else value
return len(values)>80 or any(structure_truncated(v,depth+1) for v in values[:80])
return False
def request_sample(s,body,secrets=()):
r=s['home']['honeypot']['request'];out=[];budget=4000;header_rows=r.get('headers',[])
for h in header_rows[:48]:
name=private_clean(h['name'],secrets)[:128];name=name if re.fullmatch(r"[!#$%&'*+.^_`|~0-9A-Za-z-]+",name) else '[INVALID_HEADER]';value=h['value'];sensitive=bool(SENSITIVE.search(decoded_key(h['name']))) or unquote(decoded_key(h['name']))!=decoded_key(h['name']) or name.lower() in {'x-request-id','x-real-ip','x-forwarded-for','cf-connecting-ip','true-client-ip','forwarded'}
if name.lower()=='host' and re.fullmatch(r'[A-Za-z0-9._:\[\]-]+',value):sensitive=True
# NSC_USER can itself carry an exploit; ordinary principal names are masked.
if name.lower()=='nsc_user' and re.fullmatch(r'[\w@. -]{1,128}',value):sensitive=True
cleaned='[REDACTED]' if sensitive else private_clean(value,secrets)
limit=min(1000,budget);out.append({'name':name,'value':cleaned[:limit] if limit else '','original_length':len(value),'truncated':len(cleaned)>limit});budget-=min(len(cleaned),limit)
payload={'status':'missing_or_unverified','text':''}
if body is not None:
capped=False
try:
parsed=json.loads(body);capped=structure_truncated(parsed);text=json.dumps(structured(parsed,secrets),ensure_ascii=False)
except (ValueError,TypeError,RecursionError):
content_type=' '.join(h['value'] for h in header_rows if h['name'].lower()=='content-type').lower()
if 'application/x-www-form-urlencoded' in content_type:
try:pairs=parse_qsl(body,keep_blank_values=True,max_num_fields=100)
except ValueError:pairs=[]
text=urlencode([(private_clean(k,secrets),'[SECRET]' if (SENSITIVE.search(decoded_key(k)) or unquote(decoded_key(k))!=decoded_key(k)) else private_clean(v,secrets)) for k,v in pairs]) if pairs else private_clean(body,secrets)
else:text=private_clean(body,secrets)
payload={'status':'verified_text','text':text[:4000],'truncated':len(text)>4000 or capped,'original_bytes':len(body.encode())}
method=s.get('http',{}).get('request',{}).get('method','UNKNOWN')
if not re.fullmatch('[A-Z]{1,20}',str(method)):method='UNKNOWN'
uri=private_clean(r.get('uri',''),secrets)
return {'method':method,'uri':uri[:1600],'uri_truncated':len(uri)>1600,'headers':out,'headers_complete':r.get('headers_complete') is True,'headers_truncated':len(header_rows)>48,'payload':payload}
class PostCaptureMiddleware:
"""Bounded predispatch evidence; replay unchanged bytes to the application."""
def __init__(self,app,settings):
self.app=app;self.settings=settings
async def __call__(self,scope,receive,send):
if scope['type']!='http' or scope['method']!='POST':
await self.app(scope,receive,send);return
import datetime,logging,os,uuid
from starlette.requests import Request
from starlette.responses import PlainTextResponse
from ..security import client_ip
request=Request(scope);headers=[{'name':k.decode('latin1'),'value':v.decode('latin1')} for k,v in scope['headers']]
secrets=[v for k,v in self.settings.model_dump().items() if SENSITIVE.search(decoded_key(k)) and isinstance(v,str) and v]
secrets += [v for k,v in os.environ.items() if SENSITIVE.search(decoded_key(k)) and v]
secrets.append(self.settings.admin_path)
supplied=request.headers.get('x-request-id','');rid=supplied if re.fullmatch('[a-fA-F0-9]{32}',supplied) else uuid.uuid4().hex
scope.setdefault('state',{})['audit_request_id']=rid
scope['headers']=[(k,v) for k,v in scope['headers'] if k.lower()!=b'x-request-id']+[(b'x-request-id',rid.encode())]
status=500;body=bytearray();complete=False;oversized=False;disconnected=False
try:declared=int(request.headers.get('content-length','0'))
except ValueError:declared=0
try:
if declared>self.settings.max_request_body_bytes:oversized=True
else:
while True:
message=await receive()
if message['type']=='http.disconnect':disconnected=True;break
chunk=message.get('body',b'');remaining=self.settings.max_request_body_bytes+1-len(body)
body.extend(chunk[:remaining])
if len(chunk)>remaining or len(body)>self.settings.max_request_body_bytes:oversized=True;break
if not message.get('more_body',False):complete=True;break
async def wrapped_send(message):
nonlocal status
if message['type']=='http.response.start':
status=message['status'];message=dict(message)
message['headers']=[(k,v) for k,v in message.get('headers',[]) if k.lower()!=b'x-request-id']+[(b'x-request-id',rid.encode())]
await send(message)
if oversized:
await PlainTextResponse('request body too large',status_code=413)(scope,receive,wrapped_send)
elif disconnected:status=499
else:
replayed=False
async def replay():
nonlocal replayed
if not replayed:replayed=True;return {'type':'http.request','body':bytes(body),'more_body':False}
return await receive()
await self.app(scope,replay,wrapped_send)
finally:
content_type=request.headers.get('content-type','').lower();encoding=request.headers.get('content-encoding','identity').lower()
reason='verified_text';text=None
if oversized:reason='oversized_omitted'
elif not complete:reason='incomplete_omitted'
elif encoding not in ('','identity'):reason='encoded_omitted'
elif 'multipart/' in content_type:reason='multipart_omitted'
elif content_type and not any(x in content_type for x in ('text/','json','xml','x-www-form-urlencoded','javascript')):reason='binary_type_omitted'
else:
try:
text=bytes(body).decode('utf-8')
if any(ord(c)<32 and c not in '\r\n\t' for c in text):reason='binary_omitted';text=None
except UnicodeDecodeError:reason='invalid_utf8_omitted'
source={'home':{'honeypot':{'request':{'headers':headers,'headers_complete':True,'uri':scope.get('path','')+('?' + scope.get('query_string',b'').decode('latin1') if scope.get('query_string') else '')}}},'http':{'request':{'method':'POST'}}}
sample=request_sample(source,text,secrets)
sample['schema']=1
sample['headers_complete']=not sample['headers_truncated'] and not any(h['truncated'] for h in sample['headers'])
sample['payload'].update(status=reason,original_bytes=len(body) if complete else None,captured_bytes=len(body),declared_bytes=declared if 'content-length' in request.headers and declared>=0 else None,complete=complete,capture_truncated=oversized,encoding=encoding if encoding in ('identity','gzip','deflate','br','') else 'other',truncated=sample['payload'].get('truncated',False))
row={'@timestamp':datetime.datetime.now(datetime.timezone.utc).isoformat(),'event':{'dataset':'karldns.security','kind':'event','category':['web'],'type':['info'],'action':'karldns.post_request','outcome':'unknown','provider':'karldns-native'},'http':{'request':{'method':'POST','id':rid},'response':{'status_code':status}},'karldns':{'request_sample':sample},'message':'KarlDNS sanitized POST request'}
decoded=unquote_plus(bytes(body).decode('utf-8',errors='replace')).lower()
row['labels']={'auth_scope':'public_post','actor_identity':'not_available','indicator_verdict':'review_only','unexpected_body':scope.get('path')=='/' and 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))}
try:row['source']={'ip':str(ipaddress.ip_address(client_ip(request,self.settings)))}
except ValueError:pass
while len(json.dumps(row,ensure_ascii=True))>18000:
if sample['payload']['text']:
sample['payload']['text']=sample['payload']['text'][:len(sample['payload']['text'])//2];sample['payload']['truncated']=True
elif sample['headers']:
sample['headers'].pop();sample['headers_truncated']=True;sample['headers_complete']=False
else:sample['uri']=sample['uri'][:200];sample['uri_truncated']=True;break
logging.getLogger('karldns.security').warning('KARLDNSSEC %s',json.dumps(row,separators=(',',':'),ensure_ascii=True))
+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/")
+3 -3
View File
@@ -186,11 +186,11 @@ def _render_api_docs_page(settings: DdnsSettings, request_ip: str | None = None)
def _api_docs_examples(base_url: str, example_ipv4: str) -> _ApiDocsExamples:
return _ApiDocsExamples(
create=f"curl -sS -X POST {base_url}/api/v1/hostnames",
register=f"curl -sS -X POST '{base_url}/api/v1/router/register/<karl_dashboard_secret>?myip={example_ipv4}'",
create=f"curl -sS -X POST -H \"Content-Type: application/x-www-form-urlencoded\" {base_url}/api/v1/hostnames",
register=f"curl -sS -X POST -H \"Content-Type: application/x-www-form-urlencoded\" '{base_url}/api/v1/router/register/<karl_dashboard_secret>?myip={example_ipv4}'",
update=f"curl -sS '{base_url}/api/v1/router/update/<karl_update_secret>?myip={example_ipv4}'",
status=f"curl -sS '{base_url}/api/v1/hostnames/<karl_dashboard_secret>?history_limit=10'",
rotate=f"curl -sS -X POST {base_url}/api/v1/hostnames/<karl_dashboard_secret>/rotate-update-url",
rotate=f"curl -sS -X POST -H \"Content-Type: application/x-www-form-urlencoded\" {base_url}/api/v1/hostnames/<karl_dashboard_secret>/rotate-update-url",
schema=f"curl -sS {base_url}/openapi.json",
)