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>
This commit is contained in:
A ashkan@beigi.net 2026-07-01 05:56:31 +00:00
parent f4af2193da
commit 8672b0de6b
45 changed files with 4586 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

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.

34
config.yaml.example Normal file
View File

@ -0,0 +1,34 @@
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: "none" # "open5gs" | "free5gc" | "none"
mongodb_uri: "mongodb://localhost:27017"
database_name: "open5gs"
profile:
carrier_name: "Ad-Hoc Network"
profile_name: "Emergency 5G"
apn: "internet"

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>

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

@ -0,0 +1,61 @@
'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');
}
async function activate() {
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.');
}
}
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 load_config
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()

170
scripts/setup_wifi_ap.sh Executable file
View File

@ -0,0 +1,170 @@
#!/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
mkdir -p "$INSTALL_DIR/data"
chown -R esim:esim "$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"
# 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

207
server/app.py Normal file
View File

@ -0,0 +1,207 @@
import logging
import os
import time
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,
get_subscriber_by_id,
init_db,
)
from server.profile_generator import (
create_esim_profile,
derive_opc,
generate_iccid,
generate_imsi,
generate_ki,
)
from server.utils import (
bytes_to_hex,
get_client_ip,
get_mac_from_arp,
hex_to_bytes,
load_config,
normalize_mac,
validate_mac,
)
logger = logging.getLogger(__name__)
CONFIG_PATH = os.environ.get("CONFIG_PATH", "config.yaml")
_profile_cache: dict[int, bytes] = {}
_start_time = time.time()
def create_app(config_path: str = CONFIG_PATH) -> Flask:
cfg = load_config(config_path)
portal_dir = os.path.join(os.path.dirname(__file__), "..", "portal")
portal_dir = os.path.abspath(portal_dir)
app = Flask(__name__, static_folder=portal_dir, static_url_path="")
db_path = cfg["database"]["path"]
os.makedirs(os.path.dirname(db_path), exist_ok=True)
init_db(db_path)
adapter = get_adapter(cfg)
if adapter:
logger.info(f"Core adapter: {adapter.__class__.__name__}")
else:
logger.info("Core adapter: none (export-only mode)")
net_cfg = cfg["network"]
mcc = net_cfg["mcc"]
mnc = net_cfg["mnc"]
op_bytes = hex_to_bytes(net_cfg["op_key"])
profile_cfg = cfg["profile"]
profile_name = profile_cfg["profile_name"]
carrier_name = profile_cfg["carrier_name"]
rl_cfg = cfg["rate_limiting"]
window_hours = rl_cfg["window_hours"]
# ------------------------------------------------------------------
# Routes
# ------------------------------------------------------------------
@app.route("/")
def index():
return redirect("/activate", code=302)
@app.route("/activate")
def activate():
return send_from_directory(portal_dir, "index.html")
@app.route("/api/provision", methods=["POST"])
def provision():
body = request.get_json(silent=True) or {}
mac = body.get("mac_address", "").strip()
if not mac:
client_ip = get_client_ip(request)
mac = get_mac_from_arp(client_ip) or ""
if not mac:
return jsonify({"error": "Could not determine MAC address"}), 400
mac = normalize_mac(mac)
if not validate_mac(mac):
return jsonify({"error": "Invalid MAC address"}), 400
conn = get_connection(db_path)
try:
if not check_rate_limit(conn, mac, window_hours):
return jsonify(
{"error": "Rate limit exceeded. One eSIM profile per device per hour."}
), 429
imsi = generate_imsi(mcc, mnc)
ki = generate_ki()
iccid = generate_iccid()
opc = derive_opc(ki, op_bytes)
ki_hex = bytes_to_hex(ki)
opc_hex = bytes_to_hex(opc)
profile_bytes = create_esim_profile(
imsi, ki, opc, iccid, profile_name, carrier_name
)
sub_id = add_subscriber(conn, imsi, iccid, ki_hex, opc_hex, mac)
_profile_cache[sub_id] = 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/{sub_id}",
}
), 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/<int:sub_id>")
def get_profile(sub_id: int):
profile_bytes = _profile_cache.get(sub_id)
if profile_bytes is None:
conn = get_connection(db_path)
sub = get_subscriber_by_id(conn, sub_id)
conn.close()
if sub is None:
return jsonify({"error": "Profile not found"}), 404
return jsonify({"error": "Profile no longer cached; re-provision to download"}), 410
import io
return send_file(
io.BytesIO(profile_bytes),
mimetype="application/octet-stream",
as_attachment=True,
download_name="esim-profile.bin",
)
@app.route("/api/export")
def export():
conn = get_connection(db_path)
try:
subscribers = export_all(conn)
finally:
conn.close()
return jsonify(
{
"exported_at": datetime.now(timezone.utc).isoformat(),
"count": len(subscribers),
"subscribers": subscribers,
}
)
@app.route("/api/status")
def status():
conn = get_connection(db_path)
try:
count = conn.execute("SELECT COUNT(*) FROM subscribers").fetchone()[0]
finally:
conn.close()
return jsonify(
{
"status": "ok",
"subscriber_count": count,
"uptime_seconds": int(time.time() - _start_time),
"core_adapter": adapter.__class__.__name__ if adapter else "none",
}
)
return app
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
cfg = load_config(CONFIG_PATH)
app = create_app(CONFIG_PATH)
app.run(
host=cfg["server"]["host"],
port=cfg["server"]["port"],
debug=cfg["server"].get("debug", False),
)

View File

@ -0,0 +1,19 @@
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")
if core_type == "open5gs":
from .open5gs import Open5GSAdapter
return Open5GSAdapter(uri, db_name)
elif core_type == "free5gc":
from .free5gc import Free5GCAdapter
return Free5GCAdapter(uri, db_name)
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,11 @@
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"):
super().__init__(mongodb_uri, database_name)

View File

@ -0,0 +1,54 @@
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:
"""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": "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:
return list(self.db.subscribers.find({}, {"_id": 0}))

144
server/database.py Normal file
View File

@ -0,0 +1,144 @@
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."""
conn = sqlite3.connect(db_path, detect_types=sqlite3.PARSE_DECLTYPES)
conn.row_factory = sqlite3.Row
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

View File

@ -0,0 +1,92 @@
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 create_esim_profile(
imsi: str,
ki: bytes,
opc: bytes,
iccid: str,
profile_name: str,
carrier_name: str,
) -> bytes:
"""Create an eSIM profile.
Phase 1: returns a JSON-encoded mock profile suitable for end-to-end
testing. Phase 2 (Unit 06 upgrade): replace with real pySim SGP.22
profile generation.
"""
ki_hex = ki.hex() if isinstance(ki, bytes) else ki
opc_hex = opc.hex() if isinstance(opc, bytes) else opc
profile = {
"version": "1.0",
"type": "mock",
"iccid": iccid,
"imsi": imsi,
"ki": ki_hex,
"opc": opc_hex,
"profile_name": profile_name,
"carrier_name": carrier_name,
}
logger.debug(f"Created mock eSIM profile for IMSI {imsi}")
return json.dumps(profile, indent=2).encode("utf-8")

77
server/utils.py Normal file
View File

@ -0,0 +1,77 @@
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 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)
for key in _REQUIRED_CONFIG_KEYS:
if key not in cfg:
raise ValueError(f"Missing required config section: '{key}'")
return cfg
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

View File

@ -0,0 +1,76 @@
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)
@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_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,117 @@
import json
import pytest
from server.profile_generator import (
create_esim_profile,
derive_opc,
generate_iccid,
generate_imsi,
generate_ki,
luhn_checksum,
)
_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