A
Add Phase-1 eSIM provisioning implementation
Full working closed-loop eSIM provisioning stack (spec in claude.md / plan/):
Flask API, SQLite subscriber store, credential/profile generator, Open5GS
and free5gc core adapters, captive portal, operational scripts, systemd
units, network configs, and a 44-test suite.
Profile generation currently returns a mock JSON profile; real SGP.22
generation via pySim is the planned Phase-2 upgrade.
Fixes two bugs found while getting the suite green:
- generate_imsi hardcoded a 10-digit MSIN, producing 16-digit IMSIs for a
3-digit MNC; MSIN length is now 15 - len(mcc) - len(mnc).
- check_rate_limit compared timestamps as strings across mismatched formats
(SQLite CURRENT_TIMESTAMP vs Python isoformat); both sides now normalized
via SQLite datetime() so the window check is chronologically correct.
Add .gitignore for venv, caches, runtime config.yaml, and databases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 01:56:31 -04:00
|
|
|
import sqlite3
|
|
|
|
|
import logging
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
from typing import Optional
|
|
|
|
|
|
|
|
|
|
from server.models import Subscriber
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
_SCHEMA = """
|
|
|
|
|
CREATE TABLE IF NOT EXISTS subscribers (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
imsi TEXT UNIQUE NOT NULL,
|
|
|
|
|
iccid TEXT UNIQUE NOT NULL,
|
|
|
|
|
ki TEXT NOT NULL,
|
|
|
|
|
opc TEXT NOT NULL,
|
|
|
|
|
mac_address TEXT,
|
|
|
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
|
synced_to_core BOOLEAN DEFAULT 0
|
|
|
|
|
);
|
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_imsi ON subscribers(imsi);
|
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_mac_address ON subscribers(mac_address);
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def init_db(db_path: str) -> None:
|
|
|
|
|
"""Create the database schema if it does not already exist."""
|
|
|
|
|
conn = sqlite3.connect(db_path)
|
|
|
|
|
try:
|
|
|
|
|
conn.executescript(_SCHEMA)
|
|
|
|
|
conn.commit()
|
|
|
|
|
logger.info(f"Database initialized at {db_path}")
|
|
|
|
|
finally:
|
|
|
|
|
conn.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_connection(db_path: str) -> sqlite3.Connection:
|
A
2026-07-01 02:16:25 -04:00
|
|
|
"""Return an open connection with Row factory enabled.
|
|
|
|
|
|
|
|
|
|
Sets a busy timeout so that under a threaded server concurrent writers
|
|
|
|
|
wait for the lock instead of immediately raising "database is locked".
|
|
|
|
|
"""
|
|
|
|
|
conn = sqlite3.connect(db_path, detect_types=sqlite3.PARSE_DECLTYPES, timeout=5.0)
|
A
Add Phase-1 eSIM provisioning implementation
Full working closed-loop eSIM provisioning stack (spec in claude.md / plan/):
Flask API, SQLite subscriber store, credential/profile generator, Open5GS
and free5gc core adapters, captive portal, operational scripts, systemd
units, network configs, and a 44-test suite.
Profile generation currently returns a mock JSON profile; real SGP.22
generation via pySim is the planned Phase-2 upgrade.
Fixes two bugs found while getting the suite green:
- generate_imsi hardcoded a 10-digit MSIN, producing 16-digit IMSIs for a
3-digit MNC; MSIN length is now 15 - len(mcc) - len(mnc).
- check_rate_limit compared timestamps as strings across mismatched formats
(SQLite CURRENT_TIMESTAMP vs Python isoformat); both sides now normalized
via SQLite datetime() so the window check is chronologically correct.
Add .gitignore for venv, caches, runtime config.yaml, and databases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 01:56:31 -04:00
|
|
|
conn.row_factory = sqlite3.Row
|
A
2026-07-01 02:16:25 -04:00
|
|
|
conn.execute("PRAGMA busy_timeout = 5000")
|
A
Add Phase-1 eSIM provisioning implementation
Full working closed-loop eSIM provisioning stack (spec in claude.md / plan/):
Flask API, SQLite subscriber store, credential/profile generator, Open5GS
and free5gc core adapters, captive portal, operational scripts, systemd
units, network configs, and a 44-test suite.
Profile generation currently returns a mock JSON profile; real SGP.22
generation via pySim is the planned Phase-2 upgrade.
Fixes two bugs found while getting the suite green:
- generate_imsi hardcoded a 10-digit MSIN, producing 16-digit IMSIs for a
3-digit MNC; MSIN length is now 15 - len(mcc) - len(mnc).
- check_rate_limit compared timestamps as strings across mismatched formats
(SQLite CURRENT_TIMESTAMP vs Python isoformat); both sides now normalized
via SQLite datetime() so the window check is chronologically correct.
Add .gitignore for venv, caches, runtime config.yaml, and databases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 01:56:31 -04:00
|
|
|
return conn
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _row_to_subscriber(row: sqlite3.Row) -> Subscriber:
|
|
|
|
|
created = row["created_at"]
|
|
|
|
|
if isinstance(created, str):
|
|
|
|
|
created = datetime.fromisoformat(created)
|
|
|
|
|
return Subscriber(
|
|
|
|
|
id=row["id"],
|
|
|
|
|
imsi=row["imsi"],
|
|
|
|
|
iccid=row["iccid"],
|
|
|
|
|
ki=row["ki"],
|
|
|
|
|
opc=row["opc"],
|
|
|
|
|
mac_address=row["mac_address"],
|
|
|
|
|
created_at=created,
|
|
|
|
|
synced_to_core=bool(row["synced_to_core"]),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def add_subscriber(
|
|
|
|
|
conn: sqlite3.Connection,
|
|
|
|
|
imsi: str,
|
|
|
|
|
iccid: str,
|
|
|
|
|
ki: str,
|
|
|
|
|
opc: str,
|
|
|
|
|
mac_address: Optional[str] = None,
|
|
|
|
|
) -> int:
|
|
|
|
|
"""Insert a new subscriber. Returns the new row id.
|
|
|
|
|
|
|
|
|
|
Raises ValueError if imsi or iccid already exists.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
cur = conn.execute(
|
|
|
|
|
"""
|
|
|
|
|
INSERT INTO subscribers (imsi, iccid, ki, opc, mac_address)
|
|
|
|
|
VALUES (?, ?, ?, ?, ?)
|
|
|
|
|
""",
|
|
|
|
|
(imsi, iccid, ki, opc, mac_address),
|
|
|
|
|
)
|
|
|
|
|
conn.commit()
|
|
|
|
|
return cur.lastrowid
|
|
|
|
|
except sqlite3.IntegrityError as exc:
|
|
|
|
|
raise ValueError(f"Duplicate subscriber (imsi or iccid already exists): {exc}") from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_subscriber_by_imsi(conn: sqlite3.Connection, imsi: str) -> Optional[Subscriber]:
|
|
|
|
|
row = conn.execute("SELECT * FROM subscribers WHERE imsi = ?", (imsi,)).fetchone()
|
|
|
|
|
return _row_to_subscriber(row) if row else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_subscriber_by_id(conn: sqlite3.Connection, sub_id: int) -> Optional[Subscriber]:
|
|
|
|
|
row = conn.execute("SELECT * FROM subscribers WHERE id = ?", (sub_id,)).fetchone()
|
|
|
|
|
return _row_to_subscriber(row) if row else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def list_unsynced(conn: sqlite3.Connection) -> list:
|
|
|
|
|
rows = conn.execute(
|
|
|
|
|
"SELECT * FROM subscribers WHERE synced_to_core = 0"
|
|
|
|
|
).fetchall()
|
|
|
|
|
return [_row_to_subscriber(r) for r in rows]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def mark_synced(conn: sqlite3.Connection, imsi: str) -> None:
|
|
|
|
|
conn.execute(
|
|
|
|
|
"UPDATE subscribers SET synced_to_core = 1 WHERE imsi = ?", (imsi,)
|
|
|
|
|
)
|
|
|
|
|
conn.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def check_rate_limit(
|
|
|
|
|
conn: sqlite3.Connection, mac_address: str, window_hours: int
|
|
|
|
|
) -> bool:
|
|
|
|
|
"""Return True if the MAC address is allowed to provision (no record in the window).
|
|
|
|
|
|
|
|
|
|
Uses SQLite's ``datetime()`` on both sides so the comparison is a true
|
|
|
|
|
chronological one regardless of whether ``created_at`` was written by
|
|
|
|
|
``CURRENT_TIMESTAMP`` (``"YYYY-MM-DD HH:MM:SS"``) or as a Python
|
|
|
|
|
``isoformat()`` string (``"YYYY-MM-DDTHH:MM:SS.ffffff"``).
|
|
|
|
|
"""
|
|
|
|
|
cur = conn.execute(
|
|
|
|
|
"SELECT COUNT(*) FROM subscribers "
|
|
|
|
|
"WHERE mac_address = ? AND datetime(created_at) > datetime('now', ?)",
|
|
|
|
|
(mac_address, f"-{window_hours} hours"),
|
|
|
|
|
)
|
|
|
|
|
count = cur.fetchone()[0]
|
|
|
|
|
return count == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def export_all(conn: sqlite3.Connection) -> list:
|
|
|
|
|
"""Return all subscriber rows as a list of plain dicts."""
|
|
|
|
|
rows = conn.execute("SELECT * FROM subscribers").fetchall()
|
|
|
|
|
result = []
|
|
|
|
|
for row in rows:
|
|
|
|
|
result.append({
|
|
|
|
|
"id": row["id"],
|
|
|
|
|
"imsi": row["imsi"],
|
|
|
|
|
"iccid": row["iccid"],
|
|
|
|
|
"ki": row["ki"],
|
|
|
|
|
"opc": row["opc"],
|
|
|
|
|
"mac_address": row["mac_address"],
|
|
|
|
|
"created_at": str(row["created_at"]),
|
|
|
|
|
"synced_to_core": bool(row["synced_to_core"]),
|
|
|
|
|
})
|
|
|
|
|
return result
|