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

+1
View File
@@ -5,6 +5,7 @@ ENV DDNS_DATABASE=/data/ddns.sqlite3
WORKDIR /app WORKDIR /app
COPY pyproject.toml README.md requirements.txt /app/ COPY pyproject.toml README.md requirements.txt /app/
COPY karldns /app/karldns COPY karldns /app/karldns
COPY scripts /app/karldns/assets/scripts
RUN pip install --no-cache-dir -r requirements.txt . \ RUN pip install --no-cache-dir -r requirements.txt . \
&& addgroup -S karldns \ && addgroup -S karldns \
&& adduser -S -G karldns -h /app karldns \ && adduser -S -G karldns -h /app karldns \
+3 -3
View File
@@ -78,7 +78,7 @@ That means users always have a current hostname for VPN, remote access, self-hos
## Quick Start ## Quick Start
```bash ```bash
git clone https://github.com/StasonJatham/karldns.de.git git clone https://tea.karlcom.de/karl/karldns.de.git
cd karldns.de cd karldns.de
python3 -m venv .venv python3 -m venv .venv
source .venv/bin/activate source .venv/bin/activate
@@ -231,10 +231,10 @@ System-wide install:
curl -fsSL https://karldns.de/install.sh | sudo sh curl -fsSL https://karldns.de/install.sh | sudo sh
``` ```
The same script is hosted in GitHub and can be used directly: The same script is hosted in Gitea and can be used directly:
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/install-linux.sh | sudo sh curl -fsSL https://tea.karlcom.de/karl/karldns.de/raw/branch/main/scripts/install-linux.sh | sudo sh
``` ```
Use an existing KarlDNS update URL: Use an existing KarlDNS update URL:
+2
View File
@@ -17,6 +17,7 @@ from .routers import admin, api, assets, management, public, updates
from .services.ddns import DdnsService from .services.ddns import DdnsService
from .settings import DdnsSettings from .settings import DdnsSettings
from .store import DdnsStore from .store import DdnsStore
from .web.request_capture import PostCaptureMiddleware
from .web.security_headers import RequestBodyLimitMiddleware, install_security_middleware from .web.security_headers import RequestBodyLimitMiddleware, install_security_middleware
__all__ = [ __all__ = [
@@ -92,6 +93,7 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
app.state.store = store app.state.store = store
app.state.service = service app.state.service = service
install_security_middleware(app, settings, store) install_security_middleware(app, settings, store)
app.add_middleware(PostCaptureMiddleware, settings=settings)
app.include_router(api.router) app.include_router(api.router)
app.include_router(assets.router) app.include_router(assets.router)
app.include_router(public.router) app.include_router(public.router)
+2 -1
View File
@@ -51,8 +51,9 @@ AdminBeforeQuery = Annotated[str | None, Query(max_length=64)]
tags=["hostnames"], tags=["hostnames"],
responses={500: {"model": ErrorResponse}}, 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) account = await run_in_threadpool(service.create_managed_account)
request.state.account_created = True
return _create_response(service, account) return _create_response(service, account)
+10 -16
View File
@@ -3,9 +3,12 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Response from fastapi import APIRouter, Response
from fastapi.responses import FileResponse, RedirectResponse from fastapi.responses import FileResponse
ASSET_DIR = Path(__file__).parents[1] / "assets" 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) router = APIRouter(include_in_schema=False)
ASSET_CACHE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"} ASSET_CACHE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"}
@@ -125,24 +128,15 @@ def admin_css() -> FileResponse:
@router.get("/install.sh") @router.get("/install.sh")
def install_sh() -> RedirectResponse: def install_sh() -> FileResponse:
return RedirectResponse( return FileResponse(SCRIPT_DIR / "install-linux.sh", media_type="text/plain", headers={"Cache-Control": "no-cache"})
"https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/install-linux.sh",
status_code=302,
)
@router.get("/uninstall.sh") @router.get("/uninstall.sh")
def uninstall_sh() -> RedirectResponse: def uninstall_sh() -> FileResponse:
return RedirectResponse( return FileResponse(SCRIPT_DIR / "uninstall-linux.sh", media_type="text/plain", headers={"Cache-Control": "no-cache"})
"https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/uninstall-linux.sh",
status_code=302,
)
@router.get("/karldns-update.sh") @router.get("/karldns-update.sh")
def karldns_update_sh() -> RedirectResponse: def karldns_update_sh() -> FileResponse:
return RedirectResponse( return FileResponse(SCRIPT_DIR / "karldns-update.sh", media_type="text/plain", headers={"Cache-Control": "no-cache"})
"https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/karldns-update.sh",
status_code=302,
)
+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: async def create_hostname(request: Request, settings: SettingsDep, service: ServiceDep) -> RedirectResponse:
await request_form(request, settings) await request_form(request, settings)
account = await run_in_threadpool(service.create_managed_account) account = await run_in_threadpool(service.create_managed_account)
request.state.account_created = True
return RedirectResponse(service.dashboard_url(account), status_code=303) return RedirectResponse(service.dashboard_url(account), status_code=303)
@@ -29,6 +30,77 @@ def healthz() -> str:
return "ok" 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) @router.get("/privacy", response_class=HTMLResponse, include_in_schema=False)
def privacy(settings: SettingsDep) -> str: def privacy(settings: SettingsDep) -> str:
return _render_privacy_page(settings) return _render_privacy_page(settings)
+1
View File
@@ -16,6 +16,7 @@ _RESERVED_PUBLIC_PATHS = {
"/favicon.ico", "/favicon.ico",
"/healthz", "/healthz",
"/install.sh", "/install.sh",
"/llms.txt",
"/bootstrap-subset.css", "/bootstrap-subset.css",
"/logo.png", "/logo.png",
"/logo-nav.webp", "/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: if label:
account_html = f'<span class="navbar-text nav-label text-truncate">{escape(label)}</span>' account_html = f'<span class="navbar-text nav-label text-truncate">{escape(label)}</span>'
if links is None: 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""" theme_toggle = f"""
<button type="button" class="theme-toggle" data-theme-toggle aria-label="Switch to dark theme"> <button type="button" class="theme-toggle" data-theme-toggle aria-label="Switch to dark theme">
{_svg_icon("moon", "theme-icon theme-icon-moon")} {_svg_icon("moon", "theme-icon theme-icon-moon")}
@@ -59,9 +62,9 @@ def _site_footer() -> SafeHtml:
<div class="container footer-inner"> <div class="container footer-inner">
<div class="footer-made">Made with <span aria-label="love">❤️</span> by karl</div> <div class="footer-made">Made with <span aria-label="love">❤️</span> by karl</div>
<nav class="footer-links" aria-label="Footer links"> <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")} {_svg_icon("github", "footer-icon")}
<span>GitHub</span> <span>Gitea</span>
</a> </a>
<a class="footer-link" href="https://karl.fail" rel="noopener"> <a class="footer-link" href="https://karl.fail" rel="noopener">
{_svg_icon("blog", "footer-icon")} {_svg_icon("blog", "footer-icon")}
@@ -100,6 +103,7 @@ def _page(
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#ffffff"> <meta name="theme-color" content="#ffffff">
<link rel="describedby" href="/llms.txt" type="text/plain">
<meta name="description" content="{attr(options.description)}"> <meta name="description" content="{attr(options.description)}">
{_canonical_tag(options.canonical_url)} {_canonical_tag(options.canonical_url)}
{_social_meta_tags( {_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 from __future__ import annotations
import logging import logging
import datetime
import ipaddress
import json
import time import time
import re
import uuid
from urllib.parse import unquote_plus
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
@@ -15,6 +21,7 @@ from ..settings import DdnsSettings
from ..store import DdnsStore from ..store import DdnsStore
ACCESS_LOGGER = logging.getLogger("karldns.access") ACCESS_LOGGER = logging.getLogger("karldns.access")
SECURITY_LOGGER = logging.getLogger("karldns.security")
SECRET_RESPONSE_PREFIXES = ( SECRET_RESPONSE_PREFIXES = (
"/m/", "/m/",
"/api/v1/hostnames", "/api/v1/hostnames",
@@ -59,6 +66,10 @@ def install_security_middleware(app: FastAPI, settings: DdnsSettings, store: Ddn
@app.middleware("http") @app.middleware("http")
async def security_middleware(request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response: async def security_middleware(request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response:
started_at = time.perf_counter() 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: try:
content_length = int(request.headers.get("content-length", "0") or "0") content_length = int(request.headers.get("content-length", "0") or "0")
except ValueError: 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) allowed = await run_in_threadpool(store.allow_rate_limit, key, limit)
if not allowed: if not allowed:
await run_in_threadpool(store.increment_metric, "rate_limit_hits_total") 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) 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) 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): if is_secret_response(request):
response.headers["Cache-Control"] = "no-store" response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache" response.headers["Pragma"] = "no-cache"
@@ -103,6 +129,52 @@ def install_security_middleware(app: FastAPI, settings: DdnsSettings, store: Ddn
return response 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: def is_admin_path(path: str, settings: DdnsSettings) -> bool:
admin_path = settings.admin_path.rstrip("/") admin_path = settings.admin_path.rstrip("/")
return path == admin_path or path.startswith(f"{admin_path}/") or path.startswith("/api/v1/admin/") 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: def _api_docs_examples(base_url: str, example_ipv4: str) -> _ApiDocsExamples:
return _ApiDocsExamples( return _ApiDocsExamples(
create=f"curl -sS -X POST {base_url}/api/v1/hostnames", 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 '{base_url}/api/v1/router/register/<karl_dashboard_secret>?myip={example_ipv4}'", 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}'", 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'", 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", schema=f"curl -sS {base_url}/openapi.json",
) )
+2 -2
View File
@@ -28,8 +28,8 @@ dependencies = [
[project.urls] [project.urls]
Homepage = "https://karldns.de" Homepage = "https://karldns.de"
Repository = "https://github.com/StasonJatham/karldns.de" Repository = "https://tea.karlcom.de/karl/karldns.de"
Issues = "https://github.com/StasonJatham/karldns.de/issues" Issues = "https://tea.karlcom.de/karl/karldns.de/issues"
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
+57 -4
View File
@@ -1,4 +1,57 @@
dnspython==2.8.0 # This file was autogenerated by uv via the following command:
fastapi==0.136.1 # uv export --no-dev --no-hashes --no-emit-project -o requirements.txt
pydantic==2.13.4 annotated-doc==0.0.5
uvicorn[standard]==0.47.0 # via fastapi
annotated-types==0.8.0
# via pydantic
anyio==4.15.1
# via
# starlette
# watchfiles
click==8.5.0
# via uvicorn
dnspython==2.9.0
# via karldns
fastapi==0.156.4
# via karldns
h11==0.16.0
# via uvicorn
httptools==0.9.0
# via uvicorn
idna==3.20
# via anyio
opentelemetry-api==1.45.1
# via fastapi
pydantic==2.14.1
# via
# fastapi
# karldns
pydantic-core==2.50.1
# via pydantic
python-dotenv==1.2.4
# via uvicorn
pyyaml==6.0.3
# via uvicorn
starlette==1.9.0
# via fastapi
typing-extensions==4.16.0
# via
# anyio
# fastapi
# opentelemetry-api
# pydantic
# pydantic-core
# starlette
# typing-inspection
typing-inspection==0.4.4
# via
# fastapi
# pydantic
uvicorn==0.54.0
# via karldns
uvloop==0.23.0 ; platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'
# via uvicorn
watchfiles==1.3.0
# via uvicorn
websockets==17.3
# via uvicorn
+7 -5
View File
@@ -8,7 +8,7 @@ KARLDNS_UPDATE_URL="${KARLDNS_UPDATE_URL:-}"
KARLDNS_HOSTNAME="${KARLDNS_HOSTNAME:-}" KARLDNS_HOSTNAME="${KARLDNS_HOSTNAME:-}"
KARLDNS_MANAGEMENT_URL="${KARLDNS_MANAGEMENT_URL:-}" KARLDNS_MANAGEMENT_URL="${KARLDNS_MANAGEMENT_URL:-}"
KARLDNS_ACTIVATION_URL="${KARLDNS_ACTIVATION_URL:-}" KARLDNS_ACTIVATION_URL="${KARLDNS_ACTIVATION_URL:-}"
KARLDNS_RAW_BASE_URL="${KARLDNS_RAW_BASE_URL:-https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts}" KARLDNS_RAW_BASE_URL="${KARLDNS_RAW_BASE_URL:-}"
KARLDNS_IPV4="${KARLDNS_IPV4:-auto}" KARLDNS_IPV4="${KARLDNS_IPV4:-auto}"
KARLDNS_IPV6="${KARLDNS_IPV6:-auto}" KARLDNS_IPV6="${KARLDNS_IPV6:-auto}"
KARLDNS_SKIP_CRON="${KARLDNS_SKIP_CRON:-0}" KARLDNS_SKIP_CRON="${KARLDNS_SKIP_CRON:-0}"
@@ -38,8 +38,8 @@ Options:
-h, --help Show this help -h, --help Show this help
Examples: Examples:
curl -fsSL https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/install-linux.sh | sudo sh curl -fsSL https://karldns.de/install.sh | sudo sh
curl -fsSL https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/install-linux.sh | sh -s -- --mode user curl -fsSL https://karldns.de/install.sh | sh -s -- --mode user
EOF EOF
} }
@@ -62,6 +62,8 @@ while [ "$#" -gt 0 ]; do
esac esac
done done
KARLDNS_RAW_BASE_URL="${KARLDNS_RAW_BASE_URL:-${KARLDNS_BASE_URL%/}}"
need() { need() {
if ! command -v "$1" >/dev/null 2>&1; then if ! command -v "$1" >/dev/null 2>&1; then
echo "KarlDNS installer: missing required command: $1" >&2 echo "KarlDNS installer: missing required command: $1" >&2
@@ -264,7 +266,7 @@ fi
if [ -z "$KARLDNS_UPDATE_URL" ]; then if [ -z "$KARLDNS_UPDATE_URL" ]; then
echo "KarlDNS: creating a new hostname at $KARLDNS_BASE_URL ..." echo "KarlDNS: creating a new hostname at $KARLDNS_BASE_URL ..."
response="$("$curl_bin" -fsS --max-time 30 -X POST "$KARLDNS_BASE_URL/api/v1/hostnames")" response="$("$curl_bin" -fsS --max-time 30 -X POST -H "Content-Type: application/x-www-form-urlencoded" "$KARLDNS_BASE_URL/api/v1/hostnames")"
KARLDNS_UPDATE_URL="$(printf '%s' "$response" | json_field update_url)" KARLDNS_UPDATE_URL="$(printf '%s' "$response" | json_field update_url)"
KARLDNS_HOSTNAME="$(printf '%s' "$response" | json_field hostname)" KARLDNS_HOSTNAME="$(printf '%s' "$response" | json_field hostname)"
KARLDNS_MANAGEMENT_URL="$(printf '%s' "$response" | json_field dashboard_url)" KARLDNS_MANAGEMENT_URL="$(printf '%s' "$response" | json_field dashboard_url)"
@@ -281,7 +283,7 @@ esac
if [ -n "$KARLDNS_ACTIVATION_URL" ] && [ "$KARLDNS_REUSED_CONFIG" = "0" ]; then if [ -n "$KARLDNS_ACTIVATION_URL" ] && [ "$KARLDNS_REUSED_CONFIG" = "0" ]; then
echo "KarlDNS: registering hostname ..." echo "KarlDNS: registering hostname ..."
"$curl_bin" -fsS --max-time 30 -X POST "$KARLDNS_ACTIVATION_URL" >/dev/null "$curl_bin" -fsS --max-time 30 -X POST -H "Content-Type: application/x-www-form-urlencoded" "$KARLDNS_ACTIVATION_URL" >/dev/null
fi fi
if [ "$KARLDNS_INSTALL_MODE" = "system" ]; then if [ "$KARLDNS_INSTALL_MODE" = "system" ]; then
+26
View File
@@ -12,6 +12,32 @@ def _path(url: str) -> str:
return parsed.path return parsed.path
def test_llms_txt_documents_public_api_without_creating_accounts(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="private-admin-password",
public_base_url="https://dns.example.net",
hostname_suffix="dns.example.net",
)
)
with TestClient(app) as client:
response = client.get("/llms.txt")
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/plain")
assert response.text.startswith("# KarlDNS\n\n> ")
assert "https://dns.example.net/openapi.json" in response.text
assert "private-admin-password" not in response.text
assert "/api/v1/admin" not in response.text
assert "## Optional\n" in response.text
assert 'rel="describedby" href="/llms.txt"' in client.get("/").text
for path, operations in client.get("/openapi.json").json()["paths"].items():
assert path in response.text
for method in operations:
assert method.upper() in response.text
assert app.state.store.count_accounts() == 0
def test_custom_api_docs_include_only_current_api_surfaces(tmp_path: Path) -> None: def test_custom_api_docs_include_only_current_api_surfaces(tmp_path: Path) -> None:
app = make_app( app = make_app(
DdnsSettings( DdnsSettings(
+27
View File
@@ -0,0 +1,27 @@
import json
import logging
from fastapi.testclient import TestClient
from karldns import DdnsSettings, make_app
def test_creation_audit_preserves_public_provisioning_and_redacts(tmp_path, caplog):
app = make_app(DdnsSettings(database_path=tmp_path / 'audit.sqlite3', admin_password='admin', hostname_suffix='example.net'))
with TestClient(app) as client, caplog.at_level(logging.WARNING, logger='karldns.security'):
for path, body, code in [('/', '', 303), ('/', '<?php eval("super-secret") ?>', 303), ('/api/v1/hostnames', 'super-secret', 201)]:
caplog.clear()
response = client.post(path, content=body, headers={'X-Request-ID': 'a' * 32}, follow_redirects=False)
assert response.status_code == code
event = json.loads(next(r.message.removeprefix('KARLDNSSEC ') for r in caplog.records if r.message.startswith('KARLDNSSEC ')))
assert event['http']['request']['id'] == response.headers['x-request-id'] == 'a' * 32
assert event['labels']['account_created'] is True
assert event['labels']['dns_activation'] is False
assert event['labels']['php_code_marker'] == ('<?php' in body)
assert event['labels']['unexpected_body'] == bool(body)
assert 'super-secret' not in json.dumps(event)
assert response.headers.get('location', 'no-private-url') not in caplog.text
caplog.clear()
response = client.post('/', content='x', headers={'X-Request-ID': 'private-invalid-id'}, follow_redirects=False)
assert len(response.headers['x-request-id']) == 32
assert 'private-invalid-id' not in caplog.text
assert app.state.store.count_accounts() == 4
assert app.state.store.list_records() == []
+48
View File
@@ -0,0 +1,48 @@
import json
import logging
from fastapi.testclient import TestClient
from karldns import DdnsSettings, make_app
from karldns.web.request_capture import private_clean,request_sample
def test_post_capture(tmp_path,caplog):
secret='runtime-canary-6729'
app=make_app(DdnsSettings(database_path=tmp_path/'post.sqlite3',admin_password=secret,hostname_suffix='example.net'))
def event():
events=[json.loads(r.message.removeprefix('KARLDNSSEC ')) for r in caplog.records if r.message.startswith('KARLDNSSEC ')]
return next(e for e in events if e['event']['action']=='karldns.post_request')
@app.post('/capture-exception')
async def failed():raise RuntimeError('synthetic failure')
with TestClient(app,raise_server_exceptions=False) as client,caplog.at_level(logging.WARNING,logger='karldns.security'):
for path,body,ctype,code in [('/',b'', 'text/plain',303),('/api/v1/hostnames',b'', 'text/plain',201),('/missing',b'<?php eval($_POST[x]); ?> UNION SELECT $(id) %252e%252e%252f', 'text/plain',404),('/',b'\xff', 'text/plain',400),('/missing',b'\x00binary','application/octet-stream',404)]:
caplog.clear();response=client.post(path,content=body,headers={'Content-Type':ctype,'X-Canary':secret,'Authorization':'Bearer auth-canary','Cookie':'session=cookie-canary','X-Request-ID':'f'*32},follow_redirects=False)
assert response.status_code==code
e=event();sample=e['karldns']['request_sample']
assert e['http']['request']['id']==response.headers['x-request-id']=='f'*32
for value in [secret,'auth-canary','cookie-canary']:assert value not in caplog.text
assert sample['payload']['original_bytes']==len(body)
if path=='/missing' and ctype=='text/plain':assert sample['payload']['text']==body.decode()
if body==b'\xff':assert sample['payload']['status']=='invalid_utf8_omitted'
caplog.clear();response=client.post('/capture-exception',content='probe=exception');assert response.status_code==500;assert event()['http']['response']['status_code']==500
assert app.state.store.count_accounts()==2
assert app.state.store.list_records()==[]
for headers,body,status in [({'Content-Length':'20000'},b'', 'oversized_omitted'),({'Content-Encoding':'gzip'},b'private', 'encoded_omitted'),({'Content-Type':'multipart/form-data;boundary=z'},b'password=private','multipart_omitted')]:
caplog.clear();r=client.post('/missing',headers=headers,content=body);s=event()['karldns']['request_sample'];assert s['payload']['status']==status;assert not s['payload']['text']
caplog.clear();r=client.post('/admin',content='password=short-canary',headers={'Content-Type':'application/x-www-form-urlencoded'});assert 'short-canary' not in caplog.text
def test_privacy_and_syntax():
for text in ['{"credential":"short-canary"','session=short-canary','private_key=short-canary','%2570assword=short-canary','password=short-canary','https://host.invalid/m/short-canary']:
assert 'short-canary' not in private_clean(text)
attack='<?php system($_GET[x]); ?>; UNION SELECT 1; $(id); %252e%252e%252f'
assert private_clean(attack)==attack
source={'home':{'honeypot':{'request':{'headers':[],'headers_complete':True,'uri':'/'}}},'http':{'request':{'method':'POST'}}}
for body in ['{"%2570assword":"short-canary"}', '{"private_key":"short-canary"}']:
assert 'short-canary' not in json.dumps(request_sample(source,body))
def test_structural_capture_truncation():
source={'home':{'honeypot':{'request':{'headers':[],'headers_complete':True,'uri':'/'}}},'http':{'request':{'method':'POST'}}}
for value in [list(range(81)),{str(i):i for i in range(81)}]:
assert request_sample(source,json.dumps(value))['payload']['truncated']
assert '%252e%252e%252f' in private_clean('password=short-canary&attack=%252e%252e%252f')
+13 -4
View File
@@ -126,15 +126,19 @@ esac
"user", "user",
"--no-cron", "--no-cron",
"--no-run", "--no-run",
"--base-url",
"https://karldns.test",
env={ env={
"HOME": str(home_dir), "HOME": str(home_dir),
"PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}",
"KARLDNS_BASE_URL": "https://karldns.test", "KARLDNS_BASE_URL": "https://karldns.test",
"KARLDNS_RAW_BASE_URL": "https://raw.karldns.test",
}, },
) )
log = log_file.read_text(encoding="utf-8") log = log_file.read_text(encoding="utf-8")
assert log.count("Content-Type: application/x-www-form-urlencoded") == 2
assert "https://karldns.test/karldns-update.sh" in log
assert "/api/v1/hostnames" in log assert "/api/v1/hostnames" in log
assert "/api/v1/router/register/karl_manage_secret_12345678901234567890" in log assert "/api/v1/router/register/karl_manage_secret_12345678901234567890" in log
assert "KarlDNS: registering hostname" in result.stdout assert "KarlDNS: registering hostname" in result.stdout
@@ -238,15 +242,20 @@ cp "$1" {crontab_file}
assert not (home_dir / ".cache" / "karldns").exists() assert not (home_dir / ".cache" / "karldns").exists()
def test_hosted_script_routes_redirect_to_github(tmp_path: Path) -> None: def test_hosted_script_routes_serve_bundled_scripts(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin")) app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app) client = TestClient(app)
assert client.get("/docs").text.count("Content-Type: application/x-www-form-urlencoded") >= 3
for path, target in { for path, target in {
"/install.sh": "install-linux.sh", "/install.sh": "install-linux.sh",
"/uninstall.sh": "uninstall-linux.sh", "/uninstall.sh": "uninstall-linux.sh",
"/karldns-update.sh": "karldns-update.sh", "/karldns-update.sh": "karldns-update.sh",
}.items(): }.items():
response = client.get(path, follow_redirects=False) response = client.get(path, follow_redirects=False)
assert response.status_code == 302 assert response.status_code == 200
assert response.headers["location"].endswith(f"/scripts/{target}") assert response.content == (ROOT / "scripts" / target).read_bytes()
assert response.headers["content-type"].startswith("text/plain")
assert response.headers["cache-control"] == "no-cache"
assert "location" not in response.headers
+1
View File
@@ -7,6 +7,7 @@ def test_reserved_admin_path_matches_exact_and_nested_public_routes() -> None:
assert _is_reserved_admin_path("/api") assert _is_reserved_admin_path("/api")
assert _is_reserved_admin_path("/api/v1") assert _is_reserved_admin_path("/api/v1")
assert _is_reserved_admin_path("/logo.png") assert _is_reserved_admin_path("/logo.png")
assert _is_reserved_admin_path("/llms.txt")
assert not _is_reserved_admin_path("/ops") assert not _is_reserved_admin_path("/ops")
assert not _is_reserved_admin_path("/apix") assert not _is_reserved_admin_path("/apix")
+1 -1
View File
@@ -737,7 +737,7 @@ def test_footer_links_and_privacy_page_are_available(tmp_path: Path) -> None:
assert response.text.count('class="lucide-icon footer-icon"') == 5 assert response.text.count('class="lucide-icon footer-icon"') == 5
assert 'class="lucide-icon theme-icon theme-icon-moon"' in response.text assert 'class="lucide-icon theme-icon theme-icon-moon"' in response.text
assert 'class="lucide-icon copy-icon"' in response.text assert 'class="lucide-icon copy-icon"' in response.text
assert 'href="https://github.com/StasonJatham/karldns.de"' in response.text assert 'href="https://tea.karlcom.de/karl/karldns.de"' in response.text
assert 'href="https://karl.fail"' in response.text assert 'href="https://karl.fail"' in response.text
assert 'href="https://karlcom.de"' in response.text assert 'href="https://karlcom.de"' in response.text
assert 'href="/privacy"' in response.text assert 'href="/privacy"' in response.text
Generated
+783 -551
View File
File diff suppressed because it is too large. Load diff