A
8672b0de6b
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>
160 lines
4.3 KiB
Markdown
160 lines
4.3 KiB
Markdown
# Unit 07 — Operational Scripts
|
|
|
|
## Goal
|
|
CLI scripts for database initialization, subscriber sync, JSON export, and Raspberry Pi WiFi AP setup.
|
|
|
|
## Files
|
|
- `scripts/init_db.py`
|
|
- `scripts/sync_subscribers.py`
|
|
- `scripts/export_subscribers.py`
|
|
- `scripts/setup_wifi_ap.sh`
|
|
|
|
---
|
|
|
|
## `scripts/init_db.py`
|
|
|
|
```python
|
|
#!/usr/bin/env python3
|
|
"""Initialize the SQLite subscriber database."""
|
|
import sys, os
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
|
|
|
from server.utils import load_config
|
|
from server.database import init_db
|
|
|
|
def main():
|
|
config_path = os.environ.get("CONFIG_PATH", "config.yaml")
|
|
cfg = load_config(config_path)
|
|
db_path = cfg["database"]["path"]
|
|
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
|
init_db(db_path)
|
|
print(f"Database initialized at {db_path}")
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
```
|
|
|
|
---
|
|
|
|
## `scripts/sync_subscribers.py`
|
|
|
|
```python
|
|
#!/usr/bin/env python3
|
|
"""Sync unsynced subscribers to the configured core network."""
|
|
import sys, os, logging
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
|
|
|
from server.utils import load_config
|
|
from server.database import get_connection, list_unsynced, mark_synced
|
|
from server.core_adapters import get_adapter
|
|
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
def main():
|
|
config_path = os.environ.get("CONFIG_PATH", "config.yaml")
|
|
cfg = load_config(config_path)
|
|
adapter = get_adapter(cfg)
|
|
if adapter is None:
|
|
logger.error("No core adapter configured. Set core.type in config.")
|
|
sys.exit(1)
|
|
|
|
conn = get_connection(cfg["database"]["path"])
|
|
unsynced = list_unsynced(conn)
|
|
logger.info(f"Found {len(unsynced)} unsynced subscribers")
|
|
|
|
success = 0
|
|
for sub in unsynced:
|
|
try:
|
|
adapter.add_subscriber(sub.imsi, sub.ki, sub.opc)
|
|
mark_synced(conn, sub.imsi)
|
|
success += 1
|
|
logger.info(f"Synced {sub.imsi}")
|
|
except Exception as e:
|
|
logger.error(f"Failed to sync {sub.imsi}: {e}")
|
|
|
|
conn.close()
|
|
logger.info(f"Sync complete: {success}/{len(unsynced)} succeeded")
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
```
|
|
|
|
---
|
|
|
|
## `scripts/export_subscribers.py`
|
|
|
|
```python
|
|
#!/usr/bin/env python3
|
|
"""Export all subscribers to JSON on stdout."""
|
|
import sys, os, json
|
|
from datetime import datetime
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
|
|
|
from server.utils import load_config
|
|
from server.database import get_connection, export_all
|
|
|
|
def main():
|
|
config_path = os.environ.get("CONFIG_PATH", "config.yaml")
|
|
cfg = load_config(config_path)
|
|
conn = get_connection(cfg["database"]["path"])
|
|
subscribers = export_all(conn)
|
|
conn.close()
|
|
|
|
output = {
|
|
"exported_at": datetime.utcnow().isoformat() + "Z",
|
|
"count": len(subscribers),
|
|
"subscribers": subscribers
|
|
}
|
|
print(json.dumps(output, indent=2, default=str))
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
```
|
|
|
|
---
|
|
|
|
## `scripts/setup_wifi_ap.sh`
|
|
|
|
Raspberry Pi WiFi AP setup. Must be run as root.
|
|
|
|
Steps:
|
|
1. Install packages: `hostapd dnsmasq nginx python3 python3-pip`
|
|
2. Configure static IP on `wlan0`: `/etc/dhcpcd.conf` entry `interface wlan0 static ip_address=192.168.4.1/24`
|
|
3. Write `/etc/hostapd/hostapd.conf` (SSID=Emergency-5G, open auth, channel 6)
|
|
4. Write `/etc/dnsmasq.conf` (DHCP range 192.168.4.10-250, `address=/#/192.168.4.1`)
|
|
5. Write nginx site config (proxy `/api/` → Flask, serve portal static)
|
|
6. Enable `net.ipv4.ip_forward=1` in `/etc/sysctl.conf`
|
|
7. Add iptables rules: all port 80 from `wlan0` → `192.168.4.1:80` (captive portal)
|
|
8. Enable and start: `hostapd`, `dnsmasq`, `nginx`, `esim-provisioning`
|
|
9. Install Python dependencies: `pip3 install -r /opt/esim-provisioning/requirements.txt`
|
|
10. Print success message and next steps
|
|
|
|
Script structure:
|
|
```bash
|
|
#!/bin/bash
|
|
set -euo pipefail
|
|
[ "$(id -u)" = "0" ] || { echo "Must run as root"; exit 1; }
|
|
|
|
SSID="Emergency-5G"
|
|
CHANNEL=6
|
|
AP_IP="192.168.4.1"
|
|
DHCP_RANGE="192.168.4.10,192.168.4.250,24h"
|
|
INSTALL_DIR="/opt/esim-provisioning"
|
|
...
|
|
```
|
|
|
|
---
|
|
|
|
## Verification
|
|
```bash
|
|
# init_db
|
|
CONFIG_PATH=config.yaml python scripts/init_db.py
|
|
|
|
# export (after some provisioning)
|
|
CONFIG_PATH=config.yaml python scripts/export_subscribers.py | python -m json.tool
|
|
|
|
# sync (requires core adapter running)
|
|
CONFIG_PATH=config.yaml python scripts/sync_subscribers.py
|
|
```
|