Push Tracker
ria-ran--sim-drop/server/app.py
A ashkan@beigi.net 8672b0de6b 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 05:56:31 +00:00

208 lines
5.9 KiB
Python

import logging
import os
import time
from datetime import datetime, timezone
from flask import Flask, jsonify, redirect, request, send_file, send_from_directory
from server.core_adapters import get_adapter
from server.database import (
add_subscriber,
check_rate_limit,
export_all,
get_connection,
get_subscriber_by_id,
init_db,
)
from server.profile_generator import (
create_esim_profile,
derive_opc,
generate_iccid,
generate_imsi,
generate_ki,
)
from server.utils import (
bytes_to_hex,
get_client_ip,
get_mac_from_arp,
hex_to_bytes,
load_config,
normalize_mac,
validate_mac,
)
logger = logging.getLogger(__name__)
CONFIG_PATH = os.environ.get("CONFIG_PATH", "config.yaml")
_profile_cache: dict[int, bytes] = {}
_start_time = time.time()
def create_app(config_path: str = CONFIG_PATH) -> Flask:
cfg = load_config(config_path)
portal_dir = os.path.join(os.path.dirname(__file__), "..", "portal")
portal_dir = os.path.abspath(portal_dir)
app = Flask(__name__, static_folder=portal_dir, static_url_path="")
db_path = cfg["database"]["path"]
os.makedirs(os.path.dirname(db_path), exist_ok=True)
init_db(db_path)
adapter = get_adapter(cfg)
if adapter:
logger.info(f"Core adapter: {adapter.__class__.__name__}")
else:
logger.info("Core adapter: none (export-only mode)")
net_cfg = cfg["network"]
mcc = net_cfg["mcc"]
mnc = net_cfg["mnc"]
op_bytes = hex_to_bytes(net_cfg["op_key"])
profile_cfg = cfg["profile"]
profile_name = profile_cfg["profile_name"]
carrier_name = profile_cfg["carrier_name"]
rl_cfg = cfg["rate_limiting"]
window_hours = rl_cfg["window_hours"]
# ------------------------------------------------------------------
# Routes
# ------------------------------------------------------------------
@app.route("/")
def index():
return redirect("/activate", code=302)
@app.route("/activate")
def activate():
return send_from_directory(portal_dir, "index.html")
@app.route("/api/provision", methods=["POST"])
def provision():
body = request.get_json(silent=True) or {}
mac = body.get("mac_address", "").strip()
if not mac:
client_ip = get_client_ip(request)
mac = get_mac_from_arp(client_ip) or ""
if not mac:
return jsonify({"error": "Could not determine MAC address"}), 400
mac = normalize_mac(mac)
if not validate_mac(mac):
return jsonify({"error": "Invalid MAC address"}), 400
conn = get_connection(db_path)
try:
if not check_rate_limit(conn, mac, window_hours):
return jsonify(
{"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)
profile_bytes = create_esim_profile(
imsi, ki, opc, iccid, profile_name, carrier_name
)
sub_id = add_subscriber(conn, imsi, iccid, ki_hex, opc_hex, mac)
_profile_cache[sub_id] = profile_bytes
if adapter:
try:
adapter.add_subscriber(imsi, ki_hex, opc_hex)
except Exception as exc:
logger.error(f"Core adapter sync failed for {imsi}: {exc}")
logger.info(f"Provisioned IMSI {imsi} for MAC {mac} (id={sub_id})")
return jsonify(
{
"id": sub_id,
"imsi": imsi,
"iccid": iccid,
"profile_url": f"/api/profile/{sub_id}",
}
), 200
except Exception as exc:
logger.exception(f"Provision failed: {exc}")
return jsonify({"error": "Profile generation failed"}), 500
finally:
conn.close()
@app.route("/api/profile/<int:sub_id>")
def get_profile(sub_id: int):
profile_bytes = _profile_cache.get(sub_id)
if profile_bytes is None:
conn = get_connection(db_path)
sub = get_subscriber_by_id(conn, sub_id)
conn.close()
if sub is None:
return jsonify({"error": "Profile not found"}), 404
return jsonify({"error": "Profile no longer cached; re-provision to download"}), 410
import io
return send_file(
io.BytesIO(profile_bytes),
mimetype="application/octet-stream",
as_attachment=True,
download_name="esim-profile.bin",
)
@app.route("/api/export")
def export():
conn = get_connection(db_path)
try:
subscribers = export_all(conn)
finally:
conn.close()
return jsonify(
{
"exported_at": datetime.now(timezone.utc).isoformat(),
"count": len(subscribers),
"subscribers": subscribers,
}
)
@app.route("/api/status")
def status():
conn = get_connection(db_path)
try:
count = conn.execute("SELECT COUNT(*) FROM subscribers").fetchone()[0]
finally:
conn.close()
return jsonify(
{
"status": "ok",
"subscriber_count": count,
"uptime_seconds": int(time.time() - _start_time),
"core_adapter": adapter.__class__.__name__ if adapter else "none",
}
)
return app
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
cfg = load_config(CONFIG_PATH)
app = create_app(CONFIG_PATH)
app.run(
host=cfg["server"]["host"],
port=cfg["server"]["port"],
debug=cfg["server"].get("debug", False),
)