From 8672b0de6b93a853430e7101e3a3f1d8c45a81de Mon Sep 17 00:00:00 2001 From: "ashkan@beigi.net" Date: Wed, 1 Jul 2026 05:56:31 +0000 Subject: [PATCH] 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 --- .gitignore | 23 ++ android/README.md | 67 ++++ android/termux_setup.sh | 136 ++++++++ claude.md | 418 +++++++++++++++++++++++++ config.yaml.example | 34 ++ configs/dnsmasq.conf | 23 ++ configs/hostapd.conf | 26 ++ configs/nginx.conf | 44 +++ plan/00-project-structure.md | 85 +++++ plan/01-database.md | 90 ++++++ plan/02-profile-generator.md | 106 +++++++ plan/03-utils.md | 91 ++++++ plan/04-core-adapters.md | 145 +++++++++ plan/05-flask-app.md | 166 ++++++++++ plan/06-captive-portal.md | 157 ++++++++++ plan/07-scripts.md | 159 ++++++++++ plan/08-system-configs.md | 208 +++++++++++++ plan/09-android.md | 155 ++++++++++ plan/10-testing.md | 248 +++++++++++++++ plan/11-readme.md | 160 ++++++++++ plan/design.md | 496 ++++++++++++++++++++++++++++++ portal/css/style.css | 167 ++++++++++ portal/index.html | 58 ++++ portal/js/script.js | 61 ++++ requirements.txt | 7 + scripts/export_subscribers.py | 33 ++ scripts/init_db.py | 22 ++ scripts/setup_wifi_ap.sh | 170 ++++++++++ scripts/sync_subscribers.py | 45 +++ server/__init__.py | 0 server/app.py | 207 +++++++++++++ server/core_adapters/__init__.py | 19 ++ server/core_adapters/base.py | 15 + server/core_adapters/free5gc.py | 11 + server/core_adapters/open5gs.py | 54 ++++ server/database.py | 144 +++++++++ server/models.py | 15 + server/profile_generator.py | 92 ++++++ server/utils.py | 77 +++++ systemd/captive-portal.service | 13 + systemd/esim-provisioning.service | 26 ++ tests/__init__.py | 0 tests/test_core_adapters.py | 76 +++++ tests/test_database.py | 120 ++++++++ tests/test_profile_generator.py | 117 +++++++ 45 files changed, 4586 insertions(+) create mode 100644 .gitignore create mode 100644 android/README.md create mode 100755 android/termux_setup.sh create mode 100644 claude.md create mode 100644 config.yaml.example create mode 100644 configs/dnsmasq.conf create mode 100644 configs/hostapd.conf create mode 100644 configs/nginx.conf create mode 100644 plan/00-project-structure.md create mode 100644 plan/01-database.md create mode 100644 plan/02-profile-generator.md create mode 100644 plan/03-utils.md create mode 100644 plan/04-core-adapters.md create mode 100644 plan/05-flask-app.md create mode 100644 plan/06-captive-portal.md create mode 100644 plan/07-scripts.md create mode 100644 plan/08-system-configs.md create mode 100644 plan/09-android.md create mode 100644 plan/10-testing.md create mode 100644 plan/11-readme.md create mode 100644 plan/design.md create mode 100644 portal/css/style.css create mode 100644 portal/index.html create mode 100644 portal/js/script.js create mode 100644 requirements.txt create mode 100644 scripts/export_subscribers.py create mode 100644 scripts/init_db.py create mode 100755 scripts/setup_wifi_ap.sh create mode 100644 scripts/sync_subscribers.py create mode 100644 server/__init__.py create mode 100644 server/app.py create mode 100644 server/core_adapters/__init__.py create mode 100644 server/core_adapters/base.py create mode 100644 server/core_adapters/free5gc.py create mode 100644 server/core_adapters/open5gs.py create mode 100644 server/database.py create mode 100644 server/models.py create mode 100644 server/profile_generator.py create mode 100644 server/utils.py create mode 100644 systemd/captive-portal.service create mode 100644 systemd/esim-provisioning.service create mode 100644 tests/__init__.py create mode 100644 tests/test_core_adapters.py create mode 100644 tests/test_database.py create mode 100644 tests/test_profile_generator.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..61ed772 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..ec53237 --- /dev/null +++ b/android/README.md @@ -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. diff --git a/android/termux_setup.sh b/android/termux_setup.sh new file mode 100755 index 0000000..f7c2ecc --- /dev/null +++ b/android/termux_setup.sh @@ -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." diff --git a/claude.md b/claude.md new file mode 100644 index 0000000..53f4b7e --- /dev/null +++ b/claude.md @@ -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. \ No newline at end of file diff --git a/config.yaml.example b/config.yaml.example new file mode 100644 index 0000000..832422d --- /dev/null +++ b/config.yaml.example @@ -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" diff --git a/configs/dnsmasq.conf b/configs/dnsmasq.conf new file mode 100644 index 0000000..0df226c --- /dev/null +++ b/configs/dnsmasq.conf @@ -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 diff --git a/configs/hostapd.conf b/configs/hostapd.conf new file mode 100644 index 0000000..c787aa5 --- /dev/null +++ b/configs/hostapd.conf @@ -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 diff --git a/configs/nginx.conf b/configs/nginx.conf new file mode 100644 index 0000000..31e93b0 --- /dev/null +++ b/configs/nginx.conf @@ -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; + } +} diff --git a/plan/00-project-structure.md b/plan/00-project-structure.md new file mode 100644 index 0000000..3a02e04 --- /dev/null +++ b/plan/00-project-structure.md @@ -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')" +``` diff --git a/plan/01-database.md b/plan/01-database.md new file mode 100644 index 0000000..19aa2a1 --- /dev/null +++ b/plan/01-database.md @@ -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) +" +``` diff --git a/plan/02-profile-generator.md b/plan/02-profile-generator.md new file mode 100644 index 0000000..116acb1 --- /dev/null +++ b/plan/02-profile-generator.md @@ -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') +" +``` diff --git a/plan/03-utils.md b/plan/03-utils.md new file mode 100644 index 0000000..b8a6e7e --- /dev/null +++ b/plan/03-utils.md @@ -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') +" +``` diff --git a/plan/04-core-adapters.md b/plan/04-core-adapters.md new file mode 100644 index 0000000..6c1849d --- /dev/null +++ b/plan/04-core-adapters.md @@ -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__}') +" +``` diff --git a/plan/05-flask-app.md b/plan/05-flask-app.md new file mode 100644 index 0000000..a608ba5 --- /dev/null +++ b/plan/05-flask-app.md @@ -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/` | 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/` — 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 +``` diff --git a/plan/06-captive-portal.md b/plan/06-captive-portal.md new file mode 100644 index 0000000..7ce68e8 --- /dev/null +++ b/plan/06-captive-portal.md @@ -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 + + + + + + eSIM Activation + + + +
+ +

Emergency 5G Network

+

Activate your free eSIM to connect

+ + +
+ +

Tap to generate your personal eSIM profile

+
+ + + + + + + + + +
+ + + +``` + +--- + +## `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 +``` diff --git a/plan/07-scripts.md b/plan/07-scripts.md new file mode 100644 index 0000000..b5f2324 --- /dev/null +++ b/plan/07-scripts.md @@ -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 +``` diff --git a/plan/08-system-configs.md b/plan/08-system-configs.md new file mode 100644 index 0000000..8cb7a1c --- /dev/null +++ b/plan/08-system-configs.md @@ -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 +``` diff --git a/plan/09-android.md b/plan/09-android.md new file mode 100644 index 0000000..d9ff822 --- /dev/null +++ b/plan/09-android.md @@ -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 +``` diff --git a/plan/10-testing.md b/plan/10-testing.md new file mode 100644 index 0000000..4a529c2 --- /dev/null +++ b/plan/10-testing.md @@ -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. diff --git a/plan/11-readme.md b/plan/11-readme.md new file mode 100644 index 0000000..1992475 --- /dev/null +++ b/plan/11-readme.md @@ -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 +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/` | 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 diff --git a/plan/design.md b/plan/design.md new file mode 100644 index 0000000..18e106b --- /dev/null +++ b/plan/design.md @@ -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/ \ No newline at end of file diff --git a/portal/css/style.css b/portal/css/style.css new file mode 100644 index 0000000..e66b1a4 --- /dev/null +++ b/portal/css/style.css @@ -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; +} diff --git a/portal/index.html b/portal/index.html new file mode 100644 index 0000000..5dd0323 --- /dev/null +++ b/portal/index.html @@ -0,0 +1,58 @@ + + + + + + Emergency 5G — eSIM Activation + + + +
+ +

Emergency 5G Network

+

Activate your free eSIM to connect to the 5G network

+ + +
+ +

Tap to generate your personal eSIM profile

+
+ + + + + + + + + +
+ + + + diff --git a/portal/js/script.js b/portal/js/script.js new file mode 100644 index 0000000..e71488d --- /dev/null +++ b/portal/js/script.js @@ -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'); }); +}); diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1a61aee --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/scripts/export_subscribers.py b/scripts/export_subscribers.py new file mode 100644 index 0000000..6847e6f --- /dev/null +++ b/scripts/export_subscribers.py @@ -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() diff --git a/scripts/init_db.py b/scripts/init_db.py new file mode 100644 index 0000000..af9ab84 --- /dev/null +++ b/scripts/init_db.py @@ -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() diff --git a/scripts/setup_wifi_ap.sh b/scripts/setup_wifi_ap.sh new file mode 100755 index 0000000..11f39f9 --- /dev/null +++ b/scripts/setup_wifi_ap.sh @@ -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" diff --git a/scripts/sync_subscribers.py b/scripts/sync_subscribers.py new file mode 100644 index 0000000..508a7a9 --- /dev/null +++ b/scripts/sync_subscribers.py @@ -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() diff --git a/server/__init__.py b/server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/app.py b/server/app.py new file mode 100644 index 0000000..ff8ff44 --- /dev/null +++ b/server/app.py @@ -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/") + 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), + ) diff --git a/server/core_adapters/__init__.py b/server/core_adapters/__init__.py new file mode 100644 index 0000000..b1b0857 --- /dev/null +++ b/server/core_adapters/__init__.py @@ -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 diff --git a/server/core_adapters/base.py b/server/core_adapters/base.py new file mode 100644 index 0000000..7de738c --- /dev/null +++ b/server/core_adapters/base.py @@ -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.""" diff --git a/server/core_adapters/free5gc.py b/server/core_adapters/free5gc.py new file mode 100644 index 0000000..64e7b14 --- /dev/null +++ b/server/core_adapters/free5gc.py @@ -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) diff --git a/server/core_adapters/open5gs.py b/server/core_adapters/open5gs.py new file mode 100644 index 0000000..b282817 --- /dev/null +++ b/server/core_adapters/open5gs.py @@ -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})) diff --git a/server/database.py b/server/database.py new file mode 100644 index 0000000..b2455be --- /dev/null +++ b/server/database.py @@ -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 diff --git a/server/models.py b/server/models.py new file mode 100644 index 0000000..1cac631 --- /dev/null +++ b/server/models.py @@ -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 diff --git a/server/profile_generator.py b/server/profile_generator.py new file mode 100644 index 0000000..894e438 --- /dev/null +++ b/server/profile_generator.py @@ -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") diff --git a/server/utils.py b/server/utils.py new file mode 100644 index 0000000..7c3a457 --- /dev/null +++ b/server/utils.py @@ -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 diff --git a/systemd/captive-portal.service b/systemd/captive-portal.service new file mode 100644 index 0000000..ba3d54a --- /dev/null +++ b/systemd/captive-portal.service @@ -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 diff --git a/systemd/esim-provisioning.service b/systemd/esim-provisioning.service new file mode 100644 index 0000000..346f272 --- /dev/null +++ b/systemd/esim-provisioning.service @@ -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 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_core_adapters.py b/tests/test_core_adapters.py new file mode 100644 index 0000000..02c893a --- /dev/null +++ b/tests/test_core_adapters.py @@ -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" diff --git a/tests/test_database.py b/tests/test_database.py new file mode 100644 index 0000000..c92c9a5 --- /dev/null +++ b/tests/test_database.py @@ -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) == [] diff --git a/tests/test_profile_generator.py b/tests/test_profile_generator.py new file mode 100644 index 0000000..9abdc14 --- /dev/null +++ b/tests/test_profile_generator.py @@ -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