Compare commits

..

7 Commits

Author SHA1 Message Date
A ashkan@beigi.net
0b362bbd0b Harden: gate /api/export behind an admin token (closed by default)
/api/export returns every subscriber's Ki/OPc and was reachable unauthenticated
by any WiFi client (and directly, since Flask binds 0.0.0.0). It now requires
server.admin_token via the X-Admin-Token header (constant-time compare) and is
disabled entirely when no token is configured.

Add admin_token to config.yaml.example. Tests: +2 (disabled/401/200); 86 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:01:59 +00:00
A ashkan@beigi.net
035bc59460 Harden deployment: DB ownership and config secrecy
- Restrict the deployed /etc/esim-provisioning/config.yaml to 600 owned by the
  esim service user; it holds the OP key and was previously world-readable.
- Chown data/ AFTER init_db runs (as root) so the SQLite file is owned by the
  esim service user; the service runs with ReadWritePaths=.../data and could
  not write a root-owned database before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:59:27 +00:00
A ashkan@beigi.net
c6423b1bad Harden: concurrency safety (db lock timeout, cache lock, retry, portal)
- get_connection sets PRAGMA busy_timeout so concurrent writers under a
  threaded server wait instead of hitting "database is locked".
- Provision retries credential generation on IMSI/ICCID collision (up to 5x)
  instead of surfacing a duplicate as a 500.
- Guard the module-level profile cache with a threading.Lock.
- Captive portal disables the activate button while a request is in flight to
  avoid double provisioning.

Tests: +2 (collision retry + give-up); 84 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 06:16:25 +00:00
A ashkan@beigi.net
0be02bd1f4 Harden: fix bare-filename db path crash, configurable APN
- Fix FileNotFoundError when database.path has no directory component
  (os.makedirs(os.path.dirname(path)) with path="subscribers.db" raised).
  Add ensure_parent_dir helper; use it in app.py and scripts/init_db.py.
- Open5GS/free5gc adapters now use profile.apn for the core session/DNN name
  instead of a hardcoded "internet"; apn is threaded through get_adapter.

Tests: +4 (ensure_parent_dir, adapter APN); 82 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 06:13:26 +00:00
A ashkan@beigi.net
f367103ceb Harden: capability-token profile URLs, fast core failure, config validation
Security:
- Serve generated profiles at /api/profile/<random-token> instead of
  /api/profile/<sequential-int>. Profiles carry Ki/OPc, so the enumerable id
  scheme allowed anyone on the WiFi to harvest all subscriber keys. Unknown
  tokens return a plain 404 so they can't be probed.

Robustness:
- Open5GS/free5gc adapters use short MongoDB timeouts so an unreachable core
  fails provisioning fast instead of blocking ~30s on the default.
- Profile cache is now a size-capped LRU (OrderedDict) to prevent unbounded
  memory growth.
- load_config rejects empty/non-mapping YAML and validates network.op_key is a
  16-byte hex string.

Tests: +25 (test_app.py API coverage, test_utils.py); 78 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 06:11:14 +00:00
A ashkan@beigi.net
1ade8dfc2c Phase 2: real 3GPP profile encodings + changelog
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>
2026-07-01 06:03:48 +00:00
A ashkan@beigi.net
8672b0de6b 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 05:56:31 +00:00
48 changed files with 5139 additions and 0 deletions

23
.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
.mypy_cache/
# Virtual environments
venv/
.venv/
env/
# Local runtime config (contains network secrets); commit config.yaml.example instead
config.yaml
# Databases / runtime data
*.db
*.sqlite3
data/
# OS / editor
.DS_Store
*.swp

85
CHANGELOG.md Normal file
View File

@ -0,0 +1,85 @@
# Changelog
All notable changes to this project are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Security
- `/api/export` (dumps every subscriber's Ki/OPc) now requires an admin token
(`server.admin_token`, sent as `X-Admin-Token`, constant-time compared) and
is disabled by default. Previously any WiFi client could pull all credentials
unauthenticated.
- Profile download URLs now use an unguessable capability token
(`secrets.token_urlsafe`) instead of the sequential subscriber id. Profiles
contain Ki/OPc, so the old `/api/profile/<int>` scheme let anyone on the WiFi
enumerate and harvest every subscriber's keys. Unknown tokens return a plain
404 (indistinguishable from evicted) so tokens cannot be probed.
### Hardened
- Core adapters set short MongoDB timeouts (`serverSelectionTimeoutMS` etc.) so
an unreachable core fails provisioning fast instead of hanging ~30s.
- In-memory profile cache is now a size-capped LRU (`_MAX_CACHED_PROFILES`) and
can no longer grow without bound.
- `load_config` rejects an empty/non-mapping YAML file and validates
`network.op_key` is a 16-byte hex string, with clear error messages.
- Fixed `FileNotFoundError` when `database.path` is a bare filename with no
directory component: `app.py` and `scripts/init_db.py` now use a shared
`ensure_parent_dir` helper that no-ops on an empty parent.
- Open5GS/free5gc adapters now use the configured `profile.apn` for the core
session/DNN name instead of a hardcoded `"internet"`.
- Connections set `PRAGMA busy_timeout` (5s) so concurrent writers under a
threaded server wait for the lock instead of raising "database is locked".
- Provisioning retries on the (astronomically rare) IMSI/ICCID collision
instead of returning a 500.
- The in-memory profile cache is guarded by a lock (thread-safe under a
threaded server), and the captive portal disables the activate button while
a request is in flight to prevent double provisioning.
- `setup_wifi_ap.sh`: the deployed `config.yaml` (holds the OP key) is now
`chmod 600` and owned by the service user instead of world-readable, and
`data/` is chowned *after* `init_db` so the root-created SQLite file is
writable by the `esim` service (previously it was root-owned and the service
could not write to it).
- Added `test_app.py` (API-level: provision, rate-limit, token download,
404 on unknown/enumerated ids, collision retry/give-up), `test_utils.py`
(MAC/IMSI/op_key/config/ensure_parent_dir), and adapter APN coverage.
### Added
- **Phase-2 profile generation.** `create_esim_profile` now emits a structured
profile package with real 3GPP encodings instead of a loose JSON mock:
- `enc_iccid` — EF_ICCID encoding (nibble-swapped BCD, 10-byte, 0xF padded)
per 3GPP TS 31.102 §4.2.1.
- `enc_imsi` — EF_IMSI encoding (length octet + parity nibble + nibble-swapped
BCD) per 3GPP TS 31.102 §4.2.2.
- `build_lpa_activation_code` — SGP.22 LPA activation string
(`LPA:1$<smdp>$<matching-id>`) so the profile carries a device-consumable
activation reference.
- `CHANGELOG.md`.
### Changed
- Profile package `type` is now `profile-package-v2`; it embeds the raw
credentials plus the encoded EF byte values and the LPA activation code.
### Fixed
- `generate_imsi` hardcoded a 10-digit MSIN, producing invalid 16-digit IMSIs
for a 3-digit MNC (e.g. MCC 302 / MNC 720). 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`), making the rate-limit
window unreliable. Both sides are now normalized via SQLite `datetime()`.
### Notes
- Full SGP.22 SIMalliance Interoperable Profile (SAIP / ASN.1 UPP) generation
via osmocom pySim remains the follow-up step; it needs an SM-DP+ / LPA to be
installed onto consumer devices over the air. The current package targets
programmable SIMs and core-network sync.
## [0.1.0] — 2026-07-01
### Added
- Initial Phase-1 closed-loop eSIM provisioning stack: Flask API, SQLite
subscriber store, credential generator (IMSI/Ki/OPc/ICCID), Open5GS and
free5gc core adapters, captive portal, operational scripts, systemd units,
network configs, and a test suite.

67
android/README.md Normal file
View File

@ -0,0 +1,67 @@
# Android / Termux Deployment
Run the eSIM provisioning server on an Android device using [Termux](https://termux.dev) (no root required for basic operation).
## Prerequisites
- Android 9 or later
- **Termux** installed from [F-Droid](https://f-droid.org/packages/com.termux/) (not the Play Store version — it is outdated)
- Optional: **Termux:Boot** (F-Droid) for automatic start on reboot
- 5G-capable device with a pre-provisioned SIM for gNB backhaul
## Install
```bash
# In Termux, from the repo root:
bash android/termux_setup.sh
```
The script will:
1. Update Termux packages
2. Install Python, nginx, and required libraries
3. Deploy the server files to `~/esim-provisioning/`
4. Write a localised `config.yaml`
5. Configure nginx on port 8080
6. Initialise the SQLite database
## Start the Server
```bash
~/esim-provisioning/start.sh
```
## WiFi Hotspot
1. Go to **Settings → Network → Hotspot & Tethering → WiFi Hotspot**
2. Set a name (e.g. `Emergency-5G`) and **remove the password** (open network)
3. Enable the hotspot
Clients connect to the hotspot, then navigate to:
```
http://192.168.43.1:8080/activate
```
> Most Android devices use `192.168.43.1` as the hotspot gateway. Samsung and Pixel devices may differ — check with `ip route` in Termux and update `config.yaml` accordingly.
## Captive Portal Limitation
Without root, full DNS hijacking is not possible. Clients will **not** be automatically redirected — they must open the URL manually or scan a QR code posted at the deployment site.
With root + Termux, install `dnsmasq` via `pkg install dnsmasq` and add:
```
address=/#/192.168.43.1
```
to `/data/data/com.termux/files/usr/etc/dnsmasq.conf`, then run `dnsmasq`.
## Configuration
Edit `~/esim-provisioning/config.yaml` to set your MCC, MNC, and OP key, then restart:
```bash
pkill -f server.app
CONFIG_PATH=~/esim-provisioning/config.yaml python -m server.app &
```
## Auto-Start on Reboot (Termux:Boot)
Install **Termux:Boot** from F-Droid. The setup script copies `start.sh` to
`~/.termux/boot/` automatically. Open Termux:Boot once to enable it.

136
android/termux_setup.sh Executable file
View File

@ -0,0 +1,136 @@
#!/data/data/com.termux/files/usr/bin/bash
# termux_setup.sh — Deploy the eSIM provisioning server on Android via Termux.
# Run from the repo root: bash android/termux_setup.sh
set -euo pipefail
INSTALL_DIR="$HOME/esim-provisioning"
FLASK_PORT=5000
NGINX_PORT=8080
# Default Android hotspot gateway — adjust if your device differs
AP_IP="${ESIM_AP_IP:-192.168.43.1}"
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
echo "=== eSIM Provisioning — Termux Setup ==="
echo " Install dir: $INSTALL_DIR"
echo " AP IP: $AP_IP"
echo ""
# ---- 1. Update and install packages ----
echo "[1/5] Installing Termux packages..."
pkg update -y
pkg install -y python python-pip nginx
# ---- 2. Install Python dependencies ----
echo "[2/5] Installing Python dependencies..."
pip install --quiet flask cryptography pymongo pyyaml requests
# ---- 3. Deploy server files ----
echo "[3/5] Deploying server files..."
mkdir -p "$INSTALL_DIR"/{server/core_adapters,portal/{css,js,assets},scripts,tests,data}
cp -r "$SCRIPT_DIR/server/"* "$INSTALL_DIR/server/"
cp -r "$SCRIPT_DIR/portal/"* "$INSTALL_DIR/portal/"
cp -r "$SCRIPT_DIR/scripts/"* "$INSTALL_DIR/scripts/"
cp "$SCRIPT_DIR/requirements.txt" "$INSTALL_DIR/"
# Write a localised config
cat > "$INSTALL_DIR/config.yaml" << EOF
network:
mcc: "302"
mnc: "720"
plmn: "302720"
op_key: "63bfa50ee6523365ff14c1f45f88737d"
wifi:
ssid: "Emergency-5G"
ip: "$AP_IP"
server:
host: "0.0.0.0"
port: $FLASK_PORT
debug: false
database:
path: "$INSTALL_DIR/data/subscribers.db"
rate_limiting:
max_profiles_per_mac: 1
window_hours: 1
core:
type: "none"
profile:
carrier_name: "Ad-Hoc Network"
profile_name: "Emergency 5G"
apn: "internet"
EOF
# ---- 4. Configure nginx ----
echo "[4/5] Configuring nginx..."
mkdir -p "$PREFIX/etc/nginx/conf.d"
cat > "$PREFIX/etc/nginx/nginx.conf" << EOF
worker_processes 1;
events { worker_connections 64; }
http {
include mime.types;
server {
listen $NGINX_PORT default_server;
server_name _;
location = / { return 302 http://$AP_IP:$NGINX_PORT/activate; }
location /activate {
alias $INSTALL_DIR/portal/;
index index.html;
}
location /css/ { alias $INSTALL_DIR/portal/css/; }
location /js/ { alias $INSTALL_DIR/portal/js/; }
location /api/ {
proxy_pass http://127.0.0.1:$FLASK_PORT;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
}
location / { return 302 http://$AP_IP:$NGINX_PORT/activate; }
}
}
EOF
# ---- 5. Initialise database and create start script ----
echo "[5/5] Initialising database and writing start script..."
CONFIG_PATH="$INSTALL_DIR/config.yaml" python "$INSTALL_DIR/scripts/init_db.py"
cat > "$INSTALL_DIR/start.sh" << 'STARTEOF'
#!/data/data/com.termux/files/usr/bin/bash
cd "$HOME/esim-provisioning"
echo "Starting nginx..."
nginx
echo "Starting Flask provisioning server..."
CONFIG_PATH="$HOME/esim-provisioning/config.yaml" python -m server.app &
echo ""
echo "Server running. Connect to Android hotspot then open:"
echo " http://192.168.43.1:8080/activate"
echo ""
echo "To stop: pkill nginx; pkill -f server.app"
STARTEOF
chmod +x "$INSTALL_DIR/start.sh"
# Termux:Boot integration
BOOT_DIR="$HOME/.termux/boot"
mkdir -p "$BOOT_DIR"
cp "$INSTALL_DIR/start.sh" "$BOOT_DIR/esim-provisioning.sh"
echo ""
echo "=== Setup Complete ==="
echo ""
echo "Next steps:"
echo " 1. Enable WiFi Hotspot in Android Settings (any SSID, no password)"
echo " 2. Run: ~/esim-provisioning/start.sh"
echo " 3. Clients connect to hotspot and visit:"
echo " http://$AP_IP:$NGINX_PORT/activate"
echo ""
echo "Install Termux:Boot (from F-Droid) for auto-start on device reboot."

418
claude.md Normal file
View File

@ -0,0 +1,418 @@
# eSIM Provisioning System - Project Setup Instructions
## Project Overview
Build a closed-loop eSIM provisioning system for emergency/NTN 5G deployments. System runs on a UE device (Android or Raspberry Pi) that connects to a 5G gNB and provides WiFi AP with automatic eSIM provisioning.
## Repository Structure
Create the following directory structure:
```
esim-provisioning/
├── README.md
├── requirements.txt
├── config.yaml.example
├── docker-compose.yml (optional)
├── server/
│ ├── __init__.py
│ ├── app.py # Main Flask application
│ ├── profile_generator.py # eSIM profile generation using pySim
│ ├── database.py # SQLite database interface
│ ├── core_adapters/
│ │ ├── __init__.py
│ │ ├── base.py # Base adapter class
│ │ ├── open5gs.py # Open5GS adapter
│ │ └── free5gc.py # free5gc adapter
│ ├── models.py # Database models
│ └── utils.py # Helper functions
├── portal/
│ ├── index.html # Captive portal landing page
│ ├── css/
│ │ └── style.css
│ ├── js/
│ │ └── script.js
│ └── assets/
│ └── (images if needed)
├── scripts/
│ ├── init_db.py # Initialize database
│ ├── sync_subscribers.py # Sync to core network
│ ├── export_subscribers.py # Export subscriber data
│ └── setup_wifi_ap.sh # WiFi AP setup (for Pi)
├── systemd/
│ ├── esim-provisioning.service
│ └── captive-portal.service
├── configs/
│ ├── nginx.conf # nginx captive portal config
│ ├── dnsmasq.conf # DNS/DHCP config
│ └── hostapd.conf # WiFi AP config (for Pi)
├── android/
│ ├── README.md # Android setup instructions
│ └── termux_setup.sh # Termux installation script
└── tests/
├── test_profile_generator.py
├── test_database.py
└── test_core_adapters.py
```
## Step 1: Initialize Project
Create the repository structure and initialize Python virtual environment:
```bash
# Create project directory
mkdir esim-provisioning
cd esim-provisioning
# Initialize git
git init
# Create virtual environment
python3 -m venv venv
source venv/bin/activate
# Create all directories
mkdir -p server/core_adapters portal/css portal/js portal/assets scripts systemd configs android tests
```
## Step 2: Create Requirements File
Create `requirements.txt` with:
```
Flask==3.0.0
pySim==1.0.0
cryptography==41.0.7
pymongo==4.6.0
requests==2.31.0
pyyaml==6.0.1
```
## Step 3: Create Configuration Template
Create `config.yaml.example`:
```yaml
network:
mcc: "302"
mnc: "720"
plmn: "302720"
op_key: "63bfa50ee6523365ff14c1f45f88737d" # Hex string
wifi:
ssid: "Emergency-5G"
channel: 6
ip: "192.168.4.1"
subnet: "192.168.4.0/24"
dhcp_range: "192.168.4.10,192.168.4.250"
server:
host: "0.0.0.0"
port: 5000
debug: false
database:
path: "/opt/esim-provisioning/data/subscribers.db"
rate_limiting:
max_profiles_per_mac: 1
window_hours: 1
core:
type: "open5gs" # or "free5gc"
mongodb_uri: "mongodb://localhost:27017"
database_name: "open5gs"
profile:
carrier_name: "Ad-Hoc Network"
profile_name: "Emergency 5G"
apn: "internet"
```
## Step 4: Core Server Components
### database.py
Create database interface with:
- SQLite connection management
- Schema creation (subscribers table)
- CRUD operations for subscribers
- Rate limiting checks by MAC address
- Export functionality
Schema:
```sql
CREATE TABLE subscribers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
imsi TEXT UNIQUE NOT NULL,
iccid TEXT UNIQUE NOT NULL,
ki TEXT NOT NULL,
opc TEXT NOT NULL,
mac_address TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
synced_to_core BOOLEAN DEFAULT 0
);
CREATE INDEX idx_imsi ON subscribers(imsi);
CREATE INDEX idx_mac_address ON subscribers(mac_address);
```
### profile_generator.py
Implement eSIM profile generation:
- Generate random IMSI (15 digits: MCC + MNC + MSIN)
- Generate random Ki (16 bytes)
- Derive OPc from Ki and OP using Milenage algorithm
- Generate ICCID (19-20 digits)
- Create SGP.22 eSIM profile using pySim
- Return profile bytes and credentials
Key functions:
```python
def generate_imsi(mcc, mnc) -> str
def generate_iccid() -> str
def generate_ki() -> bytes
def derive_opc(ki: bytes, op: bytes) -> bytes
def create_esim_profile(imsi, ki, opc, iccid, profile_name, carrier_name) -> bytes
```
### core_adapters/base.py
Define abstract base class:
```python
class CoreAdapter(ABC):
@abstractmethod
def add_subscriber(self, imsi, ki, opc):
pass
@abstractmethod
def remove_subscriber(self, imsi):
pass
@abstractmethod
def list_subscribers(self):
pass
```
### core_adapters/open5gs.py
Implement Open5GS adapter using MongoDB:
- Connect to MongoDB
- Insert subscriber with proper schema
- Configure slice, APN, AMBR settings
- Handle errors gracefully
### app.py
Main Flask application with endpoints:
```python
GET / # Captive portal redirect
GET /activate # Serve captive portal HTML
POST /api/provision # Generate and provision eSIM
GET /api/profile/:id # Download eSIM profile (LPA)
GET /api/export # Export subscribers JSON
GET /api/status # System status
```
Implement:
- Rate limiting middleware (check MAC address)
- Profile generation workflow
- LPA protocol handling
- Error handling and logging
## Step 5: Captive Portal
### portal/index.html
Simple single-page interface:
- "Activate eSIM" button
- Instructions for installation
- Progress indicator
- Success/error messages
- Mobile-friendly responsive design
### portal/js/script.js
Client-side logic:
- POST to `/api/provision` with MAC address
- Poll for profile generation status
- Trigger eSIM download via LPA URL scheme
- Handle errors
## Step 6: System Scripts
### scripts/init_db.py
Initialize SQLite database with schema
### scripts/sync_subscribers.py
Sync subscribers to core:
```python
# Read from SQLite
# For each unsynced subscriber:
# - Call core adapter
# - Mark as synced
# - Log result
```
### scripts/export_subscribers.py
Export to JSON format:
```json
{
"exported_at": "2025-02-11T10:30:00Z",
"count": 123,
"subscribers": [
{
"imsi": "302720123456789",
"ki": "00112233445566778899aabbccddeeff",
"opc": "63bfa50ee6523365ff14c1f45f88737d",
"iccid": "8910302720123456789",
"created_at": "2025-02-11T09:15:00Z"
}
]
}
```
### scripts/setup_wifi_ap.sh
Bash script for Raspberry Pi WiFi AP setup:
- Install hostapd, dnsmasq, nginx
- Configure network interfaces
- Set up iptables for captive portal
- Enable IP forwarding
- Configure services
## Step 7: Configuration Files
### configs/nginx.conf
Configure captive portal:
- Listen on port 80
- Catch-all server block
- Redirect to /activate
- Proxy /api/* to Flask backend
### configs/dnsmasq.conf
DNS/DHCP configuration:
- Interface binding
- DHCP range
- DNS hijacking (return WiFi AP IP for all domains)
- Lease time
### configs/hostapd.conf
WiFi AP configuration:
- SSID
- Channel
- Interface
- Open network (no password)
## Step 8: Systemd Services
### systemd/esim-provisioning.service
Flask server service:
- Run as dedicated user
- Auto-restart on failure
- Log to journal
- Load config from /etc/esim-provisioning/config.yaml
### systemd/captive-portal.service
Nginx + dnsmasq orchestration
## Step 9: Testing
Create unit tests for:
- Profile generation (valid IMSI, Ki, OPc formats)
- Database operations (CRUD, rate limiting)
- Core adapters (mock MongoDB)
- API endpoints (Flask test client)
## Step 10: Documentation
### README.md
Include:
- Project overview
- System requirements
- Installation instructions (Pi and Android)
- Configuration guide
- Usage instructions
- Troubleshooting
- Architecture diagram
- License
## Implementation Priority
1. **First:** Database layer + models
2. **Second:** Profile generator (without pySim, use mock for testing)
3. **Third:** Flask API with all endpoints
4. **Fourth:** Core adapters (Open5GS first)
5. **Fifth:** Captive portal frontend
6. **Sixth:** Integration with pySim for real profile generation
7. **Seventh:** System scripts and configs
8. **Eighth:** Testing and documentation
## Key Implementation Notes
- Use environment variables or config.yaml for all configuration (never hardcode)
- Log everything (use Python logging module)
- Handle all errors gracefully (no stack traces to user)
- Validate all inputs (IMSI format, MAC address format)
- Use secrets module for cryptographic randomness
- Make core adapters truly modular (easy to add new cores)
- Keep captive portal extremely simple (mobile-first)
- Rate limiting must be robust (prevent abuse)
- Database operations should be atomic
- Profile generation should be fast (<1 second)
## Testing Checklist
- [ ] Can generate valid IMSI
- [ ] Can generate valid Ki/OPc
- [ ] Can create eSIM profile
- [ ] Can store subscriber in database
- [ ] Rate limiting works (same MAC can't request multiple times)
- [ ] Can export subscribers to JSON
- [ ] Open5GS adapter can add subscriber
- [ ] API endpoints return correct responses
- [ ] Captive portal loads on mobile
- [ ] Profile downloads correctly
- [ ] End-to-end: WiFi → provision → 5G connection
## Deployment Targets
**Raspberry Pi:**
- Raspberry Pi 4 (4GB+ RAM)
- Quectel RM500Q 5G modem
- Raspberry Pi OS Lite 64-bit
- Python 3.11+
**Android:**
- Android 10+ with root/Termux
- 5G modem built-in
- Termux + proot environment
- Python 3.11+
## Security Considerations (Future)
Currently implementing with no security for simplicity, but design should allow easy addition of:
- WiFi password authentication
- HTTPS for API endpoints
- Activation code requirements
- Subscriber audit logging
- Profile encryption at rest
## Success Criteria
System is complete when:
1. User connects to WiFi
2. Captive portal loads automatically
3. Click "Activate eSIM" button
4. eSIM profile downloads to device
5. Device installs profile
6. Device connects to 5G using new eSIM
7. Open5GS authenticates successfully
Total user time: <60 seconds
---
## Starting Point
Begin by creating the project structure, then implement in this order:
1. `database.py` - Get data persistence working
2. `profile_generator.py` - Mock implementation first
3. `app.py` - Flask API with all endpoints
4. `portal/index.html` - Simple UI
5. `core_adapters/open5gs.py` - Core integration
6. Integration testing
7. Real pySim integration
8. Deployment scripts
Focus on getting a working end-to-end flow before optimizing or adding features.

36
config.yaml.example Normal file
View File

@ -0,0 +1,36 @@
network:
mcc: "302"
mnc: "720"
plmn: "302720"
op_key: "63bfa50ee6523365ff14c1f45f88737d" # 16-byte hex OP key
wifi:
ssid: "Emergency-5G"
channel: 6
ip: "192.168.4.1"
subnet: "192.168.4.0/24"
dhcp_range: "192.168.4.10,192.168.4.250"
server:
host: "0.0.0.0"
port: 5000
debug: false
admin_token: "" # set a secret to enable /api/export (sent as X-Admin-Token); empty = export disabled
database:
path: "/opt/esim-provisioning/data/subscribers.db"
rate_limiting:
max_profiles_per_mac: 1
window_hours: 1
core:
type: "none" # "open5gs" | "free5gc" | "none"
mongodb_uri: "mongodb://localhost:27017"
database_name: "open5gs"
profile:
carrier_name: "Ad-Hoc Network"
profile_name: "Emergency 5G"
apn: "internet"
smdp_address: "esim.local" # LPA activation-code SM-DP+ address

23
configs/dnsmasq.conf Normal file
View File

@ -0,0 +1,23 @@
# dnsmasq configuration for eSIM provisioning captive portal
# Install: cp configs/dnsmasq.conf /etc/dnsmasq.d/esim-provisioning.conf
# Only listen on the WiFi AP interface
interface=wlan0
bind-interfaces
# DHCP address pool for connecting clients
dhcp-range=192.168.4.10,192.168.4.250,24h
# DNS hijacking: return the AP IP for every domain query
# This triggers the OS captive-portal detection and redirects all HTTP traffic.
address=/#/192.168.4.1
# Do not forward DNS queries upstream (keep it fully local)
no-resolv
# DHCP lease file
dhcp-leasefile=/var/lib/misc/dnsmasq.leases
# Logging (disable in production to reduce I/O)
log-queries
log-dhcp

26
configs/hostapd.conf Normal file
View File

@ -0,0 +1,26 @@
# hostapd configuration for eSIM provisioning WiFi AP
# Install: cp configs/hostapd.conf /etc/hostapd/hostapd.conf
# Also set: DAEMON_CONF="/etc/hostapd/hostapd.conf" in /etc/default/hostapd
interface=wlan0
driver=nl80211
# Network identity
ssid=Emergency-5G
utf8_ssid=1
# Radio — 2.4 GHz 802.11n, channel 6
hw_mode=g
channel=6
ieee80211n=1
wmm_enabled=1
# Open network (no password) — intentional for emergency/NTN access
auth_algs=1
ignore_broadcast_ssid=0
# Adjust to the deployment country for regulatory compliance
country_code=CA
# Limit concurrent clients
max_num_sta=50

44
configs/nginx.conf Normal file
View File

@ -0,0 +1,44 @@
# nginx configuration for eSIM provisioning captive portal
# Install: cp configs/nginx.conf /etc/nginx/sites-available/esim-provisioning
# ln -s /etc/nginx/sites-available/esim-provisioning /etc/nginx/sites-enabled/
# rm -f /etc/nginx/sites-enabled/default
server {
listen 80 default_server;
server_name _;
# Redirect bare root to activation portal
location = / {
return 302 http://192.168.4.1/activate;
}
# Serve captive portal static files
location /activate {
alias /opt/esim-provisioning/portal/;
index index.html;
try_files $uri $uri/ /index.html;
}
location /css/ {
alias /opt/esim-provisioning/portal/css/;
}
location /js/ {
alias /opt/esim-provisioning/portal/js/;
}
# Reverse proxy API calls to Flask backend
location /api/ {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 10s;
proxy_read_timeout 30s;
}
# Catch-all: redirect anything else to the portal
location / {
return 302 http://192.168.4.1/activate;
}
}

View File

@ -0,0 +1,85 @@
# Unit 00 — Project Structure
## Goal
Scaffold all directories, `__init__` files, `requirements.txt`, and `config.yaml.example`. No logic yet — just the skeleton.
## Files to Create
### `requirements.txt`
```
Flask==3.0.0
cryptography==41.0.7
pymongo==4.6.0
requests==2.31.0
pyyaml==6.0.1
pytest==7.4.0
```
> Note: `pySim` is deferred to Unit 06 (real eSIM profile generation) due to install complexity. Unit 02 uses a mock profile.
### `config.yaml.example`
```yaml
network:
mcc: "302"
mnc: "720"
plmn: "302720"
op_key: "63bfa50ee6523365ff14c1f45f88737d" # 16-byte hex OP key
wifi:
ssid: "Emergency-5G"
channel: 6
ip: "192.168.4.1"
subnet: "192.168.4.0/24"
dhcp_range: "192.168.4.10,192.168.4.250"
server:
host: "0.0.0.0"
port: 5000
debug: false
database:
path: "/opt/esim-provisioning/data/subscribers.db"
rate_limiting:
max_profiles_per_mac: 1
window_hours: 1
core:
type: "open5gs" # "open5gs" | "free5gc" | "none"
mongodb_uri: "mongodb://localhost:27017"
database_name: "open5gs"
profile:
carrier_name: "Ad-Hoc Network"
profile_name: "Emergency 5G"
apn: "internet"
```
### Directory Structure
```
esim-provisioning/
├── requirements.txt
├── config.yaml.example
├── server/
│ ├── __init__.py (empty)
│ └── core_adapters/
│ └── __init__.py (empty — factory added in Unit 04)
├── portal/
│ ├── css/ (populated in Unit 06)
│ ├── js/ (populated in Unit 06)
│ └── assets/ (empty)
├── scripts/ (populated in Unit 07)
├── tests/
│ └── __init__.py (empty)
├── android/ (populated in Unit 09)
├── systemd/ (populated in Unit 08)
├── configs/ (populated in Unit 08)
└── plan/ (these files — already done)
```
## Verification
```bash
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
# Should complete with no errors
python -c "import flask, cryptography, pymongo, yaml; print('OK')"
```

90
plan/01-database.md Normal file
View File

@ -0,0 +1,90 @@
# Unit 01 — Database Layer
## Goal
SQLite persistence layer with CRUD operations and MAC-address-based rate limiting.
## Files
- `server/database.py`
- `server/models.py`
---
## `server/models.py`
```python
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
@dataclass
class Subscriber:
id: Optional[int]
imsi: str
iccid: str
ki: str # hex string
opc: str # hex string
mac_address: Optional[str]
created_at: datetime
synced_to_core: bool
```
---
## `server/database.py`
### Schema
```sql
CREATE TABLE IF NOT EXISTS subscribers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
imsi TEXT UNIQUE NOT NULL,
iccid TEXT UNIQUE NOT NULL,
ki TEXT NOT NULL,
opc TEXT NOT NULL,
mac_address TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
synced_to_core BOOLEAN DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_imsi ON subscribers(imsi);
CREATE INDEX IF NOT EXISTS idx_mac_address ON subscribers(mac_address);
```
### Functions
| Function | Signature | Returns | Notes |
|----------|-----------|---------|-------|
| `init_db` | `(db_path: str) -> None` | — | Creates schema idempotently |
| `get_connection` | `(db_path: str) -> sqlite3.Connection` | connection | `row_factory = sqlite3.Row` |
| `add_subscriber` | `(conn, imsi, iccid, ki, opc, mac_address) -> int` | row `id` | Raises `ValueError` on duplicate IMSI/ICCID |
| `get_subscriber_by_imsi` | `(conn, imsi: str) -> Optional[Subscriber]` | Subscriber or None | |
| `get_subscriber_by_id` | `(conn, sub_id: int) -> Optional[Subscriber]` | Subscriber or None | |
| `list_unsynced` | `(conn) -> list[Subscriber]` | list | WHERE synced_to_core = 0 |
| `mark_synced` | `(conn, imsi: str) -> None` | — | UPDATE synced_to_core = 1 |
| `check_rate_limit` | `(conn, mac_address: str, window_hours: int) -> bool` | True = allowed | Counts rows for mac in last N hours |
| `export_all` | `(conn) -> list[dict]` | list of dicts | All rows as serializable dicts |
### Rate Limiting Logic
```python
def check_rate_limit(conn, mac_address, window_hours):
cutoff = datetime.utcnow() - timedelta(hours=window_hours)
cur = conn.execute(
"SELECT COUNT(*) FROM subscribers WHERE mac_address = ? AND created_at > ?",
(mac_address, cutoff.isoformat())
)
count = cur.fetchone()[0]
return count == 0 # True = allowed (no prior provisioning in window)
```
## Verification
```bash
python -c "
from server.database import init_db, get_connection, add_subscriber, check_rate_limit
import tempfile, os
db = tempfile.mktemp(suffix='.db')
init_db(db)
conn = get_connection(db)
sid = add_subscriber(conn, '302720000000001', '8910302720000000001', 'aa'*16, 'bb'*16, 'aa:bb:cc:dd:ee:ff')
print('inserted id:', sid)
print('rate limit (should be False now):', check_rate_limit(conn, 'aa:bb:cc:dd:ee:ff', 1))
conn.close(); os.unlink(db)
"
```

View File

@ -0,0 +1,106 @@
# Unit 02 — Profile Generator
## Goal
Cryptographic credential generation (IMSI, Ki, OPc, ICCID) and Phase 1 mock eSIM profile. Real pySim integration is deferred to Unit 06.
## File
- `server/profile_generator.py`
---
## Functions
### `generate_imsi(mcc: str, mnc: str) -> str`
- Concatenate MCC + MNC + 10-digit random MSIN
- MSIN generated with `secrets.randbelow(10**10)` zero-padded to 10 digits
- Result must be exactly 15 digits
- Validates format before returning
### `generate_ki() -> bytes`
- `secrets.token_bytes(16)` — 16 cryptographically random bytes
### `generate_iccid() -> str`
- Industry prefix: `8910` (Telecom, country code 10)
- Append MCC+MNC from config
- Fill remaining digits randomly
- Total 18 digits + 1 Luhn check digit = 19 digits
- Luhn algorithm:
```python
def luhn_checksum(number: str) -> int:
digits = [int(d) for d in number]
odd_digits = digits[-1::-2]
even_digits = digits[-2::-2]
total = sum(odd_digits)
for d in even_digits:
total += sum(divmod(d * 2, 10))
return (10 - (total % 10)) % 10
```
### `derive_opc(ki: bytes, op: bytes) -> bytes`
Milenage OPc derivation — XOR of OP with AES_Ki(OP):
```python
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
def derive_opc(ki: bytes, op: bytes) -> bytes:
cipher = Cipher(algorithms.AES(ki), modes.ECB(), backend=default_backend())
enc = cipher.encryptor()
encrypted_op = enc.update(op) + enc.finalize()
return bytes(a ^ b for a, b in zip(encrypted_op, op))
```
### `create_esim_profile(imsi, ki, opc, iccid, profile_name, carrier_name) -> bytes`
**Phase 1 (mock):** Returns JSON-encoded bytes with all credential fields:
```python
import json
def create_esim_profile(imsi, ki, opc, iccid, profile_name, carrier_name):
profile = {
"version": "1.0",
"type": "mock",
"iccid": iccid,
"imsi": imsi,
"ki": ki.hex() if isinstance(ki, bytes) else ki,
"opc": opc.hex() if isinstance(opc, bytes) else opc,
"profile_name": profile_name,
"carrier_name": carrier_name,
}
return json.dumps(profile).encode("utf-8")
```
**Phase 2 (Unit 06):** Replace with real pySim SGP.22 profile generation.
---
## Known Test Vectors (for Unit 10 tests)
From 3GPP TS 35.208:
- Ki = `000102030405060708090a0b0c0d0e0f`
- OP = `63bfa50ee6523365ff14c1f45f88737d`
- OPc = compute at test time: `AES_Ki(OP) XOR OP`
---
## Verification
```bash
python -c "
from server.profile_generator import generate_imsi, generate_ki, generate_iccid, derive_opc, create_esim_profile
import binascii
imsi = generate_imsi('302', '720')
assert len(imsi) == 15 and imsi.startswith('302720'), f'bad IMSI: {imsi}'
ki = generate_ki()
assert len(ki) == 16
iccid = generate_iccid()
assert len(iccid) == 19, f'bad ICCID length: {len(iccid)}'
op = bytes.fromhex('63bfa50ee6523365ff14c1f45f88737d')
opc = derive_opc(ki, op)
assert len(opc) == 16
profile = create_esim_profile(imsi, ki, opc, iccid, 'Emergency 5G', 'Ad-Hoc Network')
assert len(profile) > 0
print('All checks passed')
"
```

91
plan/03-utils.md Normal file
View File

@ -0,0 +1,91 @@
# Unit 03 — Utilities
## Goal
Shared helper functions used across server modules. No business logic — pure helpers.
## File
- `server/utils.py`
---
## Functions
### `validate_mac(mac: str) -> bool`
```python
import re
MAC_RE = re.compile(r'^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$')
def validate_mac(mac: str) -> bool:
return bool(MAC_RE.match(mac)) if mac else False
```
### `normalize_mac(mac: str) -> str`
Lowercase, colon-separated. Input may be with dashes or no separator.
```python
def normalize_mac(mac: str) -> str:
clean = mac.lower().replace('-', ':').replace('.', ':')
# handle no-separator format (12 hex chars)
if ':' not in clean and len(clean) == 12:
clean = ':'.join(clean[i:i+2] for i in range(0, 12, 2))
return clean
```
### `validate_imsi(imsi: str) -> bool`
```python
def validate_imsi(imsi: str) -> bool:
return bool(imsi) and imsi.isdigit() and len(imsi) == 15
```
### `load_config(path: str) -> dict`
```python
import yaml, os
REQUIRED_KEYS = ['network', 'server', 'database', 'rate_limiting', 'profile']
def load_config(path: str) -> dict:
if not os.path.exists(path):
raise FileNotFoundError(f"Config file not found: {path}")
with open(path) as f:
cfg = yaml.safe_load(f)
for key in REQUIRED_KEYS:
if key not in cfg:
raise ValueError(f"Missing required config section: {key}")
return cfg
```
### `hex_to_bytes(h: str) -> bytes`
```python
def hex_to_bytes(h: str) -> bytes:
return bytes.fromhex(h)
```
### `bytes_to_hex(b: bytes) -> str`
```python
def bytes_to_hex(b: bytes) -> str:
return b.hex()
```
### `get_client_ip(request) -> str`
Flask request helper — returns real IP behind proxy:
```python
def get_client_ip(request) -> str:
if request.headers.get('X-Forwarded-For'):
return request.headers['X-Forwarded-For'].split(',')[0].strip()
return request.remote_addr
```
---
## Verification
```bash
python -c "
from server.utils import validate_mac, normalize_mac, validate_imsi, load_config
assert validate_mac('aa:bb:cc:dd:ee:ff') == True
assert validate_mac('AA:BB:CC:DD:EE:FF') == True
assert validate_mac('bad') == False
assert normalize_mac('AABBCCDDEEFF') == 'aa:bb:cc:dd:ee:ff'
assert validate_imsi('302720123456789') == True
assert validate_imsi('12345') == False
print('All checks passed')
"
```

145
plan/04-core-adapters.md Normal file
View File

@ -0,0 +1,145 @@
# Unit 04 — Core Adapters
## Goal
Pluggable adapter pattern for Open5GS and free5gc. New cores can be added by subclassing `CoreAdapter`.
## Files
- `server/core_adapters/base.py`
- `server/core_adapters/open5gs.py`
- `server/core_adapters/free5gc.py`
- `server/core_adapters/__init__.py` (factory function)
---
## `server/core_adapters/base.py`
```python
from abc import ABC, abstractmethod
from typing import Optional
class CoreAdapter(ABC):
@abstractmethod
def add_subscriber(self, imsi: str, ki: str, opc: str) -> None:
"""Add a subscriber to the core network. ki and opc are hex strings."""
@abstractmethod
def remove_subscriber(self, imsi: str) -> None:
"""Remove a subscriber from the core network."""
@abstractmethod
def list_subscribers(self) -> list[dict]:
"""Return list of subscriber dicts from the core."""
```
---
## `server/core_adapters/open5gs.py`
Uses direct MongoDB insertion (same approach as Open5GS web UI).
```python
import logging
from pymongo import MongoClient
from .base import CoreAdapter
logger = logging.getLogger(__name__)
class Open5GSAdapter(CoreAdapter):
def __init__(self, mongodb_uri: str, database_name: str = "open5gs"):
self.client = MongoClient(mongodb_uri)
self.db = self.client[database_name]
def add_subscriber(self, imsi: str, ki: str, opc: str) -> None:
doc = {
"imsi": imsi,
"security": {
"k": ki,
"opc": opc,
"amf": "8000",
"sqn": 0
},
"ambr": {
"downlink": {"value": 1, "unit": 3},
"uplink": {"value": 1, "unit": 3}
},
"slice": [{
"sst": 1,
"default_indicator": True,
"session": [{
"name": "internet",
"type": 3,
"ambr": {
"downlink": {"value": 1, "unit": 3},
"uplink": {"value": 1, "unit": 3}
}
}]
}]
}
self.db.subscribers.replace_one({"imsi": imsi}, doc, upsert=True)
logger.info(f"Added subscriber {imsi} to Open5GS")
def remove_subscriber(self, imsi: str) -> None:
self.db.subscribers.delete_one({"imsi": imsi})
logger.info(f"Removed subscriber {imsi} from Open5GS")
def list_subscribers(self) -> list[dict]:
return list(self.db.subscribers.find({}, {"_id": 0}))
```
---
## `server/core_adapters/free5gc.py`
Stub — same document format, different DB name. Implement fully when free5gc is available.
```python
from .open5gs import Open5GSAdapter
class Free5GCAdapter(Open5GSAdapter):
def __init__(self, mongodb_uri: str, database_name: str = "free5gc"):
super().__init__(mongodb_uri, database_name)
```
---
## `server/core_adapters/__init__.py`
Factory function:
```python
from .base import CoreAdapter
from .open5gs import Open5GSAdapter
from .free5gc import Free5GCAdapter
from typing import Optional
def get_adapter(config: dict) -> Optional[CoreAdapter]:
core_cfg = config.get("core", {})
core_type = core_cfg.get("type", "none")
uri = core_cfg.get("mongodb_uri", "mongodb://localhost:27017")
db_name = core_cfg.get("database_name", "open5gs")
if core_type == "open5gs":
return Open5GSAdapter(uri, db_name)
elif core_type == "free5gc":
return Free5GCAdapter(uri, db_name)
else:
return None # No core integration (export-only mode)
```
---
## Verification
```bash
# With no MongoDB running — verify import and factory
python -c "
from server.core_adapters import get_adapter
cfg = {'core': {'type': 'none'}}
adapter = get_adapter(cfg)
assert adapter is None
print('Factory returns None for type=none: OK')
# Test Open5GS adapter instantiation (no connection attempt until query)
cfg2 = {'core': {'type': 'open5gs', 'mongodb_uri': 'mongodb://localhost:27017', 'database_name': 'open5gs'}}
adapter2 = get_adapter(cfg2)
print(f'Open5GS adapter: {adapter2.__class__.__name__}')
"
```

166
plan/05-flask-app.md Normal file
View File

@ -0,0 +1,166 @@
# Unit 05 — Flask Application
## Goal
Main Flask server wiring together the database, profile generator, utils, and core adapters.
## File
- `server/app.py`
---
## Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/` | none | 302 redirect → `/activate` |
| GET | `/activate` | none | Serve `portal/index.html` |
| POST | `/api/provision` | none | Generate + provision eSIM |
| GET | `/api/profile/<int:id>` | none | Download profile bytes |
| GET | `/api/export` | none | JSON export of all subscribers |
| GET | `/api/status` | none | Health check |
---
## `POST /api/provision` — Full Flow
Request body (JSON):
```json
{"mac_address": "aa:bb:cc:dd:ee:ff"}
```
Steps:
1. Parse JSON body; if `mac_address` missing/empty, resolve from server-side ARP (`/proc/net/arp` lookup by client IP)
2. `normalize_mac()` + `validate_mac()` → 400 if invalid
3. `check_rate_limit(conn, mac, window_hours)` → 429 if exceeded
4. `generate_imsi(mcc, mnc)`
5. `generate_ki()`
6. `generate_iccid()`
7. `derive_opc(ki, op_bytes)`
8. `create_esim_profile(...)``profile_bytes`
9. `add_subscriber(conn, imsi, iccid, ki_hex, opc_hex, mac)``sub_id`
10. Store `profile_bytes` in memory cache `{sub_id: bytes}` (or temp file)
11. If `core_adapter` configured: attempt `add_subscriber(imsi, ki_hex, opc_hex)`; log failure, don't abort
12. Return 200:
```json
{
"id": 1,
"imsi": "302720XXXXXXXXXX",
"iccid": "8910302720XXXXXXXXX",
"profile_url": "/api/profile/1"
}
```
Error responses:
- 400: `{"error": "Invalid MAC address"}`
- 429: `{"error": "Rate limit exceeded. One profile per device per hour."}`
- 500: `{"error": "Profile generation failed"}`
---
## `GET /api/profile/<id>` — Profile Download
- Look up subscriber by `id` in DB
- Retrieve profile bytes from in-memory cache (keyed by `sub_id`)
- 404 if not found or cache expired
- Response: `Content-Type: application/octet-stream`, `Content-Disposition: attachment; filename=esim-profile.bin`
---
## `GET /api/export` — Subscriber Export
Response:
```json
{
"exported_at": "2025-02-11T10:30:00Z",
"count": 123,
"subscribers": [
{
"imsi": "302720123456789",
"ki": "00112233445566778899aabbccddeeff",
"opc": "63bfa50ee6523365ff14c1f45f88737d",
"iccid": "8910302720123456789",
"mac_address": "aa:bb:cc:dd:ee:ff",
"created_at": "2025-02-11T09:15:00Z",
"synced_to_core": false
}
]
}
```
---
## `GET /api/status` — Health
```json
{
"status": "ok",
"subscriber_count": 42,
"uptime_seconds": 3600,
"core_adapter": "open5gs"
}
```
---
## Application Setup
```python
import os, logging
from flask import Flask, request, jsonify, send_file, redirect
from server.database import init_db, get_connection, ...
from server.profile_generator import ...
from server.utils import load_config, ...
from server.core_adapters import get_adapter
CONFIG_PATH = os.environ.get("CONFIG_PATH", "config.yaml")
def create_app(config_path=CONFIG_PATH):
app = Flask(__name__, static_folder="../portal", static_url_path="")
cfg = load_config(config_path)
db_path = cfg["database"]["path"]
init_db(db_path)
adapter = get_adapter(cfg)
profile_cache = {} # {sub_id: bytes}
start_time = time.time()
# register routes ...
return app
if __name__ == "__main__":
app = create_app()
app.run(host=cfg["server"]["host"], port=cfg["server"]["port"])
```
---
## MAC Address from ARP (server-side fallback)
When client doesn't send MAC:
```python
def get_mac_from_arp(ip: str) -> str | None:
try:
with open("/proc/net/arp") as f:
for line in f:
parts = line.split()
if len(parts) >= 4 and parts[0] == ip:
return parts[3]
except Exception:
pass
return None
```
---
## Verification
```bash
CONFIG_PATH=config.yaml python -m server.app &
sleep 1
curl -s http://localhost:5000/api/status | python -m json.tool
curl -s -X POST http://localhost:5000/api/provision \
-H "Content-Type: application/json" \
-d '{"mac_address":"aa:bb:cc:dd:ee:ff"}' | python -m json.tool
curl -s http://localhost:5000/api/export | python -m json.tool
kill %1
```

157
plan/06-captive-portal.md Normal file
View File

@ -0,0 +1,157 @@
# Unit 06 — Captive Portal Frontend
## Goal
Mobile-first single-page UI served at `/activate`. Pure HTML/CSS/JS — no external dependencies.
## Files
- `portal/index.html`
- `portal/css/style.css`
- `portal/js/script.js`
---
## UI States
```
[idle] → "Activate eSIM" button visible
[loading] → spinner, button disabled
[success] → green checkmark, download link, instructions
[error] → red message, "Try Again" button
```
---
## `portal/index.html`
Structure:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>eSIM Activation</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="card">
<div class="logo">📡</div>
<h1>Emergency 5G Network</h1>
<p class="subtitle">Activate your free eSIM to connect</p>
<!-- Idle state -->
<div id="state-idle">
<button id="btn-activate" class="btn-primary">Activate eSIM</button>
<p class="hint">Tap to generate your personal eSIM profile</p>
</div>
<!-- Loading state -->
<div id="state-loading" hidden>
<div class="spinner"></div>
<p>Generating your eSIM profile...</p>
</div>
<!-- Success state -->
<div id="state-success" hidden>
<div class="checkmark"></div>
<h2>eSIM Ready!</h2>
<p>Your profile has been generated.</p>
<a id="download-link" class="btn-primary" download="esim-profile.bin">
Download Profile
</a>
<div class="instructions">
<h3>Next Steps:</h3>
<ol>
<li>Download the profile above</li>
<li>Go to <strong>Settings → Mobile Data → Add eSIM</strong></li>
<li>Select "Import from file"</li>
<li>Disconnect from this WiFi</li>
<li>Your device will connect to 5G automatically</li>
</ol>
</div>
</div>
<!-- Error state -->
<div id="state-error" hidden>
<div class="error-icon"></div>
<h2>Activation Failed</h2>
<p id="error-message">An error occurred. Please try again.</p>
<button id="btn-retry" class="btn-secondary">Try Again</button>
</div>
</div>
<script src="/js/script.js"></script>
</body>
</html>
```
---
## `portal/css/style.css`
Key rules:
- `body`: `font-family: system-ui, sans-serif; background: #1a1a2e; color: #eee; display: flex; align-items: center; justify-content: center; min-height: 100vh;`
- `.card`: `background: #16213e; border-radius: 16px; padding: 2rem; max-width: 400px; width: 90%; text-align: center; box-shadow: 0 8px 32px rgba(0,0,0,0.4);`
- `.btn-primary`: `background: #0f3460; color: white; padding: 1rem 2rem; border-radius: 8px; font-size: 1.2rem; border: none; cursor: pointer; width: 100%; margin: 1rem 0;`
- `.btn-primary:hover`: `background: #e94560;`
- `.spinner`: CSS animation rotating border
- `.checkmark`: `color: #4caf50; font-size: 4rem;`
- `.error-icon`: `color: #f44336; font-size: 4rem;`
- `.instructions`: `text-align: left; background: #0f3460; border-radius: 8px; padding: 1rem; margin-top: 1rem;`
---
## `portal/js/script.js`
```javascript
const states = ['idle', 'loading', 'success', 'error'];
function showState(name) {
states.forEach(s => {
document.getElementById(`state-${s}`).hidden = (s !== name);
});
}
async function activate() {
showState('loading');
try {
const res = await fetch('/api/provision', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mac_address: '' }) // server resolves from ARP
});
if (res.status === 429) {
showError('You already have an active eSIM for this network.');
return;
}
if (!res.ok) {
const err = await res.json().catch(() => ({}));
showError(err.error || 'Server error. Please try again.');
return;
}
const data = await res.json();
document.getElementById('download-link').href = data.profile_url;
showState('success');
} catch (e) {
showError('Could not reach the server. Are you connected to the WiFi?');
}
}
function showError(msg) {
document.getElementById('error-message').textContent = msg;
showState('error');
}
document.getElementById('btn-activate').addEventListener('click', activate);
document.getElementById('btn-retry').addEventListener('click', () => {
showState('idle');
});
```
---
## Verification
```bash
# With Flask running
open http://localhost:5000/activate
# Verify page loads, button works, cycling through all states
# Check mobile responsiveness with browser DevTools device emulation
```

159
plan/07-scripts.md Normal file
View File

@ -0,0 +1,159 @@
# 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
```

208
plan/08-system-configs.md Normal file
View File

@ -0,0 +1,208 @@
# Unit 08 — System Configuration Files
## Goal
Production-ready nginx, dnsmasq, hostapd configs and systemd service units.
## Files
- `configs/nginx.conf`
- `configs/dnsmasq.conf`
- `configs/hostapd.conf`
- `systemd/esim-provisioning.service`
- `systemd/captive-portal.service`
---
## `configs/nginx.conf`
```nginx
# Captive portal + reverse proxy for eSIM provisioning server
server {
listen 80 default_server;
server_name _;
# Redirect any request not matching known paths to the activation portal
location = / {
return 302 http://192.168.4.1/activate;
}
# Serve the captive portal static files
location /activate {
alias /opt/esim-provisioning/portal/;
index index.html;
try_files $uri $uri/ /index.html;
}
location /css/ {
alias /opt/esim-provisioning/portal/css/;
}
location /js/ {
alias /opt/esim-provisioning/portal/js/;
}
# Proxy API calls to Flask backend
location /api/ {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 10s;
proxy_read_timeout 30s;
}
# Catch-all: redirect everything else to activate
location / {
return 302 http://192.168.4.1/activate;
}
}
```
---
## `configs/dnsmasq.conf`
```conf
# eSIM Provisioning - DNS/DHCP configuration
# Only listen on WiFi AP interface
interface=wlan0
bind-interfaces
# DHCP range
dhcp-range=192.168.4.10,192.168.4.250,24h
# DNS: return WiFi AP IP for ALL domains (captive portal hijacking)
address=/#/192.168.4.1
# Prevent upstream DNS resolution (keep it local)
no-resolv
# DHCP lease file
dhcp-leasefile=/var/lib/misc/dnsmasq.leases
# Log queries (useful for debugging)
log-queries
log-dhcp
```
---
## `configs/hostapd.conf`
```conf
# WiFi AP configuration for eSIM provisioning drop node
interface=wlan0
driver=nl80211
# Network identity
ssid=Emergency-5G
utf8_ssid=1
# Radio settings
hw_mode=g
channel=6
ieee80211n=1
wmm_enabled=1
# Open network (no password) — intentional for emergency access
auth_algs=1
ignore_broadcast_ssid=0
# Country code — adjust per deployment region
country_code=CA
# Max clients
max_num_sta=50
```
---
## `systemd/esim-provisioning.service`
```ini
[Unit]
Description=eSIM Provisioning Server
After=network.target
Wants=network.target
[Service]
Type=simple
User=esim
Group=esim
WorkingDirectory=/opt/esim-provisioning
Environment=CONFIG_PATH=/etc/esim-provisioning/config.yaml
ExecStart=/opt/esim-provisioning/venv/bin/python -m server.app
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=esim-provisioning
# Security hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/opt/esim-provisioning/data
[Install]
WantedBy=multi-user.target
```
---
## `systemd/captive-portal.service`
```ini
[Unit]
Description=Captive Portal (nginx + dnsmasq)
After=network.target hostapd.service
Wants=nginx.service dnsmasq.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/systemctl start nginx dnsmasq
ExecStop=/bin/systemctl stop nginx dnsmasq
[Install]
WantedBy=multi-user.target
```
---
## Deployment Notes
Install configs:
```bash
# nginx
sudo cp configs/nginx.conf /etc/nginx/sites-available/esim-provisioning
sudo ln -sf /etc/nginx/sites-available/esim-provisioning /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx
# dnsmasq
sudo cp configs/dnsmasq.conf /etc/dnsmasq.d/esim-provisioning.conf
sudo systemctl restart dnsmasq
# hostapd
sudo cp configs/hostapd.conf /etc/hostapd/hostapd.conf
sudo systemctl unmask hostapd
sudo systemctl enable --now hostapd
# systemd services
sudo cp systemd/esim-provisioning.service /etc/systemd/system/
sudo cp systemd/captive-portal.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now esim-provisioning captive-portal
```
## Verification
```bash
sudo nginx -t # Config syntax check
sudo systemctl status esim-provisioning
sudo systemctl status captive-portal
nslookup google.com 192.168.4.1 # Should return 192.168.4.1
curl http://192.168.4.1/api/status # Should return JSON status
```

155
plan/09-android.md Normal file
View File

@ -0,0 +1,155 @@
# Unit 09 — Android / Termux Deployment
## Goal
Automated setup script and instructions for running the provisioning stack on Android via Termux (no root required for basic operation).
## Files
- `android/README.md`
- `android/termux_setup.sh`
---
## Constraints & Differences from Raspberry Pi
| Feature | Raspberry Pi | Android (Termux) |
|---------|-------------|-----------------|
| WiFi AP | hostapd (full control) | Android Settings hotspot (limited) |
| DNS hijacking | dnsmasq full control | Limited without root |
| Port 80 | Direct | Need port >1024 or root; use 8080 |
| Captive portal trigger | iptables redirect | Relies on OS captive portal detection |
| Systemd | Yes | No — use Termux boot scripts |
---
## `android/termux_setup.sh`
```bash
#!/data/data/com.termux/files/usr/bin/bash
set -euo pipefail
echo "=== eSIM Provisioning Server — Termux Setup ==="
# Update Termux packages
pkg update -y && pkg upgrade -y
# Install core dependencies
pkg install -y python python-pip nginx
# Install Python dependencies
pip install flask cryptography pymongo pyyaml requests
# Create directory structure
INSTALL_DIR="$HOME/esim-provisioning"
mkdir -p "$INSTALL_DIR"/{server/core_adapters,portal/{css,js,assets},scripts,tests,data}
# Copy server code (assumes script run from repo root)
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cp -r "$SCRIPT_DIR/server" "$INSTALL_DIR/"
cp -r "$SCRIPT_DIR/portal" "$INSTALL_DIR/"
cp -r "$SCRIPT_DIR/scripts" "$INSTALL_DIR/"
cp "$SCRIPT_DIR/config.yaml.example" "$INSTALL_DIR/config.yaml"
# Configure for Android (port 8080, data path in home)
sed -i 's|port: 5000|port: 8080|' "$INSTALL_DIR/config.yaml"
sed -i "s|/opt/esim-provisioning/data|$INSTALL_DIR/data|" "$INSTALL_DIR/config.yaml"
# Configure nginx for Android (port 8080 as reverse proxy target)
cat > "$PREFIX/etc/nginx/nginx.conf" << 'EOF'
worker_processes 1;
events { worker_connections 64; }
http {
server {
listen 8080 default_server;
server_name _;
location /activate {
alias /data/data/com.termux/files/home/esim-provisioning/portal/;
index index.html;
}
location /css/ { alias /data/data/com.termux/files/home/esim-provisioning/portal/css/; }
location /js/ { alias /data/data/com.termux/files/home/esim-provisioning/portal/js/; }
location /api/ { proxy_pass http://127.0.0.1:5000; }
location / { return 302 http://192.168.43.1:8080/activate; }
}
}
EOF
# Initialize database
cd "$INSTALL_DIR"
CONFIG_PATH=config.yaml python scripts/init_db.py
# Create start script
cat > "$INSTALL_DIR/start.sh" << 'STARTEOF'
#!/data/data/com.termux/files/usr/bin/bash
cd ~/esim-provisioning
echo "Starting eSIM provisioning server..."
nginx
CONFIG_PATH=config.yaml python -m server.app &
echo "Server started. Access at http://192.168.43.1:8080/activate"
echo "To stop: pkill nginx && pkill python"
STARTEOF
chmod +x "$INSTALL_DIR/start.sh"
# Termux:Boot integration (auto-start on reboot)
mkdir -p "$HOME/.termux/boot"
cp "$INSTALL_DIR/start.sh" "$HOME/.termux/boot/esim-provisioning.sh"
echo ""
echo "=== Setup Complete ==="
echo ""
echo "Next steps:"
echo "1. Enable WiFi Hotspot in Android Settings"
echo " - SSID: Emergency-5G (or any name)"
echo " - No password"
echo "2. Run: ~/esim-provisioning/start.sh"
echo "3. Users connect to hotspot and visit http://192.168.43.1:8080/activate"
echo ""
echo "Note: Install Termux:Boot app for auto-start on reboot"
```
---
## `android/README.md`
### Prerequisites
- Android 9+
- Termux app (from F-Droid, NOT Play Store)
- Optional: Termux:Boot (for auto-start)
- 5G-capable device with pre-provisioned SIM for gNB backhaul
### Installation
```bash
# In Termux:
cd /path/to/esim-provisioning
bash android/termux_setup.sh
```
### Starting the Server
```bash
~/esim-provisioning/start.sh
```
### WiFi Hotspot Setup
1. Android Settings → Network → Hotspot
2. Set name to `Emergency-5G` (optional, any name works)
3. Remove password (open network)
4. Enable hotspot
### Captive Portal Limitation
Android's captive portal detection requires DNS manipulation. Without root, clients must navigate manually to `http://192.168.43.1:8080/activate`. With root + Termux, dnsmasq can be used for full DNS hijacking.
### Known Android Hotspot IPs
- Most Android: `192.168.43.1`
- Samsung: `192.168.0.1` or `192.168.43.1`
- Pixel: `192.168.43.1`
Update `config.yaml` and nginx config with your device's hotspot IP.
---
## Verification
```bash
# On Android in Termux:
~/esim-provisioning/start.sh
curl http://127.0.0.1:5000/api/status
curl http://127.0.0.1:8080/activate # through nginx
```

248
plan/10-testing.md Normal file
View File

@ -0,0 +1,248 @@
# Unit 10 — Testing
## Goal
Unit tests covering the database layer, profile generator, and core adapters. Run with `pytest`.
## Files
- `tests/test_database.py`
- `tests/test_profile_generator.py`
- `tests/test_core_adapters.py`
---
## `tests/test_database.py`
```python
import pytest, tempfile, os
from datetime import datetime, timedelta
from unittest.mock import patch
from server.database import init_db, get_connection, add_subscriber, \
get_subscriber_by_imsi, get_subscriber_by_id, check_rate_limit, \
list_unsynced, mark_synced, export_all
@pytest.fixture
def db_conn(tmp_path):
db_path = str(tmp_path / "test.db")
init_db(db_path)
conn = get_connection(db_path)
yield conn
conn.close()
def sub_data(**kwargs):
defaults = dict(imsi="302720000000001", iccid="8910302720000000001",
ki="aa" * 16, opc="bb" * 16, mac_address="aa:bb:cc:dd:ee:ff")
defaults.update(kwargs)
return defaults
class TestCRUD:
def test_add_and_retrieve_by_imsi(self, db_conn):
d = sub_data()
sid = add_subscriber(db_conn, **d)
sub = get_subscriber_by_imsi(db_conn, d["imsi"])
assert sub is not None
assert sub.imsi == d["imsi"]
assert sub.id == sid
def test_add_and_retrieve_by_id(self, db_conn):
d = sub_data()
sid = add_subscriber(db_conn, **d)
sub = get_subscriber_by_id(db_conn, sid)
assert sub.iccid == d["iccid"]
def test_duplicate_imsi_raises(self, db_conn):
add_subscriber(db_conn, **sub_data())
with pytest.raises(ValueError):
add_subscriber(db_conn, **sub_data())
def test_not_found_returns_none(self, db_conn):
assert get_subscriber_by_imsi(db_conn, "999999999999999") is None
assert get_subscriber_by_id(db_conn, 99999) is None
class TestRateLimiting:
def test_first_request_allowed(self, db_conn):
assert check_rate_limit(db_conn, "aa:bb:cc:dd:ee:ff", 1) == True
def test_second_request_blocked(self, db_conn):
add_subscriber(db_conn, **sub_data())
assert check_rate_limit(db_conn, "aa:bb:cc:dd:ee:ff", 1) == False
def test_different_mac_allowed(self, db_conn):
add_subscriber(db_conn, **sub_data())
assert check_rate_limit(db_conn, "11:22:33:44:55:66", 1) == True
def test_expired_window_allows_again(self, db_conn):
# Insert a subscriber with a timestamp 2 hours in the past
add_subscriber(db_conn, **sub_data())
past = (datetime.utcnow() - timedelta(hours=2)).isoformat()
db_conn.execute("UPDATE subscribers SET created_at = ?", (past,))
db_conn.commit()
assert check_rate_limit(db_conn, "aa:bb:cc:dd:ee:ff", 1) == True
class TestSync:
def test_list_unsynced(self, db_conn):
add_subscriber(db_conn, **sub_data())
assert len(list_unsynced(db_conn)) == 1
def test_mark_synced(self, db_conn):
add_subscriber(db_conn, **sub_data())
mark_synced(db_conn, sub_data()["imsi"])
assert len(list_unsynced(db_conn)) == 0
class TestExport:
def test_export_returns_list(self, db_conn):
add_subscriber(db_conn, **sub_data())
rows = export_all(db_conn)
assert len(rows) == 1
assert "imsi" in rows[0]
assert "ki" in rows[0]
```
---
## `tests/test_profile_generator.py`
```python
import pytest
from server.profile_generator import (
generate_imsi, generate_ki, generate_iccid, derive_opc, create_esim_profile
)
class TestIMSI:
def test_length(self):
assert len(generate_imsi("302", "720")) == 15
def test_prefix(self):
imsi = generate_imsi("302", "720")
assert imsi.startswith("302720")
def test_digits_only(self):
assert generate_imsi("302", "720").isdigit()
def test_uniqueness(self):
imsis = {generate_imsi("302", "720") for _ in range(100)}
assert len(imsis) > 90 # Very unlikely to get 10+ collisions
class TestKi:
def test_length(self):
assert len(generate_ki()) == 16
def test_randomness(self):
keys = {generate_ki() for _ in range(50)}
assert len(keys) == 50
class TestICCID:
def test_length(self):
assert len(generate_iccid()) == 19
def test_digits_only(self):
assert generate_iccid().isdigit()
def test_luhn_valid(self):
iccid = generate_iccid()
# Verify last digit is valid Luhn check digit
from server.profile_generator import luhn_checksum
assert luhn_checksum(iccid[:-1]) == int(iccid[-1])
class TestOPc:
# Test vector from 3GPP TS 35.208
KI_HEX = "000102030405060708090a0b0c0d0e0f"
OP_HEX = "63bfa50ee6523365ff14c1f45f88737d"
def test_output_length(self):
ki = bytes.fromhex(self.KI_HEX)
op = bytes.fromhex(self.OP_HEX)
opc = derive_opc(ki, op)
assert len(opc) == 16
def test_deterministic(self):
ki = bytes.fromhex(self.KI_HEX)
op = bytes.fromhex(self.OP_HEX)
assert derive_opc(ki, op) == derive_opc(ki, op)
def test_known_vector(self):
# Pre-computed: AES_Ki(OP) XOR OP
ki = bytes.fromhex(self.KI_HEX)
op = bytes.fromhex(self.OP_HEX)
opc = derive_opc(ki, op)
# Verify it's not trivially wrong
assert opc != op
assert opc != ki
class TestProfile:
def test_returns_bytes(self):
ki = generate_ki()
op = bytes.fromhex("63bfa50ee6523365ff14c1f45f88737d")
opc = derive_opc(ki, op)
imsi = generate_imsi("302", "720")
iccid = generate_iccid()
profile = create_esim_profile(imsi, ki, opc, iccid, "Test", "Test Carrier")
assert isinstance(profile, bytes)
assert len(profile) > 0
```
---
## `tests/test_core_adapters.py`
```python
import pytest
from unittest.mock import MagicMock, patch
from server.core_adapters import get_adapter
from server.core_adapters.open5gs import Open5GSAdapter
class TestFactory:
def test_none_type(self):
cfg = {"core": {"type": "none"}}
assert get_adapter(cfg) is None
def test_open5gs_type(self):
cfg = {"core": {"type": "open5gs", "mongodb_uri": "mongodb://localhost:27017", "database_name": "open5gs"}}
with patch("server.core_adapters.open5gs.MongoClient"):
adapter = get_adapter(cfg)
assert isinstance(adapter, Open5GSAdapter)
class TestOpen5GSAdapter:
@pytest.fixture
def adapter(self):
with patch("server.core_adapters.open5gs.MongoClient") as mock_client:
a = Open5GSAdapter("mongodb://localhost:27017", "open5gs")
a.db = MagicMock()
yield a
def test_add_subscriber_calls_replace_one(self, adapter):
adapter.add_subscriber("302720000000001", "aa" * 16, "bb" * 16)
adapter.db.subscribers.replace_one.assert_called_once()
call_args = adapter.db.subscribers.replace_one.call_args
doc = call_args[0][1]
assert doc["imsi"] == "302720000000001"
assert "security" in doc
assert doc["security"]["k"] == "aa" * 16
assert "slice" in doc
assert len(doc["slice"]) == 1
def test_remove_subscriber(self, adapter):
adapter.remove_subscriber("302720000000001")
adapter.db.subscribers.delete_one.assert_called_once_with({"imsi": "302720000000001"})
def test_list_subscribers(self, adapter):
adapter.db.subscribers.find.return_value = [{"imsi": "302720000000001"}]
result = adapter.list_subscribers()
assert len(result) == 1
```
---
## Running Tests
```bash
# All tests
pytest tests/ -v
# With coverage
pytest tests/ -v --cov=server --cov-report=term-missing
# Single file
pytest tests/test_profile_generator.py -v
```
Expected output: all green, ~30 tests.

160
plan/11-readme.md Normal file
View File

@ -0,0 +1,160 @@
# Unit 11 — README.md
## Goal
Write the main `README.md` at the project root. This is the last unit — written after all code exists.
## File
- `README.md`
---
## Required Sections
### Header
- Project name + one-line description
- Badges (optional): Python version, license
### Architecture Diagram
Copy from `design.md`:
```
┌─────────────────────────────────────────┐
│ Server Host (srsRAN gNB) │
│ - srsRAN gNB │
│ - Open5GS Core (or free5gc) │
│ - Subscriber database │
└─────────────┬───────────────────────────┘
│ 5G NR
┌─────────────────────────────────────────┐
│ UE WiFi AP Drop │
│ (Android device or Raspberry Pi) │
│ - WiFi AP (open network) │
│ - eSIM Provisioning Server │
│ - Captive Portal │
└─────────────┬───────────────────────────┘
│ WiFi
End User Devices
```
### User Flow (numbered list)
1. User connects to open WiFi `Emergency-5G`
2. Captive portal auto-redirects to activation page
3. User taps "Activate eSIM"
4. Server generates IMSI/Ki/OPc/ICCID
5. eSIM profile downloaded to device
6. User installs profile via Settings
7. Device connects to 5G network automatically
### System Requirements
**Raspberry Pi:**
- Raspberry Pi 4 (4GB+ RAM)
- Quectel RM500Q 5G modem (or compatible)
- Raspberry Pi OS Lite 64-bit
- Python 3.11+
**Android:**
- Android 9+ with Termux (F-Droid)
- 5G-capable device
- Pre-provisioned SIM for gNB backhaul
**End-user devices:**
- eSIM-capable device (eUICC support)
- Any OS (Android, iOS, Windows)
### Quick Start (5 commands)
```bash
git clone <repo>
cd esim-provisioning
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
cp config.yaml.example config.yaml # edit as needed
python scripts/init_db.py
python -m server.app
# → Server running at http://localhost:5000
```
### Configuration Guide
Key `config.yaml` fields:
| Field | Description | Example |
|-------|-------------|---------|
| `network.mcc` | Mobile Country Code | `"302"` |
| `network.mnc` | Mobile Network Code | `"720"` |
| `network.op_key` | Operator key (hex, 16 bytes) | `"63bfa..."` |
| `wifi.ip` | WiFi AP IP address | `"192.168.4.1"` |
| `database.path` | SQLite database path | `"/opt/.../subscribers.db"` |
| `rate_limiting.window_hours` | Rate limit window | `1` |
| `core.type` | Core adapter | `"open5gs"` / `"none"` |
| `core.mongodb_uri` | MongoDB connection | `"mongodb://localhost:27017"` |
### Raspberry Pi Installation
Reference `scripts/setup_wifi_ap.sh` for automated setup, or follow manual steps from `configs/` directory.
### Android / Termux Installation
```bash
bash android/termux_setup.sh
~/esim-provisioning/start.sh
```
See `android/README.md` for detailed instructions and limitations.
### API Reference
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/activate` | GET | Captive portal page |
| `/api/provision` | POST | Generate eSIM profile |
| `/api/profile/<id>` | GET | Download profile |
| `/api/export` | GET | Export all subscribers (JSON) |
| `/api/status` | GET | Health check |
### Troubleshooting
**Profile generation fails:**
```
Check: python -c "from server.profile_generator import generate_ki; print(generate_ki().hex())"
Check: OP key is 32 hex chars (16 bytes) in config.yaml
```
**Captive portal not showing:**
```
Check: nslookup google.com 192.168.4.1 → should return 192.168.4.1
Check: systemctl status dnsmasq nginx
Check: iptables -t nat -L PREROUTING
```
**5G authentication fails after eSIM install:**
```
Check: IMSI/Ki/OPc in subscriber database matches what's in Open5GS
Check: PLMN (MCC+MNC) matches gNB configuration
Check: python scripts/sync_subscribers.py
Check: Open5GS UDM/AMF logs: journalctl -u open5gs-udmd
```
**Rate limit blocking legitimate user:**
```sql
-- Find and clear rate limit for a MAC:
sqlite3 /path/to/subscribers.db \
"DELETE FROM subscribers WHERE mac_address='aa:bb:cc:dd:ee:ff';"
```
### Architecture Notes
- **Adapter pattern**: Adding a new core (e.g., srsRAN-CN) requires only a new class in `server/core_adapters/`
- **IMSI space**: 10^10 possible MSINs per MCC+MNC — effectively unlimited for field use
- **Rate limiting**: Per MAC address, 1 profile per hour window. SQLite-backed, survives restarts
- **Profile format**: Phase 1 mock (JSON bytes). Phase 2: real SGP.22 via pySim for eUICC compatibility
### License
MIT (or as appropriate for deployment context)
---
## Writing Notes
When writing the actual README.md:
- Keep it scannable — use tables, code blocks, headers
- Lead with the architecture diagram
- Quick Start must work in <5 minutes on a fresh Pi
- Troubleshooting section is critical — include it prominently
- Do not duplicate design.md — link to it for deep dives

496
plan/design.md Normal file
View File

@ -0,0 +1,496 @@
# Closed-Loop eSIM Provisioning System for Emergency/NTN 5G Deployment
## Overview
Self-contained eSIM provisioning system for ad-hoc 5G network deployment in emergency or NTN scenarios. Users connect to WiFi AP, automatically receive eSIM profile, then connect to 5G network.
## System Architecture
```
┌─────────────────────────────────────────┐
│ Server Host (srsRAN gNB) │
│ - srsRAN gNB │
│ - Open5GS Core (or free5gc) │
│ - Subscriber database │
└─────────────┬───────────────────────────┘
│ 5G NR
┌─────────────────────────────────────────┐
│ UE WiFi AP Drop │
│ (Android device or Raspberry Pi) │
│ │
│ ┌───────────────────────────────────┐ │
│ │ 5G Modem (pre-provisioned SIM) │ │
│ │ Connects to gNB for backhaul │ │
│ └───────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────┐ │
│ │ WiFi AP (open network) │ │
│ └───────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────┐ │
│ │ eSIM Provisioning Server │ │
│ │ - Profile generator │ │
│ │ - Captive portal │ │
│ │ - Credential database │ │
│ └───────────────────────────────────┘ │
└─────────────────────────────────────────┘
│ WiFi
End User Devices
```
## User Flow
1. User device detects open WiFi network
2. Connects to WiFi → Auto-redirected to captive portal
3. Captive portal displays "Activate eSIM" button
4. Click → Server generates unique IMSI/Ki/OPc credentials
5. Server generates SGP.22 eSIM profile with credentials
6. Profile delivered to device via LPA protocol
7. Device installs eSIM profile
8. User disconnects from WiFi
9. Device connects to 5G network using new eSIM
10. Open5GS/free5gc authenticates user
## Components
### 1. UE WiFi AP Drop Device
**Hardware Options:**
- **Primary**: Android phone (cheapest if app is simple)
- **Fallback**: Raspberry Pi 4 + Quectel RM500Q 5G modem
**Requirements:**
- Pre-provisioned "admin" SIM to connect to gNB
- WiFi hotspot capability
- Runs provisioning server stack
**Software Stack:**
- Cellular connection to gNB (uses pre-provisioned SIM)
- WiFi AP (hostapd or Android hotspot)
- Captive portal (dnsmasq + nginx)
- eSIM provisioning server (Python)
### 2. eSIM Provisioning Server
**Technology:** Python + Flask
**Core Functions:**
- Generate IMSI/Ki/OPc credentials on-demand
- Create SGP.22-compliant eSIM profiles using pySim
- Serve profiles via LPA protocol
- Store generated credentials in SQLite
- Rate limiting (1 profile per MAC address per hour)
- Export credentials for core sync
**Dependencies:**
- `pySim` - SIM/eSIM profile generation
- `Flask` - Web server
- `sqlite3` - Local credential storage
- `cryptography` - Key generation
**Endpoints:**
```
GET / - Captive portal landing page
POST /activate - Trigger eSIM generation
GET /profile/:id - Download eSIM profile (LPA)
GET /export - Export credentials for core sync
```
**Profile Generation Logic:**
```python
# Generate credentials
IMSI = generate_imsi(mcc=302, mnc=720) # Adjust for your network
Ki = random_bytes(16)
OPc = derive_opc(Ki, OP)
# Create eSIM profile
profile = create_esim_profile(
iccid=generate_iccid(),
imsi=IMSI,
ki=Ki,
opc=OPc,
profile_name="Emergency 5G"
)
# Store in database
store_subscriber(imsi, ki, opc, timestamp, mac_address)
# Return profile for LPA download
return profile
```
### 3. Captive Portal
**Technology:** HTML + JavaScript + nginx
**Features:**
- Auto-redirect any HTTP request to portal
- Single-button interface: "Activate eSIM"
- Instructions for eSIM installation
- Minimal UI, works on any device
**DNS Hijacking:**
```
# dnsmasq configuration
address=/#/192.168.4.1
```
**nginx Configuration:**
```nginx
server {
listen 80 default_server;
server_name _;
location / {
return 302 http://192.168.4.1/activate;
}
location /activate {
root /var/www/captive;
index index.html;
}
location /api {
proxy_pass http://127.0.0.1:5000;
}
}
```
### 4. Core Integration Module
**Design:** Adapter pattern for portability
**Supported Cores (initially):**
- Open5GS
- free5gc
**Interface:**
```python
class CoreAdapter:
def add_subscriber(self, imsi, ki, opc):
"""Add subscriber to core network"""
pass
def remove_subscriber(self, imsi):
"""Remove subscriber from core network"""
pass
def list_subscribers(self):
"""List all subscribers"""
pass
```
**Open5GS Implementation:**
```python
class Open5GSAdapter(CoreAdapter):
def add_subscriber(self, imsi, ki, opc):
# Direct MongoDB insertion
db.subscribers.insert_one({
"imsi": imsi,
"security": {
"k": ki,
"opc": opc,
"amf": "8000",
"sqn": 0
},
"ambr": {
"downlink": {"value": 1, "unit": 3}, # 1 Gbps
"uplink": {"value": 1, "unit": 3}
},
"slice": [{
"sst": 1,
"default_indicator": True,
"session": [{
"name": "internet",
"type": 3, # IPv4v6
"ambr": {
"downlink": {"value": 1, "unit": 3},
"uplink": {"value": 1, "unit": 3}
}
}]
}]
})
```
**Sync Options:**
1. **Push**: Provisioning server calls core adapter immediately
2. **Pull**: Core polls provisioning server API periodically
3. **Manual**: Export JSON, manually import to core
**Initial Implementation:** Export JSON format
```json
{
"subscribers": [
{
"imsi": "302720123456789",
"ki": "00112233445566778899aabbccddeeff",
"opc": "63bfa50ee6523365ff14c1f45f88737d",
"timestamp": "2025-02-11T10:30:00Z",
"mac_address": "aa:bb:cc:dd:ee:ff"
}
]
}
```
### 5. eSIM Profile Format
**Standard:** SGP.22 compliant eSIM profile
**Profile Contents:**
- ICCID (SIM card identifier)
- IMSI (subscriber identity)
- Ki (authentication key)
- OPc (derived operator key)
- Profile metadata (name, description)
- APN configuration (if needed)
**Generation Method:**
Use pySim's eUICC profile generator:
```python
from pySim.esim import EsimProfile
profile = EsimProfile(
iccid=iccid,
imsi=imsi,
ki=ki,
opc=opc,
profile_name="Emergency 5G",
carrier_name="Ad-Hoc Network"
)
profile_bytes = profile.encode()
```
**Fallback:** If SGP.22 generation fails, serve simplified profile (may have compatibility issues with some devices)
## Implementation Phases
### Phase 1: Core Server Components
- [ ] eSIM provisioning server (Python/Flask)
- [ ] Credential generation and storage
- [ ] Profile generation using pySim
- [ ] LPA protocol implementation
- [ ] SQLite database schema
### Phase 2: Captive Portal
- [ ] HTML/JS frontend
- [ ] nginx configuration
- [ ] DNS hijacking setup
- [ ] Auto-redirect logic
### Phase 3: Core Integration
- [ ] Open5GS adapter
- [ ] JSON export format
- [ ] Sync script
### Phase 4: UE WiFi AP Drop
- [ ] Android app (if feasible)
- [ ] OR Raspberry Pi setup scripts
- [ ] Auto-connection to gNB
- [ ] WiFi AP configuration
- [ ] Service orchestration
### Phase 5: Testing & Hardening
- [ ] End-to-end flow testing
- [ ] Rate limiting verification
- [ ] Profile compatibility testing
- [ ] Network isolation verification
## Deployment Instructions
### Prerequisites
- srsRAN gNB running and configured
- Open5GS core running (or free5gc)
- One pre-provisioned "admin" SIM for backhaul UE
- UE device (Android phone or Raspberry Pi)
### UE WiFi AP Drop Setup
**If Android:**
1. Install Termux
2. Install required packages (Python, hostapd, dnsmasq, nginx)
3. Deploy provisioning server
4. Configure WiFi hotspot
5. Start services
**If Raspberry Pi:**
1. Install Raspberry Pi OS Lite
2. Install Quectel RM500Q modem drivers
3. Configure modem for gNB connection
4. Install provisioning server stack
5. Configure hostapd + dnsmasq
6. Start services
### Provisioning Server Setup
```bash
# Install dependencies
pip install flask pySim cryptography
# Create directory structure
mkdir -p /opt/esim-provisioning/{server,portal,data}
# Deploy server code
cp provisioning_server.py /opt/esim-provisioning/server/
cp -r templates/ /opt/esim-provisioning/server/
# Deploy captive portal
cp -r portal/* /opt/esim-provisioning/portal/
# Initialize database
python init_db.py
# Start server
systemctl start esim-provisioning
```
### Core Integration
```bash
# Export subscribers from provisioning server
curl http://192.168.4.1:5000/export > subscribers.json
# Import to Open5GS
python open5gs_import.py subscribers.json
# OR for free5gc
python free5gc_import.py subscribers.json
```
## Configuration
### Network Parameters
```python
# config.py
MCC = "302" # Canada (adjust for your region)
MNC = "720" # Adjust for your network
PLMN = f"{MCC}{MNC}"
# IMSI format: MCC + MNC + MSIN
# Example: 302720123456789
IMSI_PREFIX = PLMN
MSIN_LENGTH = 10
# Operator key (OP) - used to derive OPc
OP = bytes.fromhex("63bfa50ee6523365ff14c1f45f88737d")
# WiFi AP configuration
WIFI_SSID = "Emergency-5G"
WIFI_CHANNEL = 6
WIFI_IP = "192.168.4.1"
WIFI_SUBNET = "192.168.4.0/24"
WIFI_DHCP_RANGE = "192.168.4.10,192.168.4.250"
# Rate limiting
MAX_PROFILES_PER_MAC = 1
RATE_LIMIT_HOURS = 1
```
### Open5GS Connection
```python
# open5gs_config.py
MONGODB_URI = "mongodb://localhost:27017"
DATABASE_NAME = "open5gs"
COLLECTION_NAME = "subscribers"
```
## Security Considerations
### Current Implementation (Phase 1)
- Open WiFi network (no authentication)
- Rate limiting by MAC address
- No encryption on provisioning server traffic (local network only)
### Future Enhancements
- Optional WiFi password (distribute via physical signage)
- Pre-qualified activation codes
- HTTPS for provisioning server
- Profile encryption
- Audit logging
## Scalability
### On-Demand Generation
- Generates credentials only when requested
- No pre-generation overhead
- Limited only by:
- IMSI space (10^10 possible MSINs with 10-digit MSIN)
- Storage (SQLite can handle millions of records)
- Processing power (generates ~10 profiles/second on modest hardware)
### Multi-Core Session Handover
**Design Consideration:** Profile format supports core mobility
Current design doesn't preclude future session handover between cores:
- IMSI/Ki/OPc are standardized, portable between cores
- Export format is core-agnostic JSON
- Core adapters can be extended to support handover signaling
**Future Implementation:**
- Add `home_core_id` field to subscriber record
- Export includes core affinity metadata
- Handover protocol exchanges subscriber credentials between cores
## Troubleshooting
### Profile Generation Fails
- Check pySim installation: `python -c "import pySim"`
- Verify OP key is 16 bytes
- Check IMSI format (15 digits total)
### Device Won't Download Profile
- Verify device supports eSIM (eUICC)
- Check LPA protocol implementation
- Verify profile format is SGP.22 compliant
- Try simplified profile format
### Can't Connect to 5G After eSIM Install
- Verify subscriber added to Open5GS/free5gc
- Check IMSI/Ki/OPc match between profile and core
- Verify PLMN configuration matches gNB
- Check AMF/UDM logs for authentication failures
### WiFi Captive Portal Not Triggering
- Verify DNS hijacking: `nslookup google.com 192.168.4.1`
- Check nginx is running: `systemctl status nginx`
- Verify iptables rules for captive portal
## File Structure
```
/opt/esim-provisioning/
├── server/
│ ├── provisioning_server.py # Main Flask application
│ ├── profile_generator.py # eSIM profile generation
│ ├── database.py # SQLite interface
│ ├── core_adapter.py # Core integration adapters
│ └── config.py # Configuration
├── portal/
│ ├── index.html # Captive portal page
│ ├── style.css
│ └── script.js
├── data/
│ ├── subscribers.db # SQLite database
│ └── profiles/ # Generated profiles (temp)
├── scripts/
│ ├── sync_open5gs.py # Open5GS sync script
│ ├── sync_free5gc.py # free5gc sync script
│ └── export_subscribers.py # Export to JSON
└── systemd/
├── esim-provisioning.service # Systemd unit file
└── captive-portal.service # Captive portal service
```
## Next Steps
1. Implement provisioning server (Python/Flask)
2. Implement profile generator using pySim
3. Create captive portal frontend
4. Build Open5GS adapter
5. Test end-to-end flow
6. Deploy to UE WiFi AP drop device
## References
- SGP.22 eSIM Specification: https://www.gsma.com/esim/
- pySim Documentation: https://github.com/osmocom/pysim
- Open5GS Documentation: https://open5gs.org/
- srsRAN Documentation: https://docs.srsran.com/

167
portal/css/style.css Normal file
View File

@ -0,0 +1,167 @@
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background: #1a1a2e;
color: #e0e0e0;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.card {
background: #16213e;
border-radius: 16px;
padding: 2.5rem 2rem;
max-width: 420px;
width: 100%;
text-align: center;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.logo {
font-size: 3.5rem;
margin-bottom: 0.5rem;
}
h1 {
font-size: 1.5rem;
font-weight: 700;
color: #ffffff;
margin-bottom: 0.5rem;
}
.subtitle {
font-size: 0.95rem;
color: #9ca3af;
margin-bottom: 2rem;
line-height: 1.5;
}
/* ---- Buttons ---- */
.btn-primary,
.btn-secondary {
display: block;
width: 100%;
padding: 1rem;
border-radius: 10px;
font-size: 1.1rem;
font-weight: 600;
border: none;
cursor: pointer;
margin: 1rem 0;
text-decoration: none;
transition: background 0.2s, transform 0.1s;
}
.btn-primary {
background: #0f3460;
color: #ffffff;
}
.btn-primary:hover {
background: #e94560;
transform: translateY(-1px);
}
.btn-primary:active {
transform: translateY(0);
}
.btn-secondary {
background: transparent;
color: #9ca3af;
border: 1px solid #374151;
}
.btn-secondary:hover {
background: #374151;
color: #ffffff;
}
.hint {
font-size: 0.8rem;
color: #6b7280;
margin-top: -0.5rem;
}
/* ---- Spinner ---- */
.spinner {
width: 48px;
height: 48px;
border: 4px solid #374151;
border-top-color: #e94560;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 1rem auto;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ---- Status icons ---- */
.icon-success {
font-size: 4rem;
color: #22c55e;
margin: 0.5rem 0;
}
.icon-error {
font-size: 4rem;
color: #ef4444;
margin: 0.5rem 0;
}
h2 {
font-size: 1.3rem;
color: #ffffff;
margin: 0.5rem 0;
}
/* ---- Instructions ---- */
.instructions {
text-align: left;
background: #0f3460;
border-radius: 10px;
padding: 1.25rem 1.5rem;
margin-top: 1.5rem;
}
.instructions h3 {
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #9ca3af;
margin-bottom: 0.75rem;
}
.instructions ol {
padding-left: 1.25rem;
}
.instructions li {
font-size: 0.9rem;
line-height: 1.6;
color: #d1d5db;
}
.instructions li + li {
margin-top: 0.4rem;
}
#error-message {
color: #fca5a5;
margin: 0.5rem 0 1rem;
font-size: 0.95rem;
}

58
portal/index.html Normal file
View File

@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Emergency 5G — eSIM Activation</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="card">
<div class="logo">📡</div>
<h1>Emergency 5G Network</h1>
<p class="subtitle">Activate your free eSIM to connect to the 5G network</p>
<!-- Idle state -->
<div id="state-idle">
<button id="btn-activate" class="btn-primary">Activate eSIM</button>
<p class="hint">Tap to generate your personal eSIM profile</p>
</div>
<!-- Loading state -->
<div id="state-loading" hidden>
<div class="spinner"></div>
<p>Generating your eSIM profile&hellip;</p>
</div>
<!-- Success state -->
<div id="state-success" hidden>
<div class="icon-success"></div>
<h2>eSIM Ready!</h2>
<p>Your profile has been generated successfully.</p>
<a id="download-link" class="btn-primary" download="esim-profile.bin">
Download Profile
</a>
<div class="instructions">
<h3>Next Steps</h3>
<ol>
<li>Tap <strong>Download Profile</strong> above</li>
<li>Go to <strong>Settings → Mobile Data → Add eSIM</strong></li>
<li>Choose <em>Import from file</em> and select the downloaded file</li>
<li>Disconnect from this WiFi</li>
<li>Your device will connect to 5G automatically</li>
</ol>
</div>
</div>
<!-- Error state -->
<div id="state-error" hidden>
<div class="icon-error"></div>
<h2>Activation Failed</h2>
<p id="error-message">An error occurred. Please try again.</p>
<button id="btn-retry" class="btn-secondary">Try Again</button>
</div>
</div>
<script src="/js/script.js"></script>
</body>
</html>

70
portal/js/script.js Normal file
View File

@ -0,0 +1,70 @@
'use strict';
const STATES = ['idle', 'loading', 'success', 'error'];
function showState(name) {
STATES.forEach(function(s) {
var el = document.getElementById('state-' + s);
if (el) el.hidden = (s !== name);
});
}
function showError(msg) {
var el = document.getElementById('error-message');
if (el) el.textContent = msg;
showState('error');
}
var inFlight = false;
async function activate() {
if (inFlight) return; // ignore double-clicks while a request is pending
inFlight = true;
var btn = document.getElementById('btn-activate');
if (btn) btn.disabled = true;
showState('loading');
try {
var res = await fetch('/api/provision', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// MAC address left empty — server resolves it via ARP from client IP
body: JSON.stringify({ mac_address: '' }),
});
var data;
try { data = await res.json(); } catch (_) { data = {}; }
if (res.status === 429) {
showError(data.error || 'You already have an eSIM for this network. Only one per device per hour.');
return;
}
if (!res.ok) {
showError(data.error || 'Server error (' + res.status + '). Please try again.');
return;
}
// Wire up download link
var link = document.getElementById('download-link');
if (link && data.profile_url) {
link.href = data.profile_url;
}
showState('success');
} catch (err) {
showError('Could not reach the server. Make sure you are connected to the WiFi network.');
} finally {
inFlight = false;
if (btn) btn.disabled = false;
}
}
document.addEventListener('DOMContentLoaded', function() {
var btnActivate = document.getElementById('btn-activate');
var btnRetry = document.getElementById('btn-retry');
if (btnActivate) btnActivate.addEventListener('click', activate);
if (btnRetry) btnRetry.addEventListener('click', function() { showState('idle'); });
});

7
requirements.txt Normal file
View File

@ -0,0 +1,7 @@
Flask==3.0.0
cryptography==41.0.7
pymongo==4.6.0
requests==2.31.0
pyyaml==6.0.1
pytest==7.4.0
pytest-cov==4.1.0

View File

@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""Export all subscribers to JSON on stdout."""
import json
import os
import sys
from datetime import datetime, timezone
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from server.database import export_all, get_connection
from server.utils import load_config
def main():
config_path = os.environ.get("CONFIG_PATH", "config.yaml")
cfg = load_config(config_path)
conn = get_connection(cfg["database"]["path"])
try:
subscribers = export_all(conn)
finally:
conn.close()
output = {
"exported_at": datetime.now(timezone.utc).isoformat(),
"count": len(subscribers),
"subscribers": subscribers,
}
print(json.dumps(output, indent=2, default=str))
if __name__ == "__main__":
main()

22
scripts/init_db.py Normal file
View File

@ -0,0 +1,22 @@
#!/usr/bin/env python3
"""Initialize the SQLite subscriber database."""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from server.database import init_db
from server.utils import ensure_parent_dir, load_config
def main():
config_path = os.environ.get("CONFIG_PATH", "config.yaml")
cfg = load_config(config_path)
db_path = cfg["database"]["path"]
ensure_parent_dir(db_path)
init_db(db_path)
print(f"Database initialized at {db_path}")
if __name__ == "__main__":
main()

178
scripts/setup_wifi_ap.sh Executable file
View File

@ -0,0 +1,178 @@
#!/bin/bash
# setup_wifi_ap.sh — Configure a Raspberry Pi as a WiFi AP + captive portal
# for the eSIM provisioning system.
# Run as root: sudo bash scripts/setup_wifi_ap.sh
set -euo pipefail
[ "$(id -u)" = "0" ] || { echo "ERROR: Must run as root (sudo)."; exit 1; }
SSID="${ESIM_SSID:-Emergency-5G}"
CHANNEL="${ESIM_CHANNEL:-6}"
AP_IP="${ESIM_AP_IP:-192.168.4.1}"
DHCP_RANGE="${ESIM_DHCP_RANGE:-192.168.4.10,192.168.4.250,24h}"
INSTALL_DIR="${ESIM_INSTALL_DIR:-/opt/esim-provisioning}"
IFACE="${ESIM_IFACE:-wlan0}"
FLASK_PORT="${ESIM_FLASK_PORT:-5000}"
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
echo "=== eSIM Provisioning — WiFi AP Setup ==="
echo " SSID: $SSID"
echo " Channel: $CHANNEL"
echo " AP IP: $AP_IP"
echo " Interface: $IFACE"
echo " Install dir: $INSTALL_DIR"
echo ""
# ---- 1. Install packages ----
echo "[1/8] Installing packages..."
apt-get update -qq
apt-get install -y hostapd dnsmasq nginx python3 python3-pip python3-venv
# ---- 2. Static IP on wlan0 ----
echo "[2/8] Configuring static IP on $IFACE..."
DHCPCD_CONF=/etc/dhcpcd.conf
if ! grep -q "interface $IFACE" "$DHCPCD_CONF" 2>/dev/null; then
cat >> "$DHCPCD_CONF" << EOF
interface $IFACE
static ip_address=$AP_IP/24
nohook wpa_supplicant
EOF
fi
# ---- 3. hostapd ----
echo "[3/8] Configuring hostapd..."
cat > /etc/hostapd/hostapd.conf << EOF
interface=$IFACE
driver=nl80211
ssid=$SSID
utf8_ssid=1
hw_mode=g
channel=$CHANNEL
ieee80211n=1
wmm_enabled=1
auth_algs=1
ignore_broadcast_ssid=0
max_num_sta=50
EOF
sed -i 's|#DAEMON_CONF=.*|DAEMON_CONF="/etc/hostapd/hostapd.conf"|' /etc/default/hostapd
# ---- 4. dnsmasq ----
echo "[4/8] Configuring dnsmasq..."
# Back up original if present
[ -f /etc/dnsmasq.conf ] && mv /etc/dnsmasq.conf /etc/dnsmasq.conf.bak
cat > /etc/dnsmasq.conf << EOF
interface=$IFACE
bind-interfaces
dhcp-range=$DHCP_RANGE
address=/#/$AP_IP
no-resolv
dhcp-leasefile=/var/lib/misc/dnsmasq.leases
log-queries
log-dhcp
EOF
# ---- 5. nginx ----
echo "[5/8] Configuring nginx..."
cat > /etc/nginx/sites-available/esim-provisioning << EOF
server {
listen 80 default_server;
server_name _;
location = / {
return 302 http://$AP_IP/activate;
}
location /activate {
alias $INSTALL_DIR/portal/;
index index.html;
try_files \$uri \$uri/ /index.html;
}
location /css/ { alias $INSTALL_DIR/portal/css/; }
location /js/ { alias $INSTALL_DIR/portal/js/; }
location /api/ {
proxy_pass http://127.0.0.1:$FLASK_PORT;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
}
location / {
return 302 http://$AP_IP/activate;
}
}
EOF
ln -sf /etc/nginx/sites-available/esim-provisioning /etc/nginx/sites-enabled/
rm -f /etc/nginx/sites-enabled/default
nginx -t
# ---- 6. IP forwarding ----
echo "[6/8] Enabling IP forwarding..."
sed -i 's|#net.ipv4.ip_forward=1|net.ipv4.ip_forward=1|' /etc/sysctl.conf
sysctl -w net.ipv4.ip_forward=1
# ---- 7. iptables — captive portal redirect ----
echo "[7/8] Configuring iptables..."
# Redirect all HTTP traffic from wlan0 clients to nginx (captive portal)
iptables -t nat -A PREROUTING -i "$IFACE" -p tcp --dport 80 -j DNAT \
--to-destination "$AP_IP:80" 2>/dev/null || true
# Persist iptables rules
apt-get install -y iptables-persistent
netfilter-persistent save
# ---- 8. Deploy server and services ----
echo "[8/8] Deploying server..."
mkdir -p "$INSTALL_DIR" /etc/esim-provisioning
cp -r "$REPO_DIR/server" "$INSTALL_DIR/"
cp -r "$REPO_DIR/portal" "$INSTALL_DIR/"
cp -r "$REPO_DIR/scripts" "$INSTALL_DIR/"
cp -r "$REPO_DIR/requirements.txt" "$INSTALL_DIR/"
[ -f /etc/esim-provisioning/config.yaml ] || \
cp "$REPO_DIR/config.yaml.example" /etc/esim-provisioning/config.yaml
# Create venv and install deps
python3 -m venv "$INSTALL_DIR/venv"
"$INSTALL_DIR/venv/bin/pip" install -q -r "$INSTALL_DIR/requirements.txt"
# Create system user
id esim &>/dev/null || useradd --system --no-create-home --shell /usr/sbin/nologin esim
# Restrict config — it holds the OP key and other secrets. Readable only by
# the service user, not world-readable.
chown esim:esim /etc/esim-provisioning/config.yaml
chmod 600 /etc/esim-provisioning/config.yaml
mkdir -p "$INSTALL_DIR/data"
# Install systemd services
cp "$REPO_DIR/systemd/esim-provisioning.service" /etc/systemd/system/
cp "$REPO_DIR/systemd/captive-portal.service" /etc/systemd/system/
systemctl daemon-reload
# Initialise DB
CONFIG_PATH=/etc/esim-provisioning/config.yaml \
"$INSTALL_DIR/venv/bin/python" "$INSTALL_DIR/scripts/init_db.py"
# Own data (incl. the DB file just created by root) as the service user, which
# runs with ReadWritePaths=.../data — otherwise the service cannot write it.
chown -R esim:esim "$INSTALL_DIR/data"
# Enable everything
systemctl unmask hostapd
systemctl enable hostapd dnsmasq nginx esim-provisioning captive-portal
systemctl restart hostapd dnsmasq nginx esim-provisioning
echo ""
echo "=== Setup Complete ==="
echo ""
echo "Edit /etc/esim-provisioning/config.yaml to set your MCC/MNC/OP key."
echo "Then: systemctl restart esim-provisioning"
echo ""
echo "Clients can connect to WiFi '$SSID' and will be redirected to the"
echo "eSIM activation portal at http://$AP_IP/activate"

View File

@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""Sync unsynced subscribers from SQLite to the configured core network."""
import logging
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from server.core_adapters import get_adapter
from server.database import get_connection, list_unsynced, mark_synced
from server.utils import load_config
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
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 to 'open5gs' or 'free5gc' in config.")
sys.exit(1)
conn = get_connection(cfg["database"]["path"])
unsynced = list_unsynced(conn)
logger.info(f"Found {len(unsynced)} unsynced subscriber(s)")
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 exc:
logger.error(f" Failed to sync {sub.imsi}: {exc}")
conn.close()
logger.info(f"Sync complete: {success}/{len(unsynced)} succeeded")
if __name__ == "__main__":
main()

0
server/__init__.py Normal file
View File

250
server/app.py Normal file
View File

@ -0,0 +1,250 @@
import io
import logging
import os
import secrets
import threading
import time
from collections import OrderedDict
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,
ensure_parent_dir,
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")
# 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()
_cache_lock = threading.Lock()
_start_time = time.time()
def _cache_profile(token: str, data: bytes) -> None:
with _cache_lock:
_profile_cache[token] = data
_profile_cache.move_to_end(token)
while len(_profile_cache) > _MAX_CACHED_PROFILES:
_profile_cache.popitem(last=False)
def _get_cached_profile(token: str):
with _cache_lock:
return _profile_cache.get(token)
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"]
ensure_parent_dir(db_path)
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"]
# Optional admin token guarding /api/export; unset means export is disabled.
admin_token = cfg.get("server", {}).get("admin_token") or ""
# ------------------------------------------------------------------
# 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
# Retry on the astronomically-unlikely IMSI/ICCID collision so a
# duplicate does not surface to the user as a 500.
sub_id = None
for attempt in range(5):
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)
try:
sub_id = add_subscriber(conn, imsi, iccid, ki_hex, opc_hex, mac)
break
except ValueError as exc:
logger.warning(f"Credential collision (attempt {attempt + 1}), retrying: {exc}")
if sub_id is None:
logger.error("Provision failed: could not allocate a unique IMSI/ICCID")
return jsonify({"error": "Profile generation failed"}), 500
profile_bytes = create_esim_profile(
imsi, ki, opc, iccid, profile_name, carrier_name,
smdp_address=smdp_address, apn=apn,
)
token = secrets.token_urlsafe(24)
_cache_profile(token, 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/{token}",
}
), 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/<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 = _get_cached_profile(token)
if profile_bytes is None:
return jsonify({"error": "Profile not found"}), 404
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():
# This endpoint dumps every subscriber's Ki/OPc, so it is gated behind
# an admin token and defaults closed (disabled unless configured).
if not admin_token:
return jsonify({"error": "Export disabled (server.admin_token not set)"}), 403
provided = request.headers.get("X-Admin-Token", "")
if not secrets.compare_digest(provided, admin_token):
return jsonify({"error": "Unauthorized"}), 401
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),
)

View File

@ -0,0 +1,20 @@
from .base import CoreAdapter
from typing import Optional
def get_adapter(config: dict) -> Optional[CoreAdapter]:
"""Factory: return the configured core adapter, or None for export-only mode."""
core_cfg = config.get("core", {})
core_type = core_cfg.get("type", "none")
uri = core_cfg.get("mongodb_uri", "mongodb://localhost:27017")
db_name = core_cfg.get("database_name", "open5gs")
apn = config.get("profile", {}).get("apn", "internet")
if core_type == "open5gs":
from .open5gs import Open5GSAdapter
return Open5GSAdapter(uri, db_name, apn)
elif core_type == "free5gc":
from .free5gc import Free5GCAdapter
return Free5GCAdapter(uri, db_name, apn)
else:
return None

View File

@ -0,0 +1,15 @@
from abc import ABC, abstractmethod
class CoreAdapter(ABC):
@abstractmethod
def add_subscriber(self, imsi: str, ki: str, opc: str) -> None:
"""Add a subscriber to the core network. ki and opc are hex strings."""
@abstractmethod
def remove_subscriber(self, imsi: str) -> None:
"""Remove a subscriber from the core network."""
@abstractmethod
def list_subscribers(self) -> list:
"""Return a list of subscriber dicts from the core."""

View File

@ -0,0 +1,12 @@
from .open5gs import Open5GSAdapter
class Free5GCAdapter(Open5GSAdapter):
"""free5gc adapter — uses the same MongoDB document schema as Open5GS.
Override methods here as free5gc-specific behaviour is needed.
"""
def __init__(self, mongodb_uri: str, database_name: str = "free5gc",
apn: str = "internet"):
super().__init__(mongodb_uri, database_name, apn)

View File

@ -0,0 +1,63 @@
import logging
from pymongo import MongoClient
from .base import CoreAdapter
logger = logging.getLogger(__name__)
class Open5GSAdapter(CoreAdapter):
def __init__(self, mongodb_uri: str, database_name: str = "open5gs",
apn: str = "internet"):
# Short timeouts so a down/unreachable core fails the provision fast
# instead of blocking the request for pymongo's 30s default.
self.client = MongoClient(
mongodb_uri,
serverSelectionTimeoutMS=3000,
connectTimeoutMS=3000,
socketTimeoutMS=3000,
)
self.db = self.client[database_name]
self.apn = apn
def add_subscriber(self, imsi: str, ki: str, opc: str) -> None:
"""Upsert a subscriber into the Open5GS MongoDB collection."""
doc = {
"imsi": imsi,
"security": {
"k": ki,
"opc": opc,
"amf": "8000",
"sqn": 0,
},
"ambr": {
"downlink": {"value": 1, "unit": 3},
"uplink": {"value": 1, "unit": 3},
},
"slice": [
{
"sst": 1,
"default_indicator": True,
"session": [
{
"name": self.apn,
"type": 3,
"ambr": {
"downlink": {"value": 1, "unit": 3},
"uplink": {"value": 1, "unit": 3},
},
}
],
}
],
}
self.db.subscribers.replace_one({"imsi": imsi}, doc, upsert=True)
logger.info(f"Added subscriber {imsi} to Open5GS")
def remove_subscriber(self, imsi: str) -> None:
self.db.subscribers.delete_one({"imsi": imsi})
logger.info(f"Removed subscriber {imsi} from Open5GS")
def list_subscribers(self) -> list:
return list(self.db.subscribers.find({}, {"_id": 0}))

149
server/database.py Normal file
View File

@ -0,0 +1,149 @@
import sqlite3
import logging
from datetime import datetime
from typing import Optional
from server.models import Subscriber
logger = logging.getLogger(__name__)
_SCHEMA = """
CREATE TABLE IF NOT EXISTS subscribers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
imsi TEXT UNIQUE NOT NULL,
iccid TEXT UNIQUE NOT NULL,
ki TEXT NOT NULL,
opc TEXT NOT NULL,
mac_address TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
synced_to_core BOOLEAN DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_imsi ON subscribers(imsi);
CREATE INDEX IF NOT EXISTS idx_mac_address ON subscribers(mac_address);
"""
def init_db(db_path: str) -> None:
"""Create the database schema if it does not already exist."""
conn = sqlite3.connect(db_path)
try:
conn.executescript(_SCHEMA)
conn.commit()
logger.info(f"Database initialized at {db_path}")
finally:
conn.close()
def get_connection(db_path: str) -> sqlite3.Connection:
"""Return an open connection with Row factory enabled.
Sets a busy timeout so that under a threaded server concurrent writers
wait for the lock instead of immediately raising "database is locked".
"""
conn = sqlite3.connect(db_path, detect_types=sqlite3.PARSE_DECLTYPES, timeout=5.0)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA busy_timeout = 5000")
return conn
def _row_to_subscriber(row: sqlite3.Row) -> Subscriber:
created = row["created_at"]
if isinstance(created, str):
created = datetime.fromisoformat(created)
return Subscriber(
id=row["id"],
imsi=row["imsi"],
iccid=row["iccid"],
ki=row["ki"],
opc=row["opc"],
mac_address=row["mac_address"],
created_at=created,
synced_to_core=bool(row["synced_to_core"]),
)
def add_subscriber(
conn: sqlite3.Connection,
imsi: str,
iccid: str,
ki: str,
opc: str,
mac_address: Optional[str] = None,
) -> int:
"""Insert a new subscriber. Returns the new row id.
Raises ValueError if imsi or iccid already exists.
"""
try:
cur = conn.execute(
"""
INSERT INTO subscribers (imsi, iccid, ki, opc, mac_address)
VALUES (?, ?, ?, ?, ?)
""",
(imsi, iccid, ki, opc, mac_address),
)
conn.commit()
return cur.lastrowid
except sqlite3.IntegrityError as exc:
raise ValueError(f"Duplicate subscriber (imsi or iccid already exists): {exc}") from exc
def get_subscriber_by_imsi(conn: sqlite3.Connection, imsi: str) -> Optional[Subscriber]:
row = conn.execute("SELECT * FROM subscribers WHERE imsi = ?", (imsi,)).fetchone()
return _row_to_subscriber(row) if row else None
def get_subscriber_by_id(conn: sqlite3.Connection, sub_id: int) -> Optional[Subscriber]:
row = conn.execute("SELECT * FROM subscribers WHERE id = ?", (sub_id,)).fetchone()
return _row_to_subscriber(row) if row else None
def list_unsynced(conn: sqlite3.Connection) -> list:
rows = conn.execute(
"SELECT * FROM subscribers WHERE synced_to_core = 0"
).fetchall()
return [_row_to_subscriber(r) for r in rows]
def mark_synced(conn: sqlite3.Connection, imsi: str) -> None:
conn.execute(
"UPDATE subscribers SET synced_to_core = 1 WHERE imsi = ?", (imsi,)
)
conn.commit()
def check_rate_limit(
conn: sqlite3.Connection, mac_address: str, window_hours: int
) -> bool:
"""Return True if the MAC address is allowed to provision (no record in the window).
Uses SQLite's ``datetime()`` on both sides so the comparison is a true
chronological one regardless of whether ``created_at`` was written by
``CURRENT_TIMESTAMP`` (``"YYYY-MM-DD HH:MM:SS"``) or as a Python
``isoformat()`` string (``"YYYY-MM-DDTHH:MM:SS.ffffff"``).
"""
cur = conn.execute(
"SELECT COUNT(*) FROM subscribers "
"WHERE mac_address = ? AND datetime(created_at) > datetime('now', ?)",
(mac_address, f"-{window_hours} hours"),
)
count = cur.fetchone()[0]
return count == 0
def export_all(conn: sqlite3.Connection) -> list:
"""Return all subscriber rows as a list of plain dicts."""
rows = conn.execute("SELECT * FROM subscribers").fetchall()
result = []
for row in rows:
result.append({
"id": row["id"],
"imsi": row["imsi"],
"iccid": row["iccid"],
"ki": row["ki"],
"opc": row["opc"],
"mac_address": row["mac_address"],
"created_at": str(row["created_at"]),
"synced_to_core": bool(row["synced_to_core"]),
})
return result

15
server/models.py Normal file
View File

@ -0,0 +1,15 @@
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
@dataclass
class Subscriber:
id: Optional[int]
imsi: str
iccid: str
ki: str # hex string
opc: str # hex string
mac_address: Optional[str]
created_at: datetime
synced_to_core: bool

148
server/profile_generator.py Normal file
View File

@ -0,0 +1,148 @@
import json
import secrets
import logging
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
logger = logging.getLogger(__name__)
def generate_imsi(mcc: str, mnc: str) -> str:
"""Generate a random 15-digit IMSI: MCC + MNC + MSIN.
IMSI is always 15 digits. MCC is 3 digits; MNC is 2 or 3 digits, so the
MSIN fills the remaining 9 or 10 digits.
"""
msin_len = 15 - len(mcc) - len(mnc)
if msin_len <= 0:
raise ValueError(f"MCC/MNC too long for a 15-digit IMSI: {mcc!r}/{mnc!r}")
msin = str(secrets.randbelow(10**msin_len)).zfill(msin_len)
imsi = mcc + mnc + msin
assert len(imsi) == 15, f"IMSI length error: {imsi!r}"
return imsi
def generate_ki() -> bytes:
"""Generate a 16-byte cryptographically random authentication key."""
return secrets.token_bytes(16)
def luhn_checksum(number: str) -> int:
"""Compute the Luhn check digit for a string of digits."""
digits = [int(d) for d in number]
odd_digits = digits[-1::-2]
even_digits = digits[-2::-2]
total = sum(odd_digits)
for d in even_digits:
total += sum(divmod(d * 2, 10))
return (10 - (total % 10)) % 10
def generate_iccid() -> str:
"""Generate a 19-digit ICCID with a valid Luhn check digit.
Format: 8910 (telecom prefix) + 10 random digits + 5 more + Luhn digit = 19 total.
"""
prefix = "8910"
body = prefix + str(secrets.randbelow(10**14)).zfill(14) # 18 digits
check = luhn_checksum(body)
return body + str(check)
def derive_opc(ki: bytes, op: bytes) -> bytes:
"""Derive OPc from Ki and OP using the Milenage algorithm.
OPc = AES_Ki(OP) XOR OP
"""
cipher = Cipher(algorithms.AES(ki), modes.ECB(), backend=default_backend())
enc = cipher.encryptor()
encrypted_op = enc.update(op) + enc.finalize()
return bytes(a ^ b for a, b in zip(encrypted_op, op))
def swap_nibbles(s: str) -> str:
"""Swap the nibbles of every byte in a hex string.
``"1234"`` -> ``"2143"``. Used for the BCD-with-swapped-nibbles layout
that 3GPP uses for ICCID and IMSI elementary files.
"""
if len(s) % 2:
raise ValueError(f"hex string must have even length: {s!r}")
return "".join(s[i + 1] + s[i] for i in range(0, len(s), 2))
def enc_iccid(iccid: str) -> bytes:
"""Encode an ICCID as EF_ICCID bytes (3GPP TS 31.102 §4.2.1).
Digits are BCD packed with swapped nibbles and right-padded to 10 bytes
(20 nibbles) with 0xF.
"""
if not iccid.isdigit():
raise ValueError(f"ICCID must be digits only: {iccid!r}")
digits = iccid.ljust(20, "f")
return bytes.fromhex(swap_nibbles(digits))
def enc_imsi(imsi: str) -> bytes:
"""Encode an IMSI as EF_IMSI bytes (3GPP TS 31.102 §4.2.2).
Layout: length octet, then a parity nibble ((odd<<3)|1) prepended to the
IMSI digits, the whole run nibble-swapped.
"""
if not imsi.isdigit():
raise ValueError(f"IMSI must be digits only: {imsi!r}")
odd = len(imsi) & 1
value = swap_nibbles(f"{(odd << 3) | 1:01x}{imsi}")
length = len(value) // 2
return bytes([length]) + bytes.fromhex(value)
def build_lpa_activation_code(iccid: str, smdp_address: str) -> str:
"""Build an SGP.22 LPA activation code string.
Format: ``LPA:1$<smdp-address>$<matching-id>``. The ICCID is used as the
matching id so the profile is self-referencing for this ad-hoc deployment.
"""
return f"LPA:1${smdp_address}${iccid}"
def create_esim_profile(
imsi: str,
ki: bytes,
opc: bytes,
iccid: str,
profile_name: str,
carrier_name: str,
smdp_address: str = "esim.local",
apn: str = "internet",
) -> bytes:
"""Create an eSIM profile package (``profile-package-v2``).
Emits a structured package embedding the raw credentials, the real 3GPP
EF_ICCID / EF_IMSI byte encodings, and an SGP.22 LPA activation code.
Full SGP.22 SIMalliance Interoperable Profile (ASN.1 UPP) generation via
osmocom pySim remains the follow-up step; see CHANGELOG.
"""
ki_hex = ki.hex() if isinstance(ki, bytes) else ki
opc_hex = opc.hex() if isinstance(opc, bytes) else opc
profile = {
"version": "2.0",
"type": "profile-package-v2",
"iccid": iccid,
"imsi": imsi,
"ki": ki_hex,
"opc": opc_hex,
"profile_name": profile_name,
"carrier_name": carrier_name,
"apn": apn,
"activation_code": build_lpa_activation_code(iccid, smdp_address),
"ef": {
"iccid": enc_iccid(iccid).hex(),
"imsi": enc_imsi(imsi).hex(),
},
}
logger.debug(f"Created profile-package-v2 for IMSI {imsi}")
return json.dumps(profile, indent=2).encode("utf-8")

106
server/utils.py Normal file
View File

@ -0,0 +1,106 @@
import os
import re
import logging
from typing import Optional
import yaml
logger = logging.getLogger(__name__)
_MAC_RE = re.compile(r"^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$")
_REQUIRED_CONFIG_KEYS = ["network", "server", "database", "rate_limiting", "profile"]
def validate_mac(mac: str) -> bool:
"""Return True if mac is a valid colon-separated MAC address."""
return bool(mac and _MAC_RE.match(mac))
def normalize_mac(mac: str) -> str:
"""Return a lowercase, colon-separated MAC address.
Accepts input with dashes, dots, or no separators.
"""
clean = mac.lower().replace("-", ":").replace(".", ":")
# Handle 12-char no-separator format
if ":" not in clean and len(clean) == 12:
clean = ":".join(clean[i : i + 2] for i in range(0, 12, 2))
return clean
def validate_imsi(imsi: str) -> bool:
"""Return True if imsi is exactly 15 decimal digits."""
return bool(imsi) and imsi.isdigit() and len(imsi) == 15
def validate_op_key(op_key: str) -> bool:
"""Return True if op_key is a 16-byte (32 hex digit) hex string."""
if not isinstance(op_key, str) or len(op_key) != 32:
return False
try:
bytes.fromhex(op_key)
except ValueError:
return False
return True
def load_config(path: str) -> dict:
"""Load and minimally validate a YAML config file."""
if not os.path.exists(path):
raise FileNotFoundError(f"Config file not found: {path}")
with open(path) as f:
cfg = yaml.safe_load(f)
if not isinstance(cfg, dict):
raise ValueError(f"Config file is empty or not a mapping: {path}")
for key in _REQUIRED_CONFIG_KEYS:
if key not in cfg:
raise ValueError(f"Missing required config section: '{key}'")
op_key = cfg["network"].get("op_key")
if not validate_op_key(op_key):
raise ValueError(
"network.op_key must be a 16-byte hex string (32 hex digits)"
)
return cfg
def ensure_parent_dir(path: str) -> None:
"""Create the parent directory of ``path`` if it has one.
Guards against ``os.makedirs("")`` raising FileNotFoundError when ``path``
is a bare filename with no directory component.
"""
parent = os.path.dirname(path)
if parent:
os.makedirs(parent, exist_ok=True)
def hex_to_bytes(h: str) -> bytes:
return bytes.fromhex(h)
def bytes_to_hex(b: bytes) -> str:
return b.hex()
def get_client_ip(request) -> str:
"""Return the real client IP, respecting X-Forwarded-For from nginx."""
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
return request.remote_addr or ""
def get_mac_from_arp(ip: str) -> Optional[str]:
"""Attempt to resolve a MAC address from /proc/net/arp for the given IP."""
try:
with open("/proc/net/arp") as f:
for line in f:
parts = line.split()
if len(parts) >= 4 and parts[0] == ip:
mac = parts[3]
if validate_mac(mac):
return normalize_mac(mac)
except Exception as exc:
logger.debug(f"ARP lookup failed for {ip}: {exc}")
return None

View File

@ -0,0 +1,13 @@
[Unit]
Description=Captive Portal (nginx + dnsmasq)
After=network.target hostapd.service
Wants=nginx.service dnsmasq.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/systemctl start nginx dnsmasq
ExecStop=/bin/systemctl stop nginx dnsmasq
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,26 @@
[Unit]
Description=eSIM Provisioning Server
After=network.target
Wants=network.target
[Service]
Type=simple
User=esim
Group=esim
WorkingDirectory=/opt/esim-provisioning
Environment=CONFIG_PATH=/etc/esim-provisioning/config.yaml
ExecStart=/opt/esim-provisioning/venv/bin/python -m server.app
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=esim-provisioning
# Basic hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/opt/esim-provisioning/data
[Install]
WantedBy=multi-user.target

0
tests/__init__.py Normal file
View File

138
tests/test_app.py Normal file
View File

@ -0,0 +1,138 @@
import json
import pytest
import yaml
from server.app import create_app
def _write_config(tmp_path, admin_token=""):
cfg = {
"network": {"mcc": "302", "mnc": "720", "plmn": "302720",
"op_key": "63bfa50ee6523365ff14c1f45f88737d"},
"wifi": {"ssid": "Emergency-5G", "channel": 6, "ip": "192.168.4.1",
"subnet": "192.168.4.0/24", "dhcp_range": "192.168.4.10,192.168.4.250"},
"server": {"host": "0.0.0.0", "port": 5000, "debug": False,
"admin_token": admin_token},
"database": {"path": str(tmp_path / "subscribers.db")},
"rate_limiting": {"max_profiles_per_mac": 1, "window_hours": 1},
"core": {"type": "none", "mongodb_uri": "mongodb://localhost:27017",
"database_name": "open5gs"},
"profile": {"carrier_name": "Ad-Hoc Network", "profile_name": "Emergency 5G",
"apn": "internet", "smdp_address": "esim.local"},
}
path = tmp_path / "config.yaml"
path.write_text(yaml.safe_dump(cfg))
return str(path)
@pytest.fixture
def client(tmp_path):
app = create_app(_write_config(tmp_path))
app.config.update(TESTING=True)
return app.test_client()
@pytest.fixture
def admin_client(tmp_path):
app = create_app(_write_config(tmp_path, admin_token="s3cret-token"))
app.config.update(TESTING=True)
return app.test_client()
class TestRoutes:
def test_index_redirects_to_activate(self, client):
r = client.get("/")
assert r.status_code == 302
assert "/activate" in r.headers["Location"]
def test_status_ok(self, client):
data = client.get("/api/status").get_json()
assert data["status"] == "ok"
assert data["core_adapter"] == "none"
def test_export_disabled_without_admin_token(self, client):
# No server.admin_token configured -> export is disabled.
assert client.get("/api/export").status_code == 403
def test_export_requires_matching_token(self, admin_client):
assert admin_client.get("/api/export").status_code == 401
assert admin_client.get(
"/api/export", headers={"X-Admin-Token": "wrong"}
).status_code == 401
def test_export_with_valid_token(self, admin_client):
r = admin_client.get("/api/export", headers={"X-Admin-Token": "s3cret-token"})
assert r.status_code == 200
data = r.get_json()
assert data["count"] == 0
assert data["subscribers"] == []
class TestProvision:
def test_provision_success(self, client):
r = client.post("/api/provision", json={"mac_address": "aa:bb:cc:11:22:33"})
assert r.status_code == 200
body = r.get_json()
assert len(body["imsi"]) == 15
assert body["profile_url"].startswith("/api/profile/")
def test_provision_missing_mac(self, client):
# No body and (in tests) no resolvable ARP entry -> 400
r = client.post("/api/provision", json={})
assert r.status_code == 400
def test_provision_invalid_mac(self, client):
r = client.post("/api/provision", json={"mac_address": "not-a-mac"})
assert r.status_code == 400
def test_rate_limited_second_request(self, client):
client.post("/api/provision", json={"mac_address": "de:ad:be:ef:00:01"})
r = client.post("/api/provision", json={"mac_address": "de:ad:be:ef:00:01"})
assert r.status_code == 429
def test_provision_retries_on_collision(self, client, monkeypatch):
import server.app as appmod
real_add = appmod.add_subscriber
calls = {"n": 0}
def flaky(conn, imsi, iccid, ki, opc, mac):
calls["n"] += 1
if calls["n"] == 1:
raise ValueError("Duplicate subscriber")
return real_add(conn, imsi, iccid, ki, opc, mac)
monkeypatch.setattr(appmod, "add_subscriber", flaky)
r = client.post("/api/provision", json={"mac_address": "aa:bb:cc:ab:cd:ef"})
assert r.status_code == 200
assert calls["n"] == 2 # failed once, succeeded on retry
def test_provision_gives_up_after_persistent_collision(self, client, monkeypatch):
import server.app as appmod
def always_dup(*a, **k):
raise ValueError("Duplicate subscriber")
monkeypatch.setattr(appmod, "add_subscriber", always_dup)
r = client.post("/api/provision", json={"mac_address": "aa:bb:cc:ab:cd:11"})
assert r.status_code == 500
class TestProfileDownload:
def test_download_with_token(self, client):
r = client.post("/api/provision", json={"mac_address": "aa:bb:cc:44:55:66"})
url = r.get_json()["profile_url"]
d = client.get(url)
assert d.status_code == 200
profile = json.loads(d.data)
assert profile["type"] == "profile-package-v2"
def test_unknown_token_is_404(self, client):
assert client.get("/api/profile/does-not-exist").status_code == 404
def test_sequential_id_not_downloadable(self, client):
# The old enumerable /api/profile/<int> scheme must be gone: the row id
# is not a valid download token.
r = client.post("/api/provision", json={"mac_address": "aa:bb:cc:77:88:99"})
sub_id = r.get_json()["id"]
assert client.get(f"/api/profile/{sub_id}").status_code == 404

View File

@ -0,0 +1,90 @@
import pytest
from unittest.mock import MagicMock, patch
from server.core_adapters import get_adapter
from server.core_adapters.open5gs import Open5GSAdapter
class TestFactory:
def test_none_type_returns_none(self):
cfg = {"core": {"type": "none"}}
assert get_adapter(cfg) is None
def test_missing_core_section_returns_none(self):
assert get_adapter({}) is None
def test_open5gs_type_returns_adapter(self):
cfg = {"core": {"type": "open5gs", "mongodb_uri": "mongodb://localhost:27017", "database_name": "open5gs"}}
with patch("server.core_adapters.open5gs.MongoClient"):
adapter = get_adapter(cfg)
assert isinstance(adapter, Open5GSAdapter)
def test_free5gc_type_returns_adapter(self):
from server.core_adapters.free5gc import Free5GCAdapter
cfg = {"core": {"type": "free5gc", "mongodb_uri": "mongodb://localhost:27017", "database_name": "free5gc"}}
with patch("server.core_adapters.open5gs.MongoClient"):
adapter = get_adapter(cfg)
assert isinstance(adapter, Free5GCAdapter)
def test_apn_threaded_from_profile_config(self):
cfg = {"core": {"type": "open5gs"}, "profile": {"apn": "ims"}}
with patch("server.core_adapters.open5gs.MongoClient"):
adapter = get_adapter(cfg)
assert adapter.apn == "ims"
@pytest.fixture
def open5gs_adapter():
with patch("server.core_adapters.open5gs.MongoClient"):
adapter = Open5GSAdapter("mongodb://localhost:27017", "open5gs")
adapter.db = MagicMock()
yield adapter
class TestOpen5GSAdapter:
def test_add_subscriber_calls_replace_one(self, open5gs_adapter):
open5gs_adapter.add_subscriber("302720000000001", "aa" * 16, "bb" * 16)
open5gs_adapter.db.subscribers.replace_one.assert_called_once()
def test_add_subscriber_document_shape(self, open5gs_adapter):
open5gs_adapter.add_subscriber("302720000000001", "aabbcc", "ddeeff")
call_args = open5gs_adapter.db.subscribers.replace_one.call_args
filter_doc = call_args[0][0]
sub_doc = call_args[0][1]
assert filter_doc == {"imsi": "302720000000001"}
assert sub_doc["imsi"] == "302720000000001"
assert sub_doc["security"]["k"] == "aabbcc"
assert sub_doc["security"]["opc"] == "ddeeff"
assert sub_doc["security"]["amf"] == "8000"
assert "slice" in sub_doc
assert len(sub_doc["slice"]) == 1
assert sub_doc["slice"][0]["sst"] == 1
assert sub_doc["slice"][0]["session"][0]["name"] == "internet"
def test_add_subscriber_uses_configured_apn(self):
with patch("server.core_adapters.open5gs.MongoClient"):
adapter = Open5GSAdapter("mongodb://localhost:27017", "open5gs", apn="ims")
adapter.db = MagicMock()
adapter.add_subscriber("302720000000001", "aa", "bb")
sub_doc = adapter.db.subscribers.replace_one.call_args[0][1]
assert sub_doc["slice"][0]["session"][0]["name"] == "ims"
def test_add_subscriber_upsert_true(self, open5gs_adapter):
open5gs_adapter.add_subscriber("302720000000001", "aa", "bb")
call_kwargs = open5gs_adapter.db.subscribers.replace_one.call_args[1]
assert call_kwargs.get("upsert") is True
def test_remove_subscriber(self, open5gs_adapter):
open5gs_adapter.remove_subscriber("302720000000001")
open5gs_adapter.db.subscribers.delete_one.assert_called_once_with(
{"imsi": "302720000000001"}
)
def test_list_subscribers(self, open5gs_adapter):
open5gs_adapter.db.subscribers.find.return_value = [
{"imsi": "302720000000001"}
]
result = open5gs_adapter.list_subscribers()
assert len(result) == 1
assert result[0]["imsi"] == "302720000000001"

120
tests/test_database.py Normal file
View File

@ -0,0 +1,120 @@
import pytest
from datetime import datetime, timedelta
from server.database import (
add_subscriber,
check_rate_limit,
export_all,
get_connection,
get_subscriber_by_id,
get_subscriber_by_imsi,
init_db,
list_unsynced,
mark_synced,
)
@pytest.fixture
def db_conn(tmp_path):
db_path = str(tmp_path / "test.db")
init_db(db_path)
conn = get_connection(db_path)
yield conn
conn.close()
def _sub(**overrides):
defaults = dict(
imsi="302720000000001",
iccid="8910302720000000001",
ki="aa" * 16,
opc="bb" * 16,
mac_address="aa:bb:cc:dd:ee:ff",
)
defaults.update(overrides)
return defaults
class TestCRUD:
def test_add_and_retrieve_by_imsi(self, db_conn):
d = _sub()
sid = add_subscriber(db_conn, **d)
sub = get_subscriber_by_imsi(db_conn, d["imsi"])
assert sub is not None
assert sub.imsi == d["imsi"]
assert sub.id == sid
def test_add_and_retrieve_by_id(self, db_conn):
d = _sub()
sid = add_subscriber(db_conn, **d)
sub = get_subscriber_by_id(db_conn, sid)
assert sub is not None
assert sub.iccid == d["iccid"]
def test_duplicate_imsi_raises(self, db_conn):
add_subscriber(db_conn, **_sub())
with pytest.raises(ValueError, match="Duplicate"):
add_subscriber(db_conn, **_sub())
def test_duplicate_iccid_raises(self, db_conn):
add_subscriber(db_conn, **_sub())
with pytest.raises(ValueError):
add_subscriber(db_conn, **_sub(imsi="302720000000002"))
def test_not_found_returns_none(self, db_conn):
assert get_subscriber_by_imsi(db_conn, "999999999999999") is None
assert get_subscriber_by_id(db_conn, 99999) is None
def test_synced_to_core_defaults_false(self, db_conn):
add_subscriber(db_conn, **_sub())
sub = get_subscriber_by_imsi(db_conn, _sub()["imsi"])
assert sub.synced_to_core is False
class TestRateLimiting:
def test_first_request_allowed(self, db_conn):
assert check_rate_limit(db_conn, "aa:bb:cc:dd:ee:ff", 1) is True
def test_second_request_blocked(self, db_conn):
add_subscriber(db_conn, **_sub())
assert check_rate_limit(db_conn, "aa:bb:cc:dd:ee:ff", 1) is False
def test_different_mac_allowed(self, db_conn):
add_subscriber(db_conn, **_sub())
assert check_rate_limit(db_conn, "11:22:33:44:55:66", 1) is True
def test_expired_window_allows_again(self, db_conn):
add_subscriber(db_conn, **_sub())
past = (datetime.utcnow() - timedelta(hours=2)).isoformat()
db_conn.execute("UPDATE subscribers SET created_at = ?", (past,))
db_conn.commit()
assert check_rate_limit(db_conn, "aa:bb:cc:dd:ee:ff", 1) is True
class TestSync:
def test_list_unsynced_after_insert(self, db_conn):
add_subscriber(db_conn, **_sub())
assert len(list_unsynced(db_conn)) == 1
def test_mark_synced_removes_from_unsynced(self, db_conn):
add_subscriber(db_conn, **_sub())
mark_synced(db_conn, _sub()["imsi"])
assert len(list_unsynced(db_conn)) == 0
def test_synced_flag_set(self, db_conn):
add_subscriber(db_conn, **_sub())
mark_synced(db_conn, _sub()["imsi"])
sub = get_subscriber_by_imsi(db_conn, _sub()["imsi"])
assert sub.synced_to_core is True
class TestExport:
def test_export_returns_list_with_correct_keys(self, db_conn):
add_subscriber(db_conn, **_sub())
rows = export_all(db_conn)
assert len(rows) == 1
for key in ("imsi", "iccid", "ki", "opc", "mac_address", "created_at", "synced_to_core"):
assert key in rows[0]
def test_export_empty_db(self, db_conn):
assert export_all(db_conn) == []

View File

@ -0,0 +1,173 @@
import json
import pytest
from server.profile_generator import (
build_lpa_activation_code,
create_esim_profile,
derive_opc,
enc_iccid,
enc_imsi,
generate_iccid,
generate_imsi,
generate_ki,
luhn_checksum,
swap_nibbles,
)
_OP = bytes.fromhex("63bfa50ee6523365ff14c1f45f88737d")
class TestIMSI:
def test_length(self):
assert len(generate_imsi("302", "720")) == 15
def test_prefix(self):
imsi = generate_imsi("302", "720")
assert imsi.startswith("302720")
def test_digits_only(self):
assert generate_imsi("302", "720").isdigit()
def test_uniqueness(self):
imsis = {generate_imsi("302", "720") for _ in range(100)}
assert len(imsis) > 90
def test_different_mcc_mnc(self):
imsi = generate_imsi("001", "01")
assert imsi.startswith("00101")
assert len(imsi) == 15
class TestKi:
def test_length(self):
assert len(generate_ki()) == 16
def test_randomness(self):
keys = {generate_ki() for _ in range(50)}
assert len(keys) == 50
class TestICCID:
def test_length(self):
assert len(generate_iccid()) == 19
def test_digits_only(self):
assert generate_iccid().isdigit()
def test_luhn_valid(self):
iccid = generate_iccid()
assert luhn_checksum(iccid[:-1]) == int(iccid[-1])
def test_starts_with_prefix(self):
assert generate_iccid().startswith("8910")
def test_uniqueness(self):
iccids = {generate_iccid() for _ in range(50)}
assert len(iccids) == 50
class TestOPc:
KI_HEX = "000102030405060708090a0b0c0d0e0f"
def test_output_length(self):
ki = bytes.fromhex(self.KI_HEX)
assert len(derive_opc(ki, _OP)) == 16
def test_deterministic(self):
ki = bytes.fromhex(self.KI_HEX)
assert derive_opc(ki, _OP) == derive_opc(ki, _OP)
def test_different_from_inputs(self):
ki = bytes.fromhex(self.KI_HEX)
opc = derive_opc(ki, _OP)
assert opc != _OP
assert opc != ki
def test_different_ki_gives_different_opc(self):
ki1 = bytes.fromhex(self.KI_HEX)
ki2 = bytes.fromhex("0f0e0d0c0b0a09080706050403020100")
assert derive_opc(ki1, _OP) != derive_opc(ki2, _OP)
class TestProfile:
def _make_profile(self):
ki = generate_ki()
opc = derive_opc(ki, _OP)
imsi = generate_imsi("302", "720")
iccid = generate_iccid()
return create_esim_profile(imsi, ki, opc, iccid, "Test Profile", "Test Carrier")
def test_returns_bytes(self):
assert isinstance(self._make_profile(), bytes)
def test_non_empty(self):
assert len(self._make_profile()) > 0
def test_valid_json(self):
data = json.loads(self._make_profile())
assert "imsi" in data
assert "ki" in data
assert "opc" in data
assert "iccid" in data
def test_imsi_in_profile(self):
ki = generate_ki()
opc = derive_opc(ki, _OP)
imsi = generate_imsi("302", "720")
iccid = generate_iccid()
profile = create_esim_profile(imsi, ki, opc, iccid, "P", "C")
data = json.loads(profile)
assert data["imsi"] == imsi
assert data["iccid"] == iccid
def test_v2_type_and_activation_code(self):
ki = generate_ki()
opc = derive_opc(ki, _OP)
data = json.loads(
create_esim_profile(generate_imsi("302", "720"), ki, opc, generate_iccid(), "P", "C")
)
assert data["type"] == "profile-package-v2"
assert data["activation_code"].startswith("LPA:1$")
assert "iccid" in data["ef"] and "imsi" in data["ef"]
def _dec_nibble_swapped_digits(b: bytes) -> str:
return swap_nibbles(b.hex())
class TestEncoding:
def test_swap_nibbles(self):
assert swap_nibbles("1234") == "2143"
assert swap_nibbles("89") == "98"
def test_swap_nibbles_rejects_odd(self):
with pytest.raises(ValueError):
swap_nibbles("123")
def test_enc_iccid_length_10_bytes(self):
assert len(enc_iccid("8910302720000000001")) == 10
def test_enc_iccid_nibble_swap(self):
# First two ICCID digits "89" -> byte 0x98
assert enc_iccid("8910302720000000001")[0] == 0x98
def test_enc_iccid_roundtrip(self):
iccid = generate_iccid()
decoded = _dec_nibble_swapped_digits(enc_iccid(iccid)).rstrip("f")
assert decoded == iccid
def test_enc_imsi_length_octet(self):
# 15-digit IMSI + 1 parity nibble = 16 nibbles = 8 value bytes
out = enc_imsi("302720974348443")
assert out[0] == 8
assert len(out) == 9 # length octet + 8 value bytes
def test_enc_imsi_roundtrip(self):
imsi = generate_imsi("302", "720")
value = _dec_nibble_swapped_digits(enc_imsi(imsi)[1:])
# first nibble is the parity indicator; the rest is the IMSI
assert value[1:] == imsi
def test_lpa_activation_code_format(self):
code = build_lpa_activation_code("8910302720000000001", "smdp.example")
assert code == "LPA:1$smdp.example$8910302720000000001"

97
tests/test_utils.py Normal file
View File

@ -0,0 +1,97 @@
import pytest
from server.utils import (
ensure_parent_dir,
load_config,
normalize_mac,
validate_imsi,
validate_mac,
validate_op_key,
)
class TestEnsureParentDir:
def test_bare_filename_does_not_crash(self):
# No directory component -> must be a no-op, not FileNotFoundError.
ensure_parent_dir("subscribers.db")
def test_creates_nested_dirs(self, tmp_path):
target = tmp_path / "a" / "b" / "x.db"
ensure_parent_dir(str(target))
assert (tmp_path / "a" / "b").is_dir()
class TestMac:
def test_valid(self):
assert validate_mac("aa:bb:cc:dd:ee:ff")
def test_invalid(self):
assert not validate_mac("nope")
assert not validate_mac("")
def test_normalize_dashes_and_case(self):
assert normalize_mac("AA-BB-CC-DD-EE-FF") == "aa:bb:cc:dd:ee:ff"
def test_normalize_no_separator(self):
assert normalize_mac("aabbccddeeff") == "aa:bb:cc:dd:ee:ff"
class TestImsi:
def test_valid(self):
assert validate_imsi("302720000000001")
def test_wrong_length(self):
assert not validate_imsi("30272000000000")
def test_non_digit(self):
assert not validate_imsi("30272000000000x")
class TestOpKey:
def test_valid(self):
assert validate_op_key("63bfa50ee6523365ff14c1f45f88737d")
def test_wrong_length(self):
assert not validate_op_key("abcd")
def test_non_hex(self):
assert not validate_op_key("zz" * 16)
class TestLoadConfig:
def _base(self):
return (
"network: {mcc: '302', mnc: '720', op_key: '63bfa50ee6523365ff14c1f45f88737d'}\n"
"server: {host: '0.0.0.0', port: 5000}\n"
"database: {path: '/tmp/x.db'}\n"
"rate_limiting: {window_hours: 1}\n"
"profile: {profile_name: 'P', carrier_name: 'C'}\n"
)
def test_valid(self, tmp_path):
p = tmp_path / "c.yaml"
p.write_text(self._base())
cfg = load_config(str(p))
assert cfg["network"]["mcc"] == "302"
def test_missing_file(self):
with pytest.raises(FileNotFoundError):
load_config("/no/such/config.yaml")
def test_empty_file(self, tmp_path):
p = tmp_path / "empty.yaml"
p.write_text("")
with pytest.raises(ValueError, match="empty or not a mapping"):
load_config(str(p))
def test_missing_section(self, tmp_path):
p = tmp_path / "c.yaml"
p.write_text("network: {op_key: '63bfa50ee6523365ff14c1f45f88737d'}\n")
with pytest.raises(ValueError, match="Missing required config section"):
load_config(str(p))
def test_bad_op_key(self, tmp_path):
p = tmp_path / "c.yaml"
p.write_text(self._base().replace("63bfa50ee6523365ff14c1f45f88737d", "abcd"))
with pytest.raises(ValueError, match="op_key"):
load_config(str(p))