diff --git a/CHANGELOG.md b/CHANGELOG.md index 15a5caa..3fed75b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,9 +26,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `ensure_parent_dir` helper that no-ops on an empty parent. - Open5GS/free5gc adapters now use the configured `profile.apn` for the core session/DNN name instead of a hardcoded `"internet"`. +- Connections set `PRAGMA busy_timeout` (5s) so concurrent writers under a + threaded server wait for the lock instead of raising "database is locked". +- Provisioning retries on the (astronomically rare) IMSI/ICCID collision + instead of returning a 500. +- The in-memory profile cache is guarded by a lock (thread-safe under a + threaded server), and the captive portal disables the activate button while + a request is in flight to prevent double provisioning. - Added `test_app.py` (API-level: provision, rate-limit, token download, - 404 on unknown/enumerated ids), `test_utils.py` (MAC/IMSI/op_key/config/ - ensure_parent_dir), and adapter APN coverage. + 404 on unknown/enumerated ids, collision retry/give-up), `test_utils.py` + (MAC/IMSI/op_key/config/ensure_parent_dir), and adapter APN coverage. ### Added - **Phase-2 profile generation.** `create_esim_profile` now emits a structured diff --git a/portal/js/script.js b/portal/js/script.js index e71488d..3f639aa 100644 --- a/portal/js/script.js +++ b/portal/js/script.js @@ -15,7 +15,13 @@ function showError(msg) { showState('error'); } +var inFlight = false; + async function activate() { + if (inFlight) return; // ignore double-clicks while a request is pending + inFlight = true; + var btn = document.getElementById('btn-activate'); + if (btn) btn.disabled = true; showState('loading'); try { @@ -49,6 +55,9 @@ async function activate() { } catch (err) { showError('Could not reach the server. Make sure you are connected to the WiFi network.'); + } finally { + inFlight = false; + if (btn) btn.disabled = false; } } diff --git a/server/app.py b/server/app.py index ff953c0..dd0282a 100644 --- a/server/app.py +++ b/server/app.py @@ -2,6 +2,7 @@ import io import logging import os import secrets +import threading import time from collections import OrderedDict from datetime import datetime, timezone @@ -43,14 +44,21 @@ CONFIG_PATH = os.environ.get("CONFIG_PATH", "config.yaml") # The cache is size-capped so it cannot grow without bound. _MAX_CACHED_PROFILES = 256 _profile_cache: "OrderedDict[str, bytes]" = OrderedDict() +_cache_lock = threading.Lock() _start_time = time.time() def _cache_profile(token: str, data: bytes) -> None: - _profile_cache[token] = data - _profile_cache.move_to_end(token) - while len(_profile_cache) > _MAX_CACHED_PROFILES: - _profile_cache.popitem(last=False) + with _cache_lock: + _profile_cache[token] = data + _profile_cache.move_to_end(token) + while len(_profile_cache) > _MAX_CACHED_PROFILES: + _profile_cache.popitem(last=False) + + +def _get_cached_profile(token: str): + with _cache_lock: + return _profile_cache.get(token) def create_app(config_path: str = CONFIG_PATH) -> Flask: @@ -120,20 +128,29 @@ def create_app(config_path: str = CONFIG_PATH) -> Flask: {"error": "Rate limit exceeded. One eSIM profile per device per hour."} ), 429 - imsi = generate_imsi(mcc, mnc) - ki = generate_ki() - iccid = generate_iccid() - opc = derive_opc(ki, op_bytes) - - ki_hex = bytes_to_hex(ki) - opc_hex = bytes_to_hex(opc) + # Retry on the astronomically-unlikely IMSI/ICCID collision so a + # duplicate does not surface to the user as a 500. + sub_id = None + for attempt in range(5): + imsi = generate_imsi(mcc, mnc) + ki = generate_ki() + iccid = generate_iccid() + opc = derive_opc(ki, op_bytes) + ki_hex = bytes_to_hex(ki) + opc_hex = bytes_to_hex(opc) + try: + sub_id = add_subscriber(conn, imsi, iccid, ki_hex, opc_hex, mac) + break + except ValueError as exc: + logger.warning(f"Credential collision (attempt {attempt + 1}), retrying: {exc}") + if sub_id is None: + logger.error("Provision failed: could not allocate a unique IMSI/ICCID") + return jsonify({"error": "Profile generation failed"}), 500 profile_bytes = create_esim_profile( imsi, ki, opc, iccid, profile_name, carrier_name, smdp_address=smdp_address, apn=apn, ) - - sub_id = add_subscriber(conn, imsi, iccid, ki_hex, opc_hex, mac) token = secrets.token_urlsafe(24) _cache_profile(token, profile_bytes) @@ -164,7 +181,7 @@ def create_app(config_path: str = CONFIG_PATH) -> Flask: # Capability URL: only the holder of the random token can download the # profile. Never distinguish "wrong token" from "expired/evicted" — # both are a plain 404 so tokens can't be probed. - profile_bytes = _profile_cache.get(token) + profile_bytes = _get_cached_profile(token) if profile_bytes is None: return jsonify({"error": "Profile not found"}), 404 diff --git a/server/database.py b/server/database.py index b2455be..c8528d6 100644 --- a/server/database.py +++ b/server/database.py @@ -35,9 +35,14 @@ def init_db(db_path: str) -> None: def get_connection(db_path: str) -> sqlite3.Connection: - """Return an open connection with Row factory enabled.""" - conn = sqlite3.connect(db_path, detect_types=sqlite3.PARSE_DECLTYPES) + """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) conn.row_factory = sqlite3.Row + conn.execute("PRAGMA busy_timeout = 5000") return conn diff --git a/tests/test_app.py b/tests/test_app.py index 5d0fba0..6c31692 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -71,6 +71,32 @@ class TestProvision: r = client.post("/api/provision", json={"mac_address": "de:ad:be:ef:00:01"}) assert r.status_code == 429 + def test_provision_retries_on_collision(self, client, monkeypatch): + import server.app as appmod + real_add = appmod.add_subscriber + calls = {"n": 0} + + def flaky(conn, imsi, iccid, ki, opc, mac): + calls["n"] += 1 + if calls["n"] == 1: + raise ValueError("Duplicate subscriber") + return real_add(conn, imsi, iccid, ki, opc, mac) + + monkeypatch.setattr(appmod, "add_subscriber", flaky) + r = client.post("/api/provision", json={"mac_address": "aa:bb:cc:ab:cd:ef"}) + assert r.status_code == 200 + assert calls["n"] == 2 # failed once, succeeded on retry + + def test_provision_gives_up_after_persistent_collision(self, client, monkeypatch): + import server.app as appmod + + def always_dup(*a, **k): + raise ValueError("Duplicate subscriber") + + monkeypatch.setattr(appmod, "add_subscriber", always_dup) + r = client.post("/api/provision", json={"mac_address": "aa:bb:cc:ab:cd:11"}) + assert r.status_code == 500 + class TestProfileDownload: def test_download_with_token(self, client):