Polish KarlDNS production UI and docs

This commit is contained in:
StasonJatham committed 2026-05-21 10:05:40 +02:00
1 parent ca92dc211a
commit 5beee0e5c6
61 files changed
+1566 -523

No files matched your search

+2 -1
View File
@@ -8,13 +8,14 @@ from time import monotonic
from .accounts import AccountsMixin
from .domains import DomainsMixin
from .metrics import MetricsMixin
from .rate_limit import RateLimitCleanupMixin
from .records import RecordsEventsMixin
from .scheduler import SchedulerMixin
from .schema import connect_database, initialize_schema
class DdnsStore(AccountsMixin, DomainsMixin, RecordsEventsMixin, RateLimitCleanupMixin, SchedulerMixin):
class DdnsStore(AccountsMixin, DomainsMixin, MetricsMixin, RecordsEventsMixin, RateLimitCleanupMixin, SchedulerMixin):
def __init__(self, database_path: Path) -> None:
self.database_path = database_path
self._connections = threading.local()
-7
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
import re
import secrets
import sqlite3
from datetime import UTC, datetime
from ..ddns_security import hash_lookup_token, hash_secret, verify_secret
@@ -36,7 +35,6 @@ class AccountsMixin:
def list_accounts(self, limit: int = 100, offset: int = 0) -> list[AccountRow]:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT accounts.hostname, accounts.username, accounts.update_slug,
@@ -57,7 +55,6 @@ class AccountsMixin:
def get_account_by_hostname(self, hostname: str) -> AccountRow | None:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
account = conn.execute(
"""
SELECT accounts.hostname, accounts.username, accounts.update_slug, accounts.dashboard_password_hash,
@@ -72,7 +69,6 @@ class AccountsMixin:
def get_account_by_slug(self, update_slug: str) -> AccountRow | None:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
account = conn.execute(
"""
SELECT hostname, username, token_hash, update_slug, disabled FROM accounts
@@ -84,7 +80,6 @@ class AccountsMixin:
def get_account_by_management_slug(self, management_slug: str) -> AccountRow | None:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
account = conn.execute(
"""
SELECT accounts.hostname, accounts.username, accounts.update_slug, accounts.dashboard_password_hash,
@@ -101,7 +96,6 @@ class AccountsMixin:
if not username or not token:
return False
with self.connect() as conn:
conn.row_factory = sqlite3.Row
account = conn.execute(
"""
SELECT token_hash, disabled FROM accounts
@@ -162,7 +156,6 @@ class AccountsMixin:
if not password:
return False
with self.connect() as conn:
conn.row_factory = sqlite3.Row
account = conn.execute(
"""
SELECT dashboard_password_hash, disabled FROM accounts
-4
View File
@@ -47,7 +47,6 @@ class DomainsMixin:
now = datetime.now(UTC).isoformat()
claim_hash = hash_lookup_token(claim_secret)
with self.connect() as conn:
conn.row_factory = sqlite3.Row
sql = "SELECT domain, token, owner_hostname, verified_at FROM domain_challenges WHERE domain = ? AND claim_hash = ?"
params = [domain, claim_hash]
if owner_hostname is not None:
@@ -76,7 +75,6 @@ class DomainsMixin:
owner_hostname: str | None = None,
) -> DomainChallengeRow | None:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
sql = "SELECT domain, token, owner_hostname, verified_at FROM domain_challenges WHERE domain = ?"
params = [domain]
if claim_secret is not None:
@@ -90,7 +88,6 @@ class DomainsMixin:
def get_domain_challenge_by_secret(self, claim_secret: str) -> DomainChallengeRow | None:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
challenge = conn.execute(
"SELECT domain, token, owner_hostname, verified_at FROM domain_challenges WHERE claim_hash = ?",
(hash_lookup_token(claim_secret),),
@@ -103,7 +100,6 @@ class DomainsMixin:
def list_domain_challenges(self, limit: int = 100, offset: int = 0) -> list[DomainChallengeRow]:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT domain, owner_hostname, created_at, verified_at
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
from datetime import UTC, datetime
class MetricsMixin:
def increment_metric(self, name: str, amount: float = 1.0) -> None:
now = datetime.now(UTC).isoformat()
with self.connect() as conn:
conn.execute(
"""
INSERT INTO service_metrics(name, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(name) DO UPDATE SET
value=value + excluded.value,
updated_at=excluded.updated_at
""",
(name, amount, now),
)
def set_metric(self, name: str, value: float) -> None:
now = datetime.now(UTC).isoformat()
with self.connect() as conn:
conn.execute(
"""
INSERT INTO service_metrics(name, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(name) DO UPDATE SET
value=excluded.value,
updated_at=excluded.updated_at
""",
(name, value, now),
)
def get_service_metrics(self) -> dict[str, float]:
with self.connect() as conn:
rows = conn.execute("SELECT name, value FROM service_metrics").fetchall()
return {str(row["name"]): float(row["value"]) for row in rows}
def record_update_metrics(
self,
latency_ms: float,
*,
is_success: bool = False,
is_dns_failure: bool = False,
is_reconcile_failure: bool = False,
is_bad_ip: bool = False,
) -> None:
increments = {
"update_requests_total": 1,
"update_latency_ms_total": latency_ms,
"update_latency_samples_total": 1,
}
if is_success:
increments["update_success_total"] = 1
if is_dns_failure:
increments["dns_failures_total"] = 1
if is_reconcile_failure:
increments["dns_reconcile_failures_total"] = 1
if is_bad_ip:
increments["bad_ip_total"] = 1
now = datetime.now(UTC).isoformat()
with self.connect() as conn:
conn.executemany(
"""
INSERT INTO service_metrics(name, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(name) DO UPDATE SET
value=value + excluded.value,
updated_at=excluded.updated_at
""",
[(name, value, now) for name, value in increments.items()],
)
conn.execute(
"""
INSERT INTO service_metrics(name, value, updated_at)
VALUES ('last_update_latency_ms', ?, ?)
ON CONFLICT(name) DO UPDATE SET
value=excluded.value,
updated_at=excluded.updated_at
""",
(latency_ms, now),
)
-2
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
import sqlite3
import time
from datetime import UTC, datetime, timedelta
@@ -12,7 +11,6 @@ RATE_LIMIT_FLUSH_SECONDS = 10.0
class RateLimitCleanupMixin:
def list_cleanup_runs(self, limit: int = 20, offset: int = 0) -> list[CleanupRunRow]:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT created_at, domain_challenges_deleted, unused_accounts_deleted
+31 -18
View File
@@ -18,7 +18,6 @@ class RecordsEventsMixin:
) -> UpdateResult:
now = datetime.now(UTC).isoformat()
with self.connect() as conn:
conn.row_factory = sqlite3.Row
existing = conn.execute("SELECT ipv4, ipv6 FROM records WHERE hostname = ?", (hostname,)).fetchone()
current_ipv4 = existing["ipv4"] if existing else None
current_ipv6 = existing["ipv6"] if existing else None
@@ -70,7 +69,6 @@ class RecordsEventsMixin:
update_ipv6: bool = True,
) -> UpdateResult:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
existing = conn.execute("SELECT ipv4, ipv6 FROM records WHERE hostname = ?", (hostname,)).fetchone()
current_ipv4 = existing["ipv4"] if existing else None
current_ipv6 = existing["ipv6"] if existing else None
@@ -91,7 +89,6 @@ class RecordsEventsMixin:
def list_records_page(self, limit: int = 100, offset: int = 0) -> list[dict[str, str | None]]:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT hostname, ipv4, ipv6, updated_at
@@ -154,28 +151,45 @@ class RecordsEventsMixin:
return self.list_update_events_page(limit=limit, offset=offset)
def list_update_events_page(self, limit: int = 100, offset: int = 0) -> list[UpdateEventRow]:
source_limit = limit + offset
with self.connect() as conn:
conn.row_factory = sqlite3.Row
event_rows = conn.execute(
rows = conn.execute(
"""
SELECT hostname, ipv4, ipv6, status, detail, source_ip, created_at, 1 AS count
FROM update_events
SELECT hostname, ipv4, ipv6, status, detail, source_ip, created_at, count
FROM (
SELECT hostname, ipv4, ipv6, status, detail, source_ip, created_at, 1 AS count
FROM update_events
UNION ALL
SELECT hostname, ipv4, ipv6, status, detail, source_ip, last_seen_at AS created_at, count
FROM update_event_rollups
)
ORDER BY created_at DESC
LIMIT ? OFFSET ?
""",
(source_limit, 0),
(limit, offset),
).fetchall()
rollup_rows = conn.execute(
"""
SELECT hostname, ipv4, ipv6, status, detail, source_ip, last_seen_at AS created_at, count
FROM update_event_rollups
ORDER BY last_seen_at DESC
LIMIT ? OFFSET ?
return [dict(row) for row in rows]
def list_update_events_before(self, limit: int = 100, before: str | None = None) -> list[UpdateEventRow]:
where_clause = "WHERE created_at < ?" if before else ""
params: tuple[object, ...] = (before, limit) if before else (limit,)
with self.connect() as conn:
rows = conn.execute(
f"""
SELECT hostname, ipv4, ipv6, status, detail, source_ip, created_at, count
FROM (
SELECT hostname, ipv4, ipv6, status, detail, source_ip, created_at, 1 AS count
FROM update_events
UNION ALL
SELECT hostname, ipv4, ipv6, status, detail, source_ip, last_seen_at AS created_at, count
FROM update_event_rollups
)
{where_clause}
ORDER BY created_at DESC
LIMIT ?
""",
(source_limit, 0),
params,
).fetchall()
return _merge_event_rows(event_rows, rollup_rows, limit, offset)
return [dict(row) for row in rows]
def count_update_events(self) -> int:
with self.connect() as conn:
@@ -185,7 +199,6 @@ class RecordsEventsMixin:
def list_update_events_for_hostname(self, hostname: str, limit: int = 25) -> list[UpdateEventRow]:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
event_rows = conn.execute(
"""
SELECT hostname, ipv4, ipv6, status, detail, source_ip, created_at, 1 AS count
-2
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
import sqlite3
from datetime import UTC, datetime, timedelta
from .types import ScheduledTaskRow
@@ -87,7 +86,6 @@ class SchedulerMixin:
def list_scheduled_tasks(self) -> list[ScheduledTaskRow]:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT name, interval_seconds, next_run_at, lease_owner, lease_expires_at,
+10
View File
@@ -17,6 +17,7 @@ def connect_database(database_path: Path) -> sqlite3.Connection:
if str(database_path) != ":memory:":
database_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(database_path, check_same_thread=False)
conn.row_factory = sqlite3.Row
configure_connection(conn)
return conn
@@ -129,6 +130,15 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_rate_limits_window ON rate_limits(window)")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS service_metrics (
name TEXT PRIMARY KEY,
value REAL NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS accounts (