Archived
Build KarlDNS self-hosted DynDNS service
This commit is contained in:
1 parent
45bc38d950
commit
ca92dc211a
65 files changed
+9200
-3570
No files matched your search
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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])
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
Reference in new issue
Block a user