furtka-gateway/control_plane/db.py
Robert Syrnicki 3a9d18fcd5 Scaffold gateway stack and single-tenant control plane
wg-easy + Traefik docker-compose stack (Phase 1) plus a stdlib-only
control-plane API for box registration, WireGuard peer provisioning via
wg-easy, and per-box route publish/unpublish backed by Traefik's file
provider (Phase 2, single-tenant mode). SQLite holds accounts/boxes/routes
so a later multi-tenant shared instance is the same schema with more rows,
not a reshape.

wg-easy's actual REST API was verified against its source rather than
assumed: it has no bearer-token auth (session-cookie login via
POST /api/auth/password) and no way to accept an externally-generated
public key (it always mints the keypair itself, private key included) —
both corrected from the original plan during implementation.
2026-08-24 11:50:25 +02:00

55 lines
1.9 KiB
Python

"""Thin sqlite3 wrapper: one connection per process, guarded by a lock.
Why sqlite3 instead of furtka-core's flat-JSON-plus-flock convention: that
convention works because each file is effectively single-writer. This
gateway's defining requirement is many unrelated accounts/boxes registering
and publishing routes concurrently — a locked JSON file would serialize
every tenant's writes against every other tenant's. sqlite3 (stdlib, zero
extra dependencies) gives real concurrent relational access instead.
"""
from __future__ import annotations
import sqlite3
import threading
from pathlib import Path
from control_plane import paths
_SCHEMA_PATH = Path(__file__).parent / "schema.sql"
class Database:
def __init__(self, db_path: Path | None = None) -> None:
path = db_path or paths.db_path()
path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.Lock()
self._conn = sqlite3.connect(path, check_same_thread=False)
self._conn.row_factory = sqlite3.Row
self._conn.execute("PRAGMA foreign_keys = ON")
with self._lock:
self._conn.executescript(_SCHEMA_PATH.read_text())
self._conn.commit()
def execute(self, sql: str, params: tuple = ()) -> None:
with self._lock:
self._conn.execute(sql, params)
self._conn.commit()
def query_one(self, sql: str, params: tuple = ()) -> sqlite3.Row | None:
with self._lock:
return self._conn.execute(sql, params).fetchone()
def query_all(self, sql: str, params: tuple = ()) -> list[sqlite3.Row]:
with self._lock:
return self._conn.execute(sql, params).fetchall()
def close(self) -> None:
with self._lock:
self._conn.close()
def row_to_dict(row: sqlite3.Row | None) -> dict | None:
if row is None:
return None
return dict(zip(row.keys(), row, strict=True))