Archived
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:
1 parent
4ecc8eb874
commit
5c46b47937
21 files changed
+1301
-593
No files matched your search
@@ -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 \
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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,
|
|
||||||
)
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -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/")
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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() == []
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user