Build KarlDNS self-hosted DynDNS service

This commit is contained in:
StasonJatham committed 2026-05-20 23:55:27 +02:00
1 parent 45bc38d950
commit ca92dc211a
65 files changed
+9200 -3570

No files matched your search

+80
View File
@@ -0,0 +1,80 @@
from __future__ import annotations
import threading
from contextlib import contextmanager
from pathlib import Path
from sqlite3 import Connection
from time import monotonic
from .accounts import AccountsMixin
from .domains import DomainsMixin
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):
def __init__(self, database_path: Path) -> None:
self.database_path = database_path
self._connections = threading.local()
self._connection_lock = threading.Lock()
self._tracked_connections: list[Connection] = []
self._closed_connection_ids: set[int] = set()
self._rate_limit_prune_lock = threading.Lock()
self._rate_limit_lock = threading.Lock()
self._rate_limit_buckets: dict[str, dict[str, float | int]] = {}
self._rate_limit_dirty_counts: dict[tuple[str, int], int] = {}
self._last_rate_limit_flush = monotonic()
self._last_rate_limit_prune_window = -1
self.database_path.parent.mkdir(parents=True, exist_ok=True)
self._init_db()
@contextmanager
def connect(self):
conn = self._thread_connection()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
def _init_db(self) -> None:
with self.connect() as conn:
initialize_schema(conn)
def optimize(self) -> None:
with self.connect() as conn:
conn.execute("PRAGMA optimize")
def checkpoint_wal(self) -> None:
if str(self.database_path) == ":memory:":
return
with self.connect() as conn:
conn.execute("PRAGMA wal_checkpoint(PASSIVE)")
def _thread_connection(self) -> Connection:
conn = getattr(self._connections, "conn", None)
if conn is None or id(conn) in self._closed_connection_ids:
conn = connect_database(self.database_path)
self._connections.conn = conn
with self._connection_lock:
self._tracked_connections.append(conn)
self._closed_connection_ids.discard(id(conn))
return conn
def close_connections(self) -> None:
with self._connection_lock:
connections = list(self._tracked_connections)
self._tracked_connections.clear()
self._closed_connection_ids.update(id(conn) for conn in connections)
for conn in connections:
conn.close()
if getattr(self._connections, "conn", None) in connections:
self._connections.conn = None
close = close_connections
__all__ = ["DdnsStore"]
+180
View File
@@ -0,0 +1,180 @@
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
from .types import AccountRow
class AccountsMixin:
def create_account(self, hostname: str, username: str | None = None) -> AccountRow:
now = datetime.now(UTC).isoformat()
token = secrets.token_urlsafe(24)
update_slug = secrets.token_urlsafe(32)
management_slug = secrets.token_urlsafe(32)
username = username or _default_username(hostname)
token_hash = hash_secret(token)
management_hash = hash_lookup_token(management_slug)
with self.connect() as conn:
conn.execute(
"""
INSERT INTO accounts(hostname, username, token_hash, update_slug, management_hash, created_at, disabled)
VALUES (?, ?, ?, ?, ?, ?, 0)
""",
(hostname, username, token_hash, update_slug, management_hash, now),
)
return {
"hostname": hostname,
"username": username,
"password": token,
"update_slug": update_slug,
"management_slug": management_slug,
}
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,
accounts.created_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
FROM accounts
LEFT JOIN records ON records.hostname = accounts.hostname
ORDER BY accounts.hostname
LIMIT ? OFFSET ?
""",
(limit, offset),
).fetchall()
return [dict(row) for row in rows]
def count_accounts(self) -> int:
with self.connect() as conn:
row = conn.execute("SELECT COUNT(*) FROM accounts").fetchone()
return int(row[0])
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,
accounts.created_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
FROM accounts
LEFT JOIN records ON records.hostname = accounts.hostname
WHERE accounts.hostname = ?
""",
(hostname,),
).fetchone()
return dict(account) if account else None
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
WHERE update_slug = ?
""",
(update_slug,),
).fetchone()
return dict(account) if account else None
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,
accounts.created_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
FROM accounts
LEFT JOIN records ON records.hostname = accounts.hostname
WHERE accounts.management_hash = ?
""",
(hash_lookup_token(management_slug),),
).fetchone()
return dict(account) if account else None
def verify_account(self, hostname: str, username: str | None, token: str | None) -> bool:
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
WHERE hostname = ? AND username = ?
""",
(hostname, username),
).fetchone()
if not account or account["disabled"]:
return False
return verify_secret(token, account["token_hash"])
def delete_account(self, hostname: str) -> None:
with self.connect() as conn:
conn.execute("DELETE FROM accounts WHERE hostname = ?", (hostname,))
conn.execute("DELETE FROM records WHERE hostname = ?", (hostname,))
def rotate_update_slug(self, hostname: str) -> AccountRow | None:
update_slug = secrets.token_urlsafe(32)
with self.connect() as conn:
conn.execute("UPDATE accounts SET update_slug = ? WHERE hostname = ?", (update_slug, hostname))
return self.get_account_by_hostname(hostname)
def rotate_management_slug(self, hostname: str) -> AccountRow | None:
management_slug = secrets.token_urlsafe(32)
management_hash = hash_lookup_token(management_slug)
with self.connect() as conn:
conn.execute("UPDATE accounts SET management_hash = ? WHERE hostname = ?", (management_hash, hostname))
account = self.get_account_by_hostname(hostname)
if account:
account["management_slug"] = management_slug
return account
def rotate_password(self, hostname: str) -> AccountRow | None:
token = secrets.token_urlsafe(24)
token_hash = hash_secret(token)
with self.connect() as conn:
conn.execute("UPDATE accounts SET token_hash = ? WHERE hostname = ?", (token_hash, hostname))
account = self.get_account_by_hostname(hostname)
if account:
account["password"] = token
return account
def set_dashboard_password(self, hostname: str, password: str) -> AccountRow | None:
password_hash = hash_secret(password)
with self.connect() as conn:
conn.execute(
"UPDATE accounts SET dashboard_password_hash = ? WHERE hostname = ?",
(password_hash, hostname),
)
return self.get_account_by_hostname(hostname)
def clear_dashboard_password(self, hostname: str) -> AccountRow | None:
with self.connect() as conn:
conn.execute("UPDATE accounts SET dashboard_password_hash = NULL WHERE hostname = ?", (hostname,))
return self.get_account_by_hostname(hostname)
def verify_dashboard_password(self, hostname: str, password: str | None) -> bool:
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
WHERE hostname = ?
""",
(hostname,),
).fetchone()
if not account or account["disabled"] or not account["dashboard_password_hash"]:
return False
return verify_secret(password, account["dashboard_password_hash"])
def _default_username(hostname: str) -> str:
label = hostname.split(".", 1)[0]
return re.sub(r"[^a-z0-9_.-]", "-", label.lower()) or "fritzbox"
+139
View File
@@ -0,0 +1,139 @@
from __future__ import annotations
import secrets
import sqlite3
from datetime import UTC, datetime
from ..ddns_security import hash_lookup_token
from .types import DomainChallengeRow
class DomainsMixin:
def create_domain_challenge(self, domain: str, owner_hostname: str | None = None) -> DomainChallengeRow:
token = f"ff-ddns-{secrets.token_urlsafe(24)}"
claim_secret = secrets.token_urlsafe(32)
claim_hash = hash_lookup_token(claim_secret)
now = datetime.now(UTC).isoformat()
with self.connect() as conn:
existing = conn.execute(
"SELECT verified_at, owner_hostname FROM domain_challenges WHERE domain = ?",
(domain,),
).fetchone()
if existing and existing[0]:
raise sqlite3.IntegrityError("domain is already verified")
if existing and existing[1] and existing[1] != owner_hostname:
raise sqlite3.IntegrityError("domain claim belongs to another dashboard")
conn.execute(
"""
INSERT INTO domain_challenges(domain, token, claim_hash, owner_hostname, created_at, verified_at)
VALUES (?, ?, ?, ?, ?, NULL)
ON CONFLICT(domain) DO UPDATE SET
token=excluded.token,
claim_hash=excluded.claim_hash,
owner_hostname=excluded.owner_hostname,
created_at=excluded.created_at,
verified_at=NULL
""",
(domain, token, claim_hash, owner_hostname, now),
)
return {"domain": domain, "token": token, "claim_secret": claim_secret}
def verify_domain_challenge(
self,
domain: str,
claim_secret: str,
owner_hostname: str | None = None,
) -> DomainChallengeRow | None:
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:
sql += " AND owner_hostname = ?"
params.append(owner_hostname)
challenge = conn.execute(sql, tuple(params)).fetchone()
if not challenge:
return None
update_sql = "UPDATE domain_challenges SET verified_at = ? WHERE domain = ? AND claim_hash = ?"
update_params = [now, domain, claim_hash]
if owner_hostname is not None:
update_sql += " AND owner_hostname = ?"
update_params.append(owner_hostname)
cursor = conn.execute(update_sql, tuple(update_params))
if cursor.rowcount != 1:
return None
result = dict(challenge)
result["verified_at"] = now
result["claim_secret"] = claim_secret
return result
def get_domain_challenge(
self,
domain: str,
claim_secret: str | None = None,
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:
sql += " AND claim_hash = ?"
params.append(hash_lookup_token(claim_secret))
if owner_hostname is not None:
sql += " AND owner_hostname = ?"
params.append(owner_hostname)
challenge = conn.execute(sql, tuple(params)).fetchone()
return dict(challenge) if challenge else None
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),),
).fetchone()
if not challenge:
return None
result = dict(challenge)
result["claim_secret"] = claim_secret
return result
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
FROM domain_challenges
ORDER BY created_at DESC
LIMIT ? OFFSET ?
""",
(limit, offset),
).fetchall()
return [dict(row) for row in rows]
def count_domain_challenges(self) -> int:
with self.connect() as conn:
row = conn.execute("SELECT COUNT(*) FROM domain_challenges").fetchone()
return int(row[0])
def is_domain_verified(
self,
domain: str,
claim_secret: str | None = None,
owner_hostname: str | None = None,
) -> bool:
with self.connect() as conn:
sql = "SELECT verified_at FROM domain_challenges WHERE domain = ?"
params = [domain]
if claim_secret is not None:
sql += " AND claim_hash = ?"
params.append(hash_lookup_token(claim_secret))
if owner_hostname is not None:
sql += " AND owner_hostname = ?"
params.append(owner_hostname)
verified_at = conn.execute(sql, tuple(params)).fetchone()
return bool(verified_at and verified_at[0])
+166
View File
@@ -0,0 +1,166 @@
from __future__ import annotations
import sqlite3
import time
from datetime import UTC, datetime, timedelta
from .types import CleanupRunRow
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
FROM cleanup_runs
ORDER BY created_at DESC
LIMIT ? OFFSET ?
""",
(limit, offset),
).fetchall()
return [CleanupRunRow(**dict(row)) for row in rows]
def count_cleanup_runs(self) -> int:
with self.connect() as conn:
row = conn.execute("SELECT COUNT(*) FROM cleanup_runs").fetchone()
return int(row[0])
def allow_rate_limit(self, key: str, limit: int) -> bool:
if limit <= 0:
return False
now = time.time()
window = int(now) // 60
refill_rate = limit / 60.0
with self._rate_limit_lock:
bucket = self._rate_limit_buckets.get(key)
if bucket is None:
bucket = {"tokens": float(limit), "updated_at": now, "window": window}
else:
elapsed_seconds = max(0.0, now - float(bucket["updated_at"]))
bucket["tokens"] = min(float(limit), float(bucket["tokens"]) + elapsed_seconds * refill_rate)
bucket["updated_at"] = now
bucket["window"] = window
if float(bucket["tokens"]) < 1.0:
self._rate_limit_buckets[key] = bucket
return False
bucket["tokens"] = float(bucket["tokens"]) - 1.0
self._rate_limit_buckets[key] = bucket
dirty_key = (key, window)
self._rate_limit_dirty_counts[dirty_key] = self._rate_limit_dirty_counts.get(dirty_key, 0) + 1
self._prune_memory_rate_limits(now)
self._flush_rate_limits_if_due()
return True
def flush_rate_limits(self) -> None:
with self._rate_limit_lock:
dirty_counts = self._rate_limit_dirty_counts
self._rate_limit_dirty_counts = {}
self._last_rate_limit_flush = time.monotonic()
if not dirty_counts:
return
try:
with self.connect() as conn:
conn.executemany(
"""
INSERT INTO rate_limits(key, window, count)
VALUES (?, ?, ?)
ON CONFLICT(key, window) DO UPDATE SET count=count + excluded.count
""",
[(key, window, count) for (key, window), count in dirty_counts.items()],
)
except Exception:
with self._rate_limit_lock:
for dirty_key, count in dirty_counts.items():
self._rate_limit_dirty_counts[dirty_key] = self._rate_limit_dirty_counts.get(dirty_key, 0) + count
raise
def _flush_rate_limits_if_due(self) -> None:
if time.monotonic() - self._last_rate_limit_flush < RATE_LIMIT_FLUSH_SECONDS:
return
self.flush_rate_limits()
def _prune_memory_rate_limits(self, now: float) -> None:
stale_before = now - 120
stale_keys = [
key
for key, bucket in self._rate_limit_buckets.items()
if float(bucket["updated_at"]) < stale_before and float(bucket["tokens"]) >= 1.0
]
for key in stale_keys:
del self._rate_limit_buckets[key]
def prune_rate_limit_storage(self) -> None:
current_window = int(time.time()) // 60
should_prune = self._should_prune_rate_limits(current_window)
if not should_prune:
return
with self.connect() as conn:
conn.execute("DELETE FROM rate_limits WHERE window < ?", (current_window - 2,))
def _should_prune_rate_limits(self, window: int) -> bool:
if self._last_rate_limit_prune_window == window:
return False
with self._rate_limit_prune_lock:
if self._last_rate_limit_prune_window == window:
return False
self._last_rate_limit_prune_window = window
return True
def cleanup(
self,
challenge_hours: int,
unused_account_hours: int,
event_history_hours: int = 24 * 180,
) -> dict[str, int]:
self.flush_rate_limits()
challenge_cutoff = (datetime.now(UTC) - timedelta(hours=challenge_hours)).isoformat()
unused_account_cutoff = (datetime.now(UTC) - timedelta(hours=unused_account_hours)).isoformat()
event_history_cutoff = (datetime.now(UTC) - timedelta(hours=event_history_hours)).isoformat()
with self.connect() as conn:
challenge_cursor = conn.execute(
"DELETE FROM domain_challenges WHERE verified_at IS NULL AND created_at < ?",
(challenge_cutoff,),
)
account_cursor = conn.execute(
"""
DELETE FROM accounts
WHERE created_at < ?
AND NOT EXISTS (
SELECT 1 FROM records WHERE records.hostname = accounts.hostname
)
""",
(unused_account_cutoff,),
)
rollup_cursor = conn.execute(
"DELETE FROM update_event_rollups WHERE last_seen_at < ?",
(unused_account_cutoff,),
)
event_cursor = conn.execute(
"DELETE FROM update_events WHERE created_at < ?",
(event_history_cutoff,),
)
current_window = int(time.time()) // 60
conn.execute("DELETE FROM rate_limits WHERE window < ?", (current_window - 60,))
conn.execute(
"""
INSERT INTO cleanup_runs(created_at, domain_challenges_deleted, unused_accounts_deleted)
VALUES (?, ?, ?)
""",
(datetime.now(UTC).isoformat(), challenge_cursor.rowcount, account_cursor.rowcount),
)
return {
"domain_challenges": challenge_cursor.rowcount,
"unused_accounts": account_cursor.rowcount,
"event_rollups": rollup_cursor.rowcount,
"update_events": event_cursor.rowcount,
}
+221
View File
@@ -0,0 +1,221 @@
from __future__ import annotations
import sqlite3
from datetime import UTC, datetime
from ..ddns_models import UpdateResult
from .types import UpdateEventRow
class RecordsEventsMixin:
def upsert(
self,
hostname: str,
ipv4: str | None,
ipv6: str | None,
update_ipv4: bool = True,
update_ipv6: bool = True,
) -> 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
effective_ipv4 = ipv4 if update_ipv4 else current_ipv4
effective_ipv6 = ipv6 if update_ipv6 else current_ipv6
changed = existing is None or current_ipv4 != effective_ipv4 or current_ipv6 != effective_ipv6
conn.execute(
"""
INSERT INTO records(hostname, ipv4, ipv6, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(hostname) DO UPDATE SET
ipv4=excluded.ipv4,
ipv6=excluded.ipv6,
updated_at=excluded.updated_at
""",
(hostname, effective_ipv4, effective_ipv6, now),
)
return UpdateResult(
hostname=hostname,
ipv4=effective_ipv4,
ipv6=effective_ipv6,
changed=changed,
update_ipv4=update_ipv4,
update_ipv6=update_ipv6,
)
def commit_update_result(self, result: UpdateResult) -> UpdateResult:
now = datetime.now(UTC).isoformat()
with self.connect() as conn:
conn.execute(
"""
INSERT INTO records(hostname, ipv4, ipv6, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(hostname) DO UPDATE SET
ipv4=excluded.ipv4,
ipv6=excluded.ipv6,
updated_at=excluded.updated_at
""",
(result.hostname, result.ipv4, result.ipv6, now),
)
return result
def preview_update(
self,
hostname: str,
ipv4: str | None,
ipv6: str | None,
update_ipv4: bool = True,
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
effective_ipv4 = ipv4 if update_ipv4 else current_ipv4
effective_ipv6 = ipv6 if update_ipv6 else current_ipv6
changed = existing is None or current_ipv4 != effective_ipv4 or current_ipv6 != effective_ipv6
return UpdateResult(
hostname=hostname,
ipv4=effective_ipv4,
ipv6=effective_ipv6,
changed=changed,
update_ipv4=update_ipv4,
update_ipv6=update_ipv6,
)
def list_records(self) -> list[dict[str, str | None]]:
return self.list_records_page(limit=10_000, offset=0)
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
FROM records
ORDER BY hostname
LIMIT ? OFFSET ?
""",
(limit, offset),
).fetchall()
return [dict(row) for row in rows]
def count_records(self) -> int:
with self.connect() as conn:
row = conn.execute("SELECT COUNT(*) FROM records").fetchone()
return int(row[0])
def log_update_event(
self,
hostname: str,
ipv4: str | None,
ipv6: str | None,
status: str,
detail: str,
source_ip: str,
) -> None:
with self.connect() as conn:
conn.execute(
"""
INSERT INTO update_events(hostname, ipv4, ipv6, status, detail, source_ip, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(hostname, ipv4, ipv6, status, detail[:500], source_ip, datetime.now(UTC).isoformat()),
)
def log_update_rollup(
self,
hostname: str,
ipv4: str | None,
ipv6: str | None,
status: str,
detail: str,
source_ip: str,
) -> None:
now = datetime.now(UTC).isoformat()
with self.connect() as conn:
conn.execute(
"""
INSERT INTO update_event_rollups(
hostname, ipv4, ipv6, status, detail, source_ip, first_seen_at, last_seen_at, count
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)
ON CONFLICT(hostname, ipv4, ipv6, status, detail, source_ip) DO UPDATE SET
last_seen_at=excluded.last_seen_at,
count=count + 1
""",
(hostname, ipv4 or "", ipv6 or "", status, detail[:500], source_ip, now, now),
)
def list_update_events(self, limit: int = 100, offset: int = 0) -> list[UpdateEventRow]:
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(
"""
SELECT hostname, ipv4, ipv6, status, detail, source_ip, created_at, 1 AS count
FROM update_events
ORDER BY created_at DESC
LIMIT ? OFFSET ?
""",
(source_limit, 0),
).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 ?
""",
(source_limit, 0),
).fetchall()
return _merge_event_rows(event_rows, rollup_rows, limit, offset)
def count_update_events(self) -> int:
with self.connect() as conn:
event_count = conn.execute("SELECT COUNT(*) FROM update_events").fetchone()[0]
rollup_count = conn.execute("SELECT COUNT(*) FROM update_event_rollups").fetchone()[0]
return int(event_count) + int(rollup_count)
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
FROM update_events
WHERE hostname = ?
ORDER BY created_at DESC
LIMIT ?
""",
(hostname, limit),
).fetchall()
rollup_rows = conn.execute(
"""
SELECT hostname, ipv4, ipv6, status, detail, source_ip, last_seen_at AS created_at, count
FROM update_event_rollups
WHERE hostname = ?
ORDER BY last_seen_at DESC
LIMIT ?
""",
(hostname, limit),
).fetchall()
return _merge_event_rows(event_rows, rollup_rows, limit, 0)
def _merge_event_rows(
event_rows: list[sqlite3.Row],
rollup_rows: list[sqlite3.Row],
limit: int,
offset: int,
) -> list[UpdateEventRow]:
rows = [dict(row) for row in event_rows]
rows.extend(dict(row) for row in rollup_rows)
rows.sort(key=lambda row: str(row["created_at"]), reverse=True)
return rows[offset : offset + limit]
+99
View File
@@ -0,0 +1,99 @@
from __future__ import annotations
import sqlite3
from datetime import UTC, datetime, timedelta
from .types import ScheduledTaskRow
def _now() -> datetime:
return datetime.now(UTC)
def _iso(value: datetime) -> str:
return value.isoformat()
class SchedulerMixin:
def ensure_scheduled_task(self, name: str, interval_seconds: int) -> None:
now = _now()
with self.connect() as conn:
conn.execute(
"""
INSERT INTO scheduled_tasks(name, interval_seconds, next_run_at, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(name) DO UPDATE SET
interval_seconds=excluded.interval_seconds,
updated_at=excluded.updated_at
""",
(name, interval_seconds, _iso(now), _iso(now)),
)
def acquire_due_task(self, name: str, lease_owner: str, lease_seconds: int) -> bool:
now = _now()
lease_expires_at = now + timedelta(seconds=lease_seconds)
with self.connect() as conn:
cursor = conn.execute(
"""
UPDATE scheduled_tasks
SET lease_owner = ?,
lease_expires_at = ?,
last_started_at = ?,
last_status = 'running',
last_error = '',
updated_at = ?
WHERE name = ?
AND next_run_at <= ?
AND (lease_expires_at IS NULL OR lease_expires_at <= ?)
""",
(
lease_owner,
_iso(lease_expires_at),
_iso(now),
_iso(now),
name,
_iso(now),
_iso(now),
),
)
return cursor.rowcount == 1
def complete_scheduled_task(
self,
name: str,
interval_seconds: int,
success: bool,
error: str = "",
) -> None:
now = _now()
next_run_at = now + timedelta(seconds=interval_seconds)
status = "ok" if success else "error"
with self.connect() as conn:
conn.execute(
"""
UPDATE scheduled_tasks
SET next_run_at = ?,
lease_owner = NULL,
lease_expires_at = NULL,
last_finished_at = ?,
last_status = ?,
last_error = ?,
interval_seconds = ?,
updated_at = ?
WHERE name = ?
""",
(_iso(next_run_at), _iso(now), status, error[:500], interval_seconds, _iso(now), name),
)
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,
last_started_at, last_finished_at, last_status, last_error, updated_at
FROM scheduled_tasks
ORDER BY name
"""
).fetchall()
return [ScheduledTaskRow(**dict(row)) for row in rows]
+193
View File
@@ -0,0 +1,193 @@
from __future__ import annotations
import secrets
import sqlite3
from pathlib import Path
from ..ddns_security import hash_lookup_token
BUSY_TIMEOUT_MS = 10_000
CACHE_SIZE_KIB = 20_000
MMAP_SIZE_BYTES = 256 * 1024 * 1024
WAL_AUTOCHECKPOINT_PAGES = 1_000
SCHEMA_VERSION = 1
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)
configure_connection(conn)
return conn
def configure_connection(conn: sqlite3.Connection) -> None:
conn.execute("PRAGMA foreign_keys = ON")
conn.execute(f"PRAGMA busy_timeout = {BUSY_TIMEOUT_MS}")
conn.execute("PRAGMA temp_store = MEMORY")
conn.execute(f"PRAGMA cache_size = -{CACHE_SIZE_KIB}")
conn.execute(f"PRAGMA mmap_size = {MMAP_SIZE_BYTES}")
conn.execute("PRAGMA synchronous = NORMAL")
conn.execute(f"PRAGMA wal_autocheckpoint = {WAL_AUTOCHECKPOINT_PAGES}")
def initialize_schema(conn: sqlite3.Connection) -> None:
configure_connection(conn)
conn.execute("PRAGMA journal_mode = WAL")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS records (
hostname TEXT PRIMARY KEY,
ipv4 TEXT,
ipv6 TEXT,
updated_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS update_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hostname TEXT NOT NULL,
ipv4 TEXT,
ipv6 TEXT,
status TEXT NOT NULL,
detail TEXT NOT NULL,
source_ip TEXT NOT NULL,
created_at TEXT NOT NULL
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_update_events_created_at ON update_events(created_at)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_update_events_hostname_created_at ON update_events(hostname, created_at DESC)")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS update_event_rollups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hostname TEXT NOT NULL,
ipv4 TEXT,
ipv6 TEXT,
status TEXT NOT NULL,
detail TEXT NOT NULL,
source_ip TEXT NOT NULL,
first_seen_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
count INTEGER NOT NULL,
UNIQUE(hostname, ipv4, ipv6, status, detail, source_ip)
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_update_event_rollups_last_seen_at ON update_event_rollups(last_seen_at)")
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_update_event_rollups_hostname_last_seen_at
ON update_event_rollups(hostname, last_seen_at DESC)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS cleanup_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
domain_challenges_deleted INTEGER NOT NULL,
unused_accounts_deleted INTEGER NOT NULL
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_cleanup_runs_created_at ON cleanup_runs(created_at)")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS scheduled_tasks (
name TEXT PRIMARY KEY,
interval_seconds INTEGER NOT NULL,
next_run_at TEXT NOT NULL,
lease_owner TEXT,
lease_expires_at TEXT,
last_started_at TEXT,
last_finished_at TEXT,
last_status TEXT NOT NULL DEFAULT 'pending',
last_error TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_next_run_at ON scheduled_tasks(next_run_at)")
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_due
ON scheduled_tasks(next_run_at, lease_expires_at)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS rate_limits (
key TEXT NOT NULL,
window INTEGER NOT NULL,
count INTEGER NOT NULL,
PRIMARY KEY(key, window)
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_rate_limits_window ON rate_limits(window)")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS accounts (
hostname TEXT PRIMARY KEY,
username TEXT NOT NULL,
token_hash TEXT NOT NULL,
update_slug TEXT NOT NULL UNIQUE,
management_hash TEXT UNIQUE,
dashboard_password_hash TEXT,
created_at TEXT NOT NULL,
disabled INTEGER NOT NULL DEFAULT 0
)
"""
)
_ensure_column(conn, "accounts", "update_slug", "TEXT")
_ensure_column(conn, "accounts", "management_hash", "TEXT")
_ensure_column(conn, "accounts", "dashboard_password_hash", "TEXT")
for row in conn.execute("SELECT hostname FROM accounts WHERE update_slug IS NULL OR update_slug = ''"):
conn.execute("UPDATE accounts SET update_slug = ? WHERE hostname = ?", (secrets.token_urlsafe(32), row[0]))
for row in conn.execute("SELECT hostname FROM accounts WHERE management_hash IS NULL OR management_hash = ''"):
conn.execute(
"UPDATE accounts SET management_hash = ? WHERE hostname = ?",
(hash_lookup_token(secrets.token_urlsafe(32)), row[0]),
)
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_update_slug ON accounts(update_slug)")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_management_hash ON accounts(management_hash)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_accounts_created_at ON accounts(created_at)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_accounts_hostname_username ON accounts(hostname, username)")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS domain_challenges (
domain TEXT PRIMARY KEY,
token TEXT NOT NULL,
claim_hash TEXT,
owner_hostname TEXT,
created_at TEXT NOT NULL,
verified_at TEXT
)
"""
)
_ensure_column(conn, "domain_challenges", "claim_hash", "TEXT")
_ensure_column(conn, "domain_challenges", "owner_hostname", "TEXT")
conn.execute("CREATE INDEX IF NOT EXISTS idx_domain_challenges_owner_hostname ON domain_challenges(owner_hostname)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_domain_challenges_owner_verified ON domain_challenges(owner_hostname, verified_at)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_domain_challenges_claim_hash ON domain_challenges(claim_hash)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_domain_challenges_created_at ON domain_challenges(created_at)")
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_domain_challenges_unverified_created
ON domain_challenges(created_at)
WHERE verified_at IS NULL
"""
)
if conn.execute("PRAGMA user_version").fetchone()[0] < SCHEMA_VERSION:
conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
conn.execute("PRAGMA optimize")
def _ensure_column(conn: sqlite3.Connection, table: str, column: str, definition: str) -> None:
columns = {row[1] for row in conn.execute(f"PRAGMA table_info({table})")}
if column not in columns:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
from typing import TypedDict
class AccountRow(TypedDict, total=False):
hostname: str
username: str
token_hash: str
update_slug: str
management_slug: str
dashboard_password_hash: str | None
created_at: str
disabled: int
ipv4: str | None
ipv6: str | None
updated_at: str | None
password: str
class DomainChallengeRow(TypedDict, total=False):
domain: str
token: str
claim_secret: str
owner_hostname: str | None
created_at: str
verified_at: str | None
class UpdateEventRow(TypedDict, total=False):
hostname: str
ipv4: str | None
ipv6: str | None
status: str
detail: str
source_ip: str
created_at: str
count: int
class CleanupRunRow(TypedDict):
created_at: str
domain_challenges_deleted: int
unused_accounts_deleted: int
class ScheduledTaskRow(TypedDict, total=False):
name: str
interval_seconds: int
next_run_at: str
lease_owner: str | None
lease_expires_at: str | None
last_started_at: str | None
last_finished_at: str | None
last_status: str
last_error: str
updated_at: str