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 <noreply@anthropic.com>
This commit is contained in:
parent
a5fc9a3e91
commit
e054cc69c3
|
|
@ -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
|
parses a package back into its ProfileElements. Optional deps in
|
||||||
`requirements-saip.txt`. A fully installable profile (remaining mandatory PEs +
|
`requirements-saip.txt`. A fully installable profile (remaining mandatory PEs +
|
||||||
card validation) is the documented next layer.
|
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.
|
- `pytest.ini` to silence asn1tools/pyparsing deprecation warnings.
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
|
||||||
|
|
@ -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)
|
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
|
Adding a new core is a single class in `server/core_adapters/` implementing the
|
||||||
`CoreAdapter` interface.
|
`CoreAdapter` interface.
|
||||||
|
|
||||||
|
|
|
||||||
101
scripts/make_profile.py
Normal file
101
scripts/make_profile.py
Normal file
|
|
@ -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())
|
||||||
85
tests/test_make_profile.py
Normal file
85
tests/test_make_profile.py
Normal file
|
|
@ -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
|
||||||
Loading…
Reference in New Issue
Block a user