Push Tracker
ria-ran--sim-drop/server/app.py
A ashkan@beigi.net f367103ceb Harden: capability-token profile URLs, fast core failure, config validation
Security:
- Serve generated profiles at /api/profile/<random-token> instead of
  /api/profile/<sequential-int>. Profiles carry Ki/OPc, so the enumerable id
  scheme allowed anyone on the WiFi to harvest all subscriber keys. Unknown
  tokens return a plain 404 so they can't be probed.

Robustness:
- Open5GS/free5gc adapters use short MongoDB timeouts so an unreachable core
  fails provisioning fast instead of blocking ~30s on the default.
- Profile cache is now a size-capped LRU (OrderedDict) to prevent unbounded
  memory growth.
- load_config rejects empty/non-mapping YAML and validates network.op_key is a
  16-byte hex string.

Tests: +25 (test_app.py API coverage, test_utils.py); 78 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 06:11:14 +00:00

222 lines
6.6 KiB
Python

import io
import logging
import os
import secrets
import time
from collections import OrderedDict
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,
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")
# Generated profiles are held in memory, keyed by an unguessable download token
# (never by the sequential subscriber id — the profile body contains Ki/OPc).
# The cache is size-capped so it cannot grow without bound.
_MAX_CACHED_PROFILES = 256
_profile_cache: "OrderedDict[str, bytes]" = OrderedDict()
_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)
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"]
apn = profile_cfg.get("apn", "internet")
smdp_address = profile_cfg.get("smdp_address", "esim.local")
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,
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)
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/{token}",
}
), 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/<token>")
def get_profile(token: str):
# 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)
if profile_bytes is None:
return jsonify({"error": "Profile not found"}), 404
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),
)