A
1ade8dfc2c
Upgrade create_esim_profile from a loose JSON mock to a structured profile-package-v2 that embeds real 3GPP field encodings: - enc_iccid: EF_ICCID nibble-swapped BCD, 10-byte 0xF-padded (TS 31.102 4.2.1) - enc_imsi: EF_IMSI length octet + parity nibble + nibble-swapped BCD (4.2.2) - build_lpa_activation_code: SGP.22 LPA:1$<smdp>$<matching-id> string Wire apn and smdp_address through config into the provisioning call; add smdp_address to config.yaml.example. Add CHANGELOG.md. Full SGP.22 SAIP/ASN.1 UPP generation via osmocom pySim remains a follow-up (not on PyPI; needs SM-DP+/LPA for OTA install) and is noted in the changelog. Tests: +9 (encoders, round-trips, activation code); 53 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
211 lines
6.1 KiB
Python
211 lines
6.1 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"]
|
|
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)
|
|
_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),
|
|
)
|