A
2026-07-01 02:11:14 -04:00
|
|
|
import io
|
A
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 01:56:31 -04:00
|
|
|
import logging
|
|
|
|
|
import os
|
A
2026-07-01 02:11:14 -04:00
|
|
|
import secrets
|
A
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 01:56:31 -04:00
|
|
|
import time
|
A
2026-07-01 02:11:14 -04:00
|
|
|
from collections import OrderedDict
|
A
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 01:56:31 -04:00
|
|
|
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")
|
|
|
|
|
|
A
2026-07-01 02:11:14 -04:00
|
|
|
# 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()
|
A
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 01:56:31 -04:00
|
|
|
_start_time = time.time()
|
|
|
|
|
|
|
|
|
|
|
A
2026-07-01 02:11:14 -04:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
A
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 01:56:31 -04:00
|
|
|
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"]
|
A
2026-07-01 02:03:48 -04:00
|
|
|
apn = profile_cfg.get("apn", "internet")
|
|
|
|
|
smdp_address = profile_cfg.get("smdp_address", "esim.local")
|
A
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 01:56:31 -04:00
|
|
|
|
|
|
|
|
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(
|
A
2026-07-01 02:03:48 -04:00
|
|
|
imsi, ki, opc, iccid, profile_name, carrier_name,
|
|
|
|
|
smdp_address=smdp_address, apn=apn,
|
A
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 01:56:31 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
sub_id = add_subscriber(conn, imsi, iccid, ki_hex, opc_hex, mac)
|
A
2026-07-01 02:11:14 -04:00
|
|
|
token = secrets.token_urlsafe(24)
|
|
|
|
|
_cache_profile(token, profile_bytes)
|
A
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 01:56:31 -04:00
|
|
|
|
|
|
|
|
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,
|
A
2026-07-01 02:11:14 -04:00
|
|
|
"profile_url": f"/api/profile/{token}",
|
A
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 01:56:31 -04:00
|
|
|
}
|
|
|
|
|
), 200
|
|
|
|
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
logger.exception(f"Provision failed: {exc}")
|
|
|
|
|
return jsonify({"error": "Profile generation failed"}), 500
|
|
|
|
|
finally:
|
|
|
|
|
conn.close()
|
|
|
|
|
|
A
2026-07-01 02:11:14 -04:00
|
|
|
@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)
|
A
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 01:56:31 -04:00
|
|
|
if profile_bytes is None:
|
A
2026-07-01 02:11:14 -04:00
|
|
|
return jsonify({"error": "Profile not found"}), 404
|
A
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 01:56:31 -04:00
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
)
|