Simplify DDNS flow and polish UI

This commit is contained in:
StasonJatham committed 2026-05-21 14:18:39 +02:00
1 parent 5beee0e5c6
commit 5a90bd9d87
42 files changed
+587 -1418

No files matched your search

+1 -2
View File
@@ -7,7 +7,6 @@ from sqlite3 import Connection
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
@@ -15,7 +14,7 @@ from .scheduler import SchedulerMixin
from .schema import connect_database, initialize_schema
class DdnsStore(AccountsMixin, DomainsMixin, MetricsMixin, RecordsEventsMixin, RateLimitCleanupMixin, SchedulerMixin):
class DdnsStore(AccountsMixin, MetricsMixin, RecordsEventsMixin, RateLimitCleanupMixin, SchedulerMixin):
def __init__(self, database_path: Path) -> None:
self.database_path = database_path
self._connections = threading.local()
-135
View File
@@ -1,135 +0,0 @@
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:
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:
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:
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:
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])
+4 -11
View File
@@ -13,7 +13,7 @@ class RateLimitCleanupMixin:
with self.connect() as conn:
rows = conn.execute(
"""
SELECT created_at, domain_challenges_deleted, unused_accounts_deleted
SELECT created_at, unused_accounts_deleted
FROM cleanup_runs
ORDER BY created_at DESC
LIMIT ? OFFSET ?
@@ -116,19 +116,13 @@ class RateLimitCleanupMixin:
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
@@ -151,13 +145,12 @@ class RateLimitCleanupMixin:
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 (?, ?, ?)
INSERT INTO cleanup_runs(created_at, unused_accounts_deleted)
VALUES (?, ?)
""",
(datetime.now(UTC).isoformat(), challenge_cursor.rowcount, account_cursor.rowcount),
(datetime.now(UTC).isoformat(), account_cursor.rowcount),
)
return {
"domain_challenges": challenge_cursor.rowcount,
"unused_accounts": account_cursor.rowcount,
"event_rollups": rollup_cursor.rowcount,
"update_events": event_cursor.rowcount,
+26 -26
View File
@@ -90,11 +90,11 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
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
)
"""
)
_migrate_cleanup_runs_schema(conn)
conn.execute("CREATE INDEX IF NOT EXISTS idx_cleanup_runs_created_at ON cleanup_runs(created_at)")
conn.execute(
"""
@@ -167,31 +167,6 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
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")
@@ -201,3 +176,28 @@ def _ensure_column(conn: sqlite3.Connection, table: str, column: str, definition
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}")
def _migrate_cleanup_runs_schema(conn: sqlite3.Connection) -> None:
columns = {row[1] for row in conn.execute("PRAGMA table_info(cleanup_runs)")}
if columns <= {"id", "created_at", "unused_accounts_deleted"}:
return
conn.execute("DROP INDEX IF EXISTS idx_cleanup_runs_created_at")
conn.execute(
"""
CREATE TABLE cleanup_runs_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
unused_accounts_deleted INTEGER NOT NULL
)
"""
)
conn.execute(
"""
INSERT INTO cleanup_runs_new(id, created_at, unused_accounts_deleted)
SELECT id, created_at, unused_accounts_deleted
FROM cleanup_runs
"""
)
conn.execute("DROP TABLE cleanup_runs")
conn.execute("ALTER TABLE cleanup_runs_new RENAME TO cleanup_runs")
-10
View File
@@ -18,15 +18,6 @@ class AccountRow(TypedDict, total=False):
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
@@ -40,7 +31,6 @@ class UpdateEventRow(TypedDict, total=False):
class CleanupRunRow(TypedDict):
created_at: str
domain_challenges_deleted: int
unused_accounts_deleted: int