From e054cc69c3e0dcb915e47219f64376772271de59 Mon Sep 17 00:00:00 2001 From: "ashkan@beigi.net" Date: Thu, 2 Jul 2026 02:48:32 +0000 Subject: [PATCH] Add make_profile.py dev/ops CLI (generate + inspect) scripts/make_profile.py generates a profile (v2 JSON or SAIP DER) from config.yaml to a file or stdout, or decodes and summarizes an existing SAIP profile via --inspect. Tests: +5 (build v2/saip, custom ids, main generate/inspect); 98 pass. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 3 ++ README.md | 7 +++ scripts/make_profile.py | 101 +++++++++++++++++++++++++++++++++++++ tests/test_make_profile.py | 85 +++++++++++++++++++++++++++++++ 4 files changed, 196 insertions(+) create mode 100644 scripts/make_profile.py create mode 100644 tests/test_make_profile.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dcef62..053493d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 parses a package back into its ProfileElements. Optional deps in `requirements-saip.txt`. A fully installable profile (remaining mandatory PEs + card validation) is the documented next layer. +- `scripts/make_profile.py`: dev/ops CLI to generate a profile (v2 or saip) to a + file/stdout from `config.yaml`, or `--inspect` a SAIP profile file (decode + + summarize its ProfileElements). - `pytest.ini` to silence asn1tools/pyparsing deprecation warnings. ### Security diff --git a/README.md b/README.md index eacdc5e..6fa48dd 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,13 @@ python scripts/sync_subscribers.py # sync unsynced subscribers to Open5GS/fr python scripts/export_subscribers.py # dump all subscribers as JSON (reads the DB directly) ``` +To generate or inspect a profile outside the server (dev/ops): + +```bash +python scripts/make_profile.py --format saip --out profile.der # generate +python scripts/make_profile.py --inspect profile.der # decode + summarize +``` + Adding a new core is a single class in `server/core_adapters/` implementing the `CoreAdapter` interface. diff --git a/scripts/make_profile.py b/scripts/make_profile.py new file mode 100644 index 0000000..0cf55b0 --- /dev/null +++ b/scripts/make_profile.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Generate or inspect an eSIM profile — a dev/ops utility. + +Generates one profile (v2 JSON or real SGP.22 SAIP DER) using the network and +profile settings from config.yaml, with random or supplied credentials, and +writes it to a file or stdout. Can also decode an existing SAIP profile file. + +Examples: + python scripts/make_profile.py --format saip --out /tmp/p.der + python scripts/make_profile.py --inspect /tmp/p.der +""" +import argparse +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from server import saip_profile +from server.profile_generator import ( + create_esim_profile, + derive_opc, + generate_iccid, + generate_imsi, + generate_ki, +) +from server.utils import hex_to_bytes, load_config + + +def build_profile(cfg: dict, fmt: str = "v2", imsi=None, iccid=None): + """Return (imsi, iccid, ki, opc, profile_bytes) for a freshly built profile.""" + net = cfg["network"] + prof = cfg.get("profile", {}) + imsi = imsi or generate_imsi(net["mcc"], net["mnc"]) + iccid = iccid or generate_iccid() + ki = generate_ki() + opc = derive_opc(ki, hex_to_bytes(net["op_key"])) + + if fmt == "saip": + if not saip_profile.saip_available(): + raise SystemExit("SAIP unavailable: install requirements-saip.txt " + "(asn1tools + SAIP ASN.1 schema)") + data = saip_profile.create_saip_profile( + imsi, ki, opc, iccid, + prof.get("profile_name", ""), prof.get("carrier_name", ""), + ) + else: + data = create_esim_profile( + imsi, ki, opc, iccid, + prof.get("profile_name", ""), prof.get("carrier_name", ""), + smdp_address=prof.get("smdp_address", "esim.local"), + apn=prof.get("apn", "internet"), + ) + return imsi, iccid, ki, opc, data + + +def summarize_saip(data: bytes) -> str: + """Return a human-readable summary of a SAIP profile's ProfileElements.""" + pes = saip_profile.decode_saip_profile(data) + lines = [f"{len(data)} bytes, {len(pes)} ProfileElement(s):"] + for choice, value in pes: + detail = "" + if choice == "header": + detail = f" iccid={value.get('iccid', b'').hex()}" + elif choice in ("mf", "usim") and isinstance(value, dict): + detail = f" templateID={value.get('templateID')}" + lines.append(f" - {choice}{detail}") + return "\n".join(lines) + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--config", default=os.environ.get("CONFIG_PATH", "config.yaml")) + ap.add_argument("--format", choices=["v2", "saip"], default="v2") + ap.add_argument("--imsi", help="use this IMSI instead of a random one") + ap.add_argument("--iccid", help="use this ICCID instead of a random one") + ap.add_argument("--out", help="output file (default: stdout)") + ap.add_argument("--inspect", metavar="FILE", + help="decode a SAIP profile file and exit") + args = ap.parse_args(argv) + + if args.inspect: + with open(args.inspect, "rb") as f: + print(summarize_saip(f.read())) + return 0 + + cfg = load_config(args.config) + imsi, iccid, _ki, _opc, data = build_profile(cfg, args.format, args.imsi, args.iccid) + + if args.out: + with open(args.out, "wb") as f: + f.write(data) + print(f"Wrote {len(data)} bytes to {args.out} " + f"(format={args.format}, IMSI {imsi}, ICCID {iccid})", file=sys.stderr) + else: + sys.stdout.buffer.write(data) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_make_profile.py b/tests/test_make_profile.py new file mode 100644 index 0000000..dfe537a --- /dev/null +++ b/tests/test_make_profile.py @@ -0,0 +1,85 @@ +import importlib.util +import json +import os + +import pytest +import yaml + +from server import saip_profile + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def _load_tool(): + path = os.path.join(ROOT, "scripts", "make_profile.py") + spec = importlib.util.spec_from_file_location("make_profile", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +mp = _load_tool() + +CFG = { + "network": {"mcc": "302", "mnc": "720", + "op_key": "63bfa50ee6523365ff14c1f45f88737d"}, + "profile": {"profile_name": "Emergency 5G", "carrier_name": "C", + "apn": "internet", "smdp_address": "esim.local"}, +} + +saip_required = pytest.mark.skipif( + not saip_profile.saip_available(), reason="SAIP unavailable") + + +def _full_config(tmp_path): + cfg = dict(CFG) + cfg.update({ + "server": {"host": "0.0.0.0", "port": 5000}, + "database": {"path": str(tmp_path / "s.db")}, + "rate_limiting": {"window_hours": 1}, + }) + path = tmp_path / "config.yaml" + path.write_text(yaml.safe_dump(cfg)) + return str(path) + + +class TestBuildProfile: + def test_v2(self): + imsi, iccid, ki, opc, data = mp.build_profile(CFG, "v2") + assert len(imsi) == 15 and len(ki) == 16 + obj = json.loads(data) + assert obj["type"] == "profile-package-v2" and obj["imsi"] == imsi + + def test_v2_custom_ids(self): + imsi, iccid, *_rest = mp.build_profile( + CFG, "v2", imsi="302720111111111", iccid="8910000000000000009") + assert imsi == "302720111111111" + assert iccid == "8910000000000000009" + + @saip_required + def test_saip_and_summary(self): + _imsi, _iccid, _ki, _opc, data = mp.build_profile(CFG, "saip") + assert data[0] == 0xA0 + summary = mp.summarize_saip(data) + assert "header" in summary and "usim" in summary + + +class TestMain: + def test_generate_v2_to_file(self, tmp_path, capsys): + out = tmp_path / "p.json" + rc = mp.main(["--config", _full_config(tmp_path), "--format", "v2", + "--out", str(out)]) + assert rc == 0 + obj = json.loads(out.read_bytes()) + assert obj["type"] == "profile-package-v2" + + @saip_required + def test_generate_and_inspect_saip(self, tmp_path, capsys): + out = tmp_path / "p.der" + mp.main(["--config", _full_config(tmp_path), "--format", "saip", + "--out", str(out)]) + assert out.read_bytes()[0] == 0xA0 + capsys.readouterr() + mp.main(["--inspect", str(out)]) + printed = capsys.readouterr().out + assert "ProfileElement" in printed and "usim" in printed