2026-05-20 13:48:24 +02:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import secrets
|
|
|
|
|
import sqlite3
|
|
|
|
|
import time
|
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
from .ddns_models import UpdateResult
|
|
|
|
|
from .ddns_security import hash_lookup_token, hash_secret, verify_secret
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class DdnsStore:
|
|
|
|
|
def __init__(self, database_path: Path) -> None:
|
|
|
|
|
self.database_path = database_path
|
|
|
|
|
self.database_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
self._init_db()
|
|
|
|
|
|
|
|
|
|
def connect(self) -> sqlite3.Connection:
|
|
|
|
|
conn = sqlite3.connect(self.database_path)
|
|
|
|
|
conn.execute("PRAGMA foreign_keys = ON")
|
|
|
|
|
conn.execute("PRAGMA busy_timeout = 5000")
|
|
|
|
|
return conn
|
|
|
|
|
|
|
|
|
|
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 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,
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-20 17:57:42 +02:00
|
|
|
def create_account(self, hostname: str, username: str | None = None) -> dict[str, str]:
|
2026-05-20 13:48:24 +02:00
|
|
|
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(
|
|
|
|
|
"""
|
2026-05-20 17:57:42 +02:00
|
|
|
INSERT INTO accounts(hostname, username, token_hash, update_slug, management_hash, created_at, disabled)
|
|
|
|
|
VALUES (?, ?, ?, ?, ?, ?, 0)
|
2026-05-20 13:48:24 +02:00
|
|
|
""",
|
2026-05-20 17:57:42 +02:00
|
|
|
(hostname, username, token_hash, update_slug, management_hash, now),
|
2026-05-20 13:48:24 +02:00
|
|
|
)
|
|
|
|
|
return {
|
|
|
|
|
"hostname": hostname,
|
|
|
|
|
"username": username,
|
|
|
|
|
"password": token,
|
|
|
|
|
"update_slug": update_slug,
|
|
|
|
|
"management_slug": management_slug,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 17:57:42 +02:00
|
|
|
def list_accounts(self) -> list[dict[str, str | int | None]]:
|
2026-05-20 13:48:24 +02:00
|
|
|
with self.connect() as conn:
|
|
|
|
|
conn.row_factory = sqlite3.Row
|
2026-05-20 17:57:42 +02:00
|
|
|
rows = conn.execute(
|
|
|
|
|
"""
|
|
|
|
|
SELECT accounts.hostname, accounts.username, accounts.update_slug,
|
2026-05-20 13:48:24 +02:00
|
|
|
accounts.created_at, accounts.disabled, records.ipv4, records.ipv6, records.updated_at
|
|
|
|
|
FROM accounts
|
|
|
|
|
LEFT JOIN records ON records.hostname = accounts.hostname
|
2026-05-20 17:57:42 +02:00
|
|
|
ORDER BY accounts.hostname
|
2026-05-20 13:48:24 +02:00
|
|
|
"""
|
2026-05-20 17:57:42 +02:00
|
|
|
).fetchall()
|
2026-05-20 13:48:24 +02:00
|
|
|
return [dict(row) for row in rows]
|
|
|
|
|
|
2026-05-20 18:17:20 +02:00
|
|
|
def get_account_by_hostname(self, hostname: str) -> dict[str, str | int | None] | None:
|
|
|
|
|
with self.connect() as conn:
|
|
|
|
|
conn.row_factory = sqlite3.Row
|
|
|
|
|
account = 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
|
|
|
|
|
WHERE accounts.hostname = ?
|
|
|
|
|
""",
|
|
|
|
|
(hostname,),
|
|
|
|
|
).fetchone()
|
|
|
|
|
return dict(account) if account else None
|
|
|
|
|
|
2026-05-20 13:48:24 +02:00
|
|
|
def get_account_by_slug(self, update_slug: str) -> dict[str, str | int | None] | 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) -> dict[str, str | int | None] | None:
|
|
|
|
|
with self.connect() as conn:
|
|
|
|
|
conn.row_factory = sqlite3.Row
|
|
|
|
|
account = conn.execute(
|
|
|
|
|
"""
|
2026-05-20 17:57:42 +02:00
|
|
|
SELECT accounts.hostname, accounts.username, accounts.update_slug,
|
2026-05-20 13:48:24 +02:00
|
|
|
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,))
|
|
|
|
|
|
2026-05-20 18:17:20 +02:00
|
|
|
def rotate_update_slug(self, hostname: str) -> dict[str, str | int | None] | 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) -> dict[str, str | int | None] | 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) -> dict[str, str | int | None] | 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
|
|
|
|
|
|
2026-05-20 17:57:42 +02:00
|
|
|
def create_domain_challenge(self, domain: str) -> dict[str, str]:
|
2026-05-20 13:48:24 +02:00
|
|
|
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:
|
2026-05-20 17:57:42 +02:00
|
|
|
existing = conn.execute("SELECT verified_at FROM domain_challenges WHERE domain = ?", (domain,)).fetchone()
|
|
|
|
|
if existing and existing[0]:
|
2026-05-20 13:48:24 +02:00
|
|
|
raise sqlite3.IntegrityError("domain is already verified")
|
|
|
|
|
conn.execute(
|
|
|
|
|
"""
|
2026-05-20 17:57:42 +02:00
|
|
|
INSERT INTO domain_challenges(domain, token, claim_hash, created_at, verified_at)
|
|
|
|
|
VALUES (?, ?, ?, ?, NULL)
|
2026-05-20 13:48:24 +02:00
|
|
|
ON CONFLICT(domain) DO UPDATE SET
|
|
|
|
|
token=excluded.token,
|
|
|
|
|
claim_hash=excluded.claim_hash,
|
|
|
|
|
created_at=excluded.created_at,
|
|
|
|
|
verified_at=NULL
|
|
|
|
|
""",
|
2026-05-20 17:57:42 +02:00
|
|
|
(domain, token, claim_hash, now),
|
2026-05-20 13:48:24 +02:00
|
|
|
)
|
|
|
|
|
return {"domain": domain, "token": token, "claim_secret": claim_secret}
|
|
|
|
|
|
2026-05-20 17:57:42 +02:00
|
|
|
def verify_domain_challenge(self, domain: str) -> dict[str, str | None] | None:
|
2026-05-20 13:48:24 +02:00
|
|
|
now = datetime.now(UTC).isoformat()
|
|
|
|
|
with self.connect() as conn:
|
|
|
|
|
conn.row_factory = sqlite3.Row
|
2026-05-20 17:57:42 +02:00
|
|
|
challenge = conn.execute("SELECT domain, token, verified_at FROM domain_challenges WHERE domain = ?", (domain,)).fetchone()
|
2026-05-20 13:48:24 +02:00
|
|
|
if not challenge:
|
|
|
|
|
return None
|
2026-05-20 17:57:42 +02:00
|
|
|
conn.execute("UPDATE domain_challenges SET verified_at = ? WHERE domain = ?", (now, domain))
|
2026-05-20 13:48:24 +02:00
|
|
|
result = dict(challenge)
|
|
|
|
|
result["verified_at"] = now
|
|
|
|
|
return result
|
|
|
|
|
|
2026-05-20 17:57:42 +02:00
|
|
|
def get_domain_challenge(self, domain: str, claim_secret: str | None = None) -> dict[str, str | None] | None:
|
2026-05-20 13:48:24 +02:00
|
|
|
with self.connect() as conn:
|
|
|
|
|
conn.row_factory = sqlite3.Row
|
|
|
|
|
sql = "SELECT domain, token, verified_at FROM domain_challenges WHERE domain = ?"
|
2026-05-20 17:57:42 +02:00
|
|
|
params = (domain,)
|
|
|
|
|
if claim_secret is not None:
|
2026-05-20 13:48:24 +02:00
|
|
|
sql += " AND claim_hash = ?"
|
|
|
|
|
params = (domain, hash_lookup_token(claim_secret))
|
|
|
|
|
challenge = conn.execute(sql, params).fetchone()
|
|
|
|
|
return dict(challenge) if challenge else None
|
|
|
|
|
|
2026-05-20 17:46:10 +02:00
|
|
|
def get_domain_challenge_by_secret(self, claim_secret: str) -> dict[str, str | None] | None:
|
|
|
|
|
with self.connect() as conn:
|
|
|
|
|
conn.row_factory = sqlite3.Row
|
|
|
|
|
challenge = conn.execute(
|
|
|
|
|
"SELECT domain, token, 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
|
|
|
|
|
|
2026-05-20 18:17:20 +02:00
|
|
|
def list_domain_challenges(self, limit: int = 100) -> list[dict[str, str | None]]:
|
|
|
|
|
with self.connect() as conn:
|
|
|
|
|
conn.row_factory = sqlite3.Row
|
|
|
|
|
rows = conn.execute(
|
|
|
|
|
"""
|
|
|
|
|
SELECT domain, created_at, verified_at
|
|
|
|
|
FROM domain_challenges
|
|
|
|
|
ORDER BY created_at DESC
|
|
|
|
|
LIMIT ?
|
|
|
|
|
""",
|
|
|
|
|
(limit,),
|
|
|
|
|
).fetchall()
|
|
|
|
|
return [dict(row) for row in rows]
|
|
|
|
|
|
2026-05-20 17:57:42 +02:00
|
|
|
def is_domain_verified(self, domain: str, claim_secret: str | None = None) -> bool:
|
2026-05-20 13:48:24 +02:00
|
|
|
with self.connect() as conn:
|
|
|
|
|
sql = "SELECT verified_at FROM domain_challenges WHERE domain = ?"
|
2026-05-20 17:57:42 +02:00
|
|
|
params = (domain,)
|
|
|
|
|
if claim_secret is not None:
|
2026-05-20 13:48:24 +02:00
|
|
|
sql += " AND claim_hash = ?"
|
|
|
|
|
params = (domain, hash_lookup_token(claim_secret))
|
|
|
|
|
verified_at = conn.execute(sql, params).fetchone()
|
|
|
|
|
return bool(verified_at and verified_at[0])
|
|
|
|
|
|
|
|
|
|
def list_records(self) -> 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").fetchall()
|
|
|
|
|
return [dict(row) for row in rows]
|
|
|
|
|
|
|
|
|
|
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 list_update_events(self, limit: int = 100) -> list[dict[str, str | None]]:
|
|
|
|
|
with self.connect() as conn:
|
|
|
|
|
conn.row_factory = sqlite3.Row
|
|
|
|
|
rows = conn.execute(
|
|
|
|
|
"""
|
|
|
|
|
SELECT hostname, ipv4, ipv6, status, detail, source_ip, created_at
|
|
|
|
|
FROM update_events
|
|
|
|
|
ORDER BY created_at DESC
|
|
|
|
|
LIMIT ?
|
|
|
|
|
""",
|
|
|
|
|
(limit,),
|
|
|
|
|
).fetchall()
|
|
|
|
|
return [dict(row) for row in rows]
|
|
|
|
|
|
2026-05-20 18:17:20 +02:00
|
|
|
def list_cleanup_runs(self, limit: int = 20) -> list[dict[str, str | int]]:
|
|
|
|
|
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 ?
|
|
|
|
|
""",
|
|
|
|
|
(limit,),
|
|
|
|
|
).fetchall()
|
|
|
|
|
return [dict(row) for row in rows]
|
|
|
|
|
|
2026-05-20 13:48:24 +02:00
|
|
|
def allow_rate_limit(self, key: str, limit: int) -> bool:
|
|
|
|
|
now = int(time.time())
|
|
|
|
|
window = now // 60
|
|
|
|
|
with self.connect() as conn:
|
|
|
|
|
conn.execute("DELETE FROM rate_limits WHERE window < ?", (window - 2,))
|
|
|
|
|
row = conn.execute(
|
|
|
|
|
"SELECT count FROM rate_limits WHERE key = ? AND window = ?",
|
|
|
|
|
(key, window),
|
|
|
|
|
).fetchone()
|
|
|
|
|
count = int(row[0]) if row else 0
|
|
|
|
|
if count >= limit:
|
|
|
|
|
return False
|
|
|
|
|
conn.execute(
|
|
|
|
|
"""
|
|
|
|
|
INSERT INTO rate_limits(key, window, count)
|
|
|
|
|
VALUES (?, ?, 1)
|
|
|
|
|
ON CONFLICT(key, window) DO UPDATE SET count=count + 1
|
|
|
|
|
""",
|
|
|
|
|
(key, window),
|
|
|
|
|
)
|
|
|
|
|
return True
|
|
|
|
|
|
2026-05-20 18:01:29 +02:00
|
|
|
def cleanup(self, challenge_hours: int, unused_account_hours: int) -> dict[str, int]:
|
|
|
|
|
challenge_cutoff = (datetime.now(UTC) - timedelta(hours=challenge_hours)).isoformat()
|
|
|
|
|
unused_account_cutoff = (datetime.now(UTC) - timedelta(hours=unused_account_hours)).isoformat()
|
2026-05-20 13:48:24 +02:00
|
|
|
with self.connect() as conn:
|
2026-05-20 18:01:29 +02:00
|
|
|
challenge_cursor = conn.execute(
|
2026-05-20 13:48:24 +02:00
|
|
|
"DELETE FROM domain_challenges WHERE verified_at IS NULL AND created_at < ?",
|
2026-05-20 18:01:29 +02:00
|
|
|
(challenge_cutoff,),
|
2026-05-20 13:48:24 +02:00
|
|
|
)
|
2026-05-20 18:01:29 +02:00
|
|
|
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,),
|
|
|
|
|
)
|
|
|
|
|
conn.execute("DELETE FROM rate_limits WHERE window < ?", (int(time.time()) - 3600,))
|
2026-05-20 18:17:20 +02:00
|
|
|
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),
|
|
|
|
|
)
|
2026-05-20 18:01:29 +02:00
|
|
|
return {
|
|
|
|
|
"domain_challenges": challenge_cursor.rowcount,
|
|
|
|
|
"unused_accounts": account_cursor.rowcount,
|
|
|
|
|
}
|
2026-05-20 13:48:24 +02:00
|
|
|
|
|
|
|
|
def _init_db(self) -> None:
|
|
|
|
|
with self.connect() as conn:
|
|
|
|
|
conn.execute("PRAGMA journal_mode = WAL")
|
|
|
|
|
conn.execute("PRAGMA synchronous = NORMAL")
|
|
|
|
|
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)")
|
2026-05-20 18:17:20 +02:00
|
|
|
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)")
|
2026-05-20 13:48:24 +02:00
|
|
|
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 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,
|
|
|
|
|
created_at TEXT NOT NULL,
|
2026-05-20 17:57:42 +02:00
|
|
|
disabled INTEGER NOT NULL DEFAULT 0
|
2026-05-20 13:48:24 +02:00
|
|
|
)
|
|
|
|
|
"""
|
|
|
|
|
)
|
|
|
|
|
_ensure_column(conn, "accounts", "update_slug", "TEXT")
|
|
|
|
|
_ensure_column(conn, "accounts", "management_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 TABLE IF NOT EXISTS domain_challenges (
|
|
|
|
|
domain TEXT PRIMARY KEY,
|
|
|
|
|
token TEXT NOT NULL,
|
|
|
|
|
claim_hash TEXT,
|
|
|
|
|
created_at TEXT NOT NULL,
|
2026-05-20 17:57:42 +02:00
|
|
|
verified_at TEXT
|
2026-05-20 13:48:24 +02:00
|
|
|
)
|
|
|
|
|
"""
|
|
|
|
|
)
|
|
|
|
|
_ensure_column(conn, "domain_challenges", "claim_hash", "TEXT")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _default_username(hostname: str) -> str:
|
|
|
|
|
label = hostname.split(".", 1)[0]
|
|
|
|
|
import re
|
|
|
|
|
|
|
|
|
|
return re.sub(r"[^a-z0-9_.-]", "-", label.lower()) or "fritzbox"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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}")
|