Archived
Polish KarlDNS production UI and docs
This commit is contained in:
1 parent
ca92dc211a
commit
5beee0e5c6
61 files changed
+1566
-523
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in new issue
Block a user