193 lines
6.6 KiB
Python
193 lines
6.6 KiB
Python
"""Application database schema definition and initialisation.
|
|
|
|
BanGUI maintains its own SQLite database that stores configuration, session
|
|
state, blocklist source definitions, and import run logs. This module is
|
|
the single source of truth for the schema — all ``CREATE TABLE`` statements
|
|
live here and are applied on first run via :func:`init_db`.
|
|
|
|
The fail2ban database is separate and is accessed read-only by the history
|
|
and ban services.
|
|
"""
|
|
|
|
import aiosqlite
|
|
import structlog
|
|
|
|
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DDL statements
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_CREATE_SETTINGS: str = """
|
|
CREATE TABLE IF NOT EXISTS settings (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
key TEXT NOT NULL UNIQUE,
|
|
value TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
);
|
|
"""
|
|
|
|
_CREATE_SESSIONS: str = """
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
token TEXT NOT NULL UNIQUE,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
expires_at TEXT NOT NULL
|
|
);
|
|
"""
|
|
|
|
_CREATE_SESSIONS_TOKEN_INDEX: str = """
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_token ON sessions (token);
|
|
"""
|
|
|
|
_CREATE_BLOCKLIST_SOURCES: str = """
|
|
CREATE TABLE IF NOT EXISTS blocklist_sources (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
url TEXT NOT NULL UNIQUE,
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
);
|
|
"""
|
|
|
|
_CREATE_IMPORT_LOG: str = """
|
|
CREATE TABLE IF NOT EXISTS import_log (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
source_id INTEGER REFERENCES blocklist_sources(id) ON DELETE SET NULL,
|
|
source_url TEXT NOT NULL,
|
|
timestamp TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
ips_imported INTEGER NOT NULL DEFAULT 0,
|
|
ips_skipped INTEGER NOT NULL DEFAULT 0,
|
|
errors TEXT
|
|
);
|
|
"""
|
|
|
|
_CREATE_GEO_CACHE: str = """
|
|
CREATE TABLE IF NOT EXISTS geo_cache (
|
|
ip TEXT PRIMARY KEY,
|
|
country_code TEXT,
|
|
country_name TEXT,
|
|
asn TEXT,
|
|
org TEXT,
|
|
cached_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
);
|
|
"""
|
|
|
|
_CREATE_HISTORY_ARCHIVE: str = """
|
|
CREATE TABLE IF NOT EXISTS history_archive (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
jail TEXT NOT NULL,
|
|
ip TEXT NOT NULL,
|
|
timeofban INTEGER NOT NULL,
|
|
bancount INTEGER NOT NULL,
|
|
data TEXT NOT NULL,
|
|
action TEXT NOT NULL CHECK(action IN ('ban', 'unban')),
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
UNIQUE(ip, jail, action, timeofban)
|
|
);
|
|
"""
|
|
|
|
_CREATE_SCHEMA_MIGRATIONS: str = """
|
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
version INTEGER PRIMARY KEY,
|
|
migrated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
);
|
|
"""
|
|
|
|
# Ordered list of DDL statements to execute on initialisation.
|
|
_SCHEMA_STATEMENTS: list[str] = [
|
|
_CREATE_SETTINGS,
|
|
_CREATE_SESSIONS,
|
|
_CREATE_SESSIONS_TOKEN_INDEX,
|
|
_CREATE_BLOCKLIST_SOURCES,
|
|
_CREATE_IMPORT_LOG,
|
|
_CREATE_GEO_CACHE,
|
|
_CREATE_HISTORY_ARCHIVE,
|
|
]
|
|
|
|
_CURRENT_SCHEMA_VERSION: int = 1
|
|
|
|
_MIGRATIONS: dict[int, str] = {
|
|
1: "\n".join(_SCHEMA_STATEMENTS),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def _configure_connection(db: aiosqlite.Connection) -> None:
|
|
"""Apply hardening pragmas to a newly-opened SQLite connection."""
|
|
await db.execute("PRAGMA journal_mode=WAL;")
|
|
await db.execute("PRAGMA foreign_keys=ON;")
|
|
await db.execute("PRAGMA busy_timeout=5000;")
|
|
|
|
|
|
async def _get_current_schema_version(db: aiosqlite.Connection) -> int:
|
|
"""Return the highest applied schema version for the given database."""
|
|
await db.executescript(_CREATE_SCHEMA_MIGRATIONS)
|
|
async with db.execute("SELECT MAX(version) FROM schema_migrations;") as cursor:
|
|
row = await cursor.fetchone()
|
|
if row is None or row[0] is None:
|
|
return 0
|
|
return int(row[0])
|
|
|
|
|
|
async def _apply_migration(db: aiosqlite.Connection, version: int) -> None:
|
|
"""Apply a single migration step and record its completion."""
|
|
migration_script = _MIGRATIONS[version]
|
|
await db.executescript(migration_script)
|
|
await db.execute("INSERT INTO schema_migrations (version) VALUES (?);", (version,))
|
|
await db.commit()
|
|
|
|
|
|
async def _migrate_schema(db: aiosqlite.Connection) -> None:
|
|
"""Migrate the database schema to the latest supported version."""
|
|
current_version = await _get_current_schema_version(db)
|
|
if current_version == _CURRENT_SCHEMA_VERSION:
|
|
return
|
|
|
|
if current_version > _CURRENT_SCHEMA_VERSION:
|
|
raise RuntimeError(
|
|
f"database schema version {current_version} is newer than supported "
|
|
f"version {_CURRENT_SCHEMA_VERSION}"
|
|
)
|
|
|
|
log.info("migrating_database_schema", from_version=current_version, to_version=_CURRENT_SCHEMA_VERSION)
|
|
for next_version in range(current_version + 1, _CURRENT_SCHEMA_VERSION + 1):
|
|
await _apply_migration(db, next_version)
|
|
log.info("database_schema_ready", schema_version=_CURRENT_SCHEMA_VERSION)
|
|
|
|
|
|
async def init_db(db: aiosqlite.Connection) -> None:
|
|
"""Create or migrate the BanGUI application database schema.
|
|
|
|
This function is idempotent — calling it on an already-initialised
|
|
database has no effect. It should be called once during application
|
|
startup inside the FastAPI lifespan handler.
|
|
|
|
Args:
|
|
db: An open :class:`aiosqlite.Connection` to the application database.
|
|
"""
|
|
log.info("initialising_database_schema")
|
|
await _configure_connection(db)
|
|
await _migrate_schema(db)
|
|
|
|
|
|
async def open_db(database_path: str) -> aiosqlite.Connection:
|
|
"""Open a new application SQLite connection with the standard settings.
|
|
|
|
Args:
|
|
database_path: Path to the BanGUI SQLite database.
|
|
|
|
Returns:
|
|
A configured :class:`aiosqlite.Connection` instance.
|
|
"""
|
|
db = await aiosqlite.connect(database_path)
|
|
db.row_factory = aiosqlite.Row
|
|
await _configure_connection(db)
|
|
return db
|