A
f367103ceb
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>
222 lines
6.6 KiB
Python
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),
|
|
)
|