Add Phase-1 eSIM provisioning implementation
Full working closed-loop eSIM provisioning stack (spec in claude.md / plan/): Flask API, SQLite subscriber store, credential/profile generator, Open5GS and free5gc core adapters, captive portal, operational scripts, systemd units, network configs, and a 44-test suite. Profile generation currently returns a mock JSON profile; real SGP.22 generation via pySim is the planned Phase-2 upgrade. Fixes two bugs found while getting the suite green: - generate_imsi hardcoded a 10-digit MSIN, producing 16-digit IMSIs for a 3-digit MNC; MSIN length is now 15 - len(mcc) - len(mnc). - check_rate_limit compared timestamps as strings across mismatched formats (SQLite CURRENT_TIMESTAMP vs Python isoformat); both sides now normalized via SQLite datetime() so the window check is chronologically correct. Add .gitignore for venv, caches, runtime config.yaml, and databases. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
f4af2193da
commit
8672b0de6b
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
|
||||
# Local runtime config (contains network secrets); commit config.yaml.example instead
|
||||
config.yaml
|
||||
|
||||
# Databases / runtime data
|
||||
*.db
|
||||
*.sqlite3
|
||||
data/
|
||||
|
||||
# OS / editor
|
||||
.DS_Store
|
||||
*.swp
|
||||
67
android/README.md
Normal file
67
android/README.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# Android / Termux Deployment
|
||||
|
||||
Run the eSIM provisioning server on an Android device using [Termux](https://termux.dev) (no root required for basic operation).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Android 9 or later
|
||||
- **Termux** installed from [F-Droid](https://f-droid.org/packages/com.termux/) (not the Play Store version — it is outdated)
|
||||
- Optional: **Termux:Boot** (F-Droid) for automatic start on reboot
|
||||
- 5G-capable device with a pre-provisioned SIM for gNB backhaul
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
# In Termux, from the repo root:
|
||||
bash android/termux_setup.sh
|
||||
```
|
||||
|
||||
The script will:
|
||||
1. Update Termux packages
|
||||
2. Install Python, nginx, and required libraries
|
||||
3. Deploy the server files to `~/esim-provisioning/`
|
||||
4. Write a localised `config.yaml`
|
||||
5. Configure nginx on port 8080
|
||||
6. Initialise the SQLite database
|
||||
|
||||
## Start the Server
|
||||
|
||||
```bash
|
||||
~/esim-provisioning/start.sh
|
||||
```
|
||||
|
||||
## WiFi Hotspot
|
||||
|
||||
1. Go to **Settings → Network → Hotspot & Tethering → WiFi Hotspot**
|
||||
2. Set a name (e.g. `Emergency-5G`) and **remove the password** (open network)
|
||||
3. Enable the hotspot
|
||||
|
||||
Clients connect to the hotspot, then navigate to:
|
||||
```
|
||||
http://192.168.43.1:8080/activate
|
||||
```
|
||||
|
||||
> Most Android devices use `192.168.43.1` as the hotspot gateway. Samsung and Pixel devices may differ — check with `ip route` in Termux and update `config.yaml` accordingly.
|
||||
|
||||
## Captive Portal Limitation
|
||||
|
||||
Without root, full DNS hijacking is not possible. Clients will **not** be automatically redirected — they must open the URL manually or scan a QR code posted at the deployment site.
|
||||
|
||||
With root + Termux, install `dnsmasq` via `pkg install dnsmasq` and add:
|
||||
```
|
||||
address=/#/192.168.43.1
|
||||
```
|
||||
to `/data/data/com.termux/files/usr/etc/dnsmasq.conf`, then run `dnsmasq`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit `~/esim-provisioning/config.yaml` to set your MCC, MNC, and OP key, then restart:
|
||||
```bash
|
||||
pkill -f server.app
|
||||
CONFIG_PATH=~/esim-provisioning/config.yaml python -m server.app &
|
||||
```
|
||||
|
||||
## Auto-Start on Reboot (Termux:Boot)
|
||||
|
||||
Install **Termux:Boot** from F-Droid. The setup script copies `start.sh` to
|
||||
`~/.termux/boot/` automatically. Open Termux:Boot once to enable it.
|
||||
136
android/termux_setup.sh
Executable file
136
android/termux_setup.sh
Executable file
|
|
@ -0,0 +1,136 @@
|
|||
#!/data/data/com.termux/files/usr/bin/bash
|
||||
# termux_setup.sh — Deploy the eSIM provisioning server on Android via Termux.
|
||||
# Run from the repo root: bash android/termux_setup.sh
|
||||
set -euo pipefail
|
||||
|
||||
INSTALL_DIR="$HOME/esim-provisioning"
|
||||
FLASK_PORT=5000
|
||||
NGINX_PORT=8080
|
||||
# Default Android hotspot gateway — adjust if your device differs
|
||||
AP_IP="${ESIM_AP_IP:-192.168.43.1}"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
echo "=== eSIM Provisioning — Termux Setup ==="
|
||||
echo " Install dir: $INSTALL_DIR"
|
||||
echo " AP IP: $AP_IP"
|
||||
echo ""
|
||||
|
||||
# ---- 1. Update and install packages ----
|
||||
echo "[1/5] Installing Termux packages..."
|
||||
pkg update -y
|
||||
pkg install -y python python-pip nginx
|
||||
|
||||
# ---- 2. Install Python dependencies ----
|
||||
echo "[2/5] Installing Python dependencies..."
|
||||
pip install --quiet flask cryptography pymongo pyyaml requests
|
||||
|
||||
# ---- 3. Deploy server files ----
|
||||
echo "[3/5] Deploying server files..."
|
||||
mkdir -p "$INSTALL_DIR"/{server/core_adapters,portal/{css,js,assets},scripts,tests,data}
|
||||
|
||||
cp -r "$SCRIPT_DIR/server/"* "$INSTALL_DIR/server/"
|
||||
cp -r "$SCRIPT_DIR/portal/"* "$INSTALL_DIR/portal/"
|
||||
cp -r "$SCRIPT_DIR/scripts/"* "$INSTALL_DIR/scripts/"
|
||||
cp "$SCRIPT_DIR/requirements.txt" "$INSTALL_DIR/"
|
||||
|
||||
# Write a localised config
|
||||
cat > "$INSTALL_DIR/config.yaml" << EOF
|
||||
network:
|
||||
mcc: "302"
|
||||
mnc: "720"
|
||||
plmn: "302720"
|
||||
op_key: "63bfa50ee6523365ff14c1f45f88737d"
|
||||
|
||||
wifi:
|
||||
ssid: "Emergency-5G"
|
||||
ip: "$AP_IP"
|
||||
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: $FLASK_PORT
|
||||
debug: false
|
||||
|
||||
database:
|
||||
path: "$INSTALL_DIR/data/subscribers.db"
|
||||
|
||||
rate_limiting:
|
||||
max_profiles_per_mac: 1
|
||||
window_hours: 1
|
||||
|
||||
core:
|
||||
type: "none"
|
||||
|
||||
profile:
|
||||
carrier_name: "Ad-Hoc Network"
|
||||
profile_name: "Emergency 5G"
|
||||
apn: "internet"
|
||||
EOF
|
||||
|
||||
# ---- 4. Configure nginx ----
|
||||
echo "[4/5] Configuring nginx..."
|
||||
mkdir -p "$PREFIX/etc/nginx/conf.d"
|
||||
cat > "$PREFIX/etc/nginx/nginx.conf" << EOF
|
||||
worker_processes 1;
|
||||
events { worker_connections 64; }
|
||||
http {
|
||||
include mime.types;
|
||||
server {
|
||||
listen $NGINX_PORT default_server;
|
||||
server_name _;
|
||||
|
||||
location = / { return 302 http://$AP_IP:$NGINX_PORT/activate; }
|
||||
|
||||
location /activate {
|
||||
alias $INSTALL_DIR/portal/;
|
||||
index index.html;
|
||||
}
|
||||
location /css/ { alias $INSTALL_DIR/portal/css/; }
|
||||
location /js/ { alias $INSTALL_DIR/portal/js/; }
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:$FLASK_PORT;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location / { return 302 http://$AP_IP:$NGINX_PORT/activate; }
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# ---- 5. Initialise database and create start script ----
|
||||
echo "[5/5] Initialising database and writing start script..."
|
||||
|
||||
CONFIG_PATH="$INSTALL_DIR/config.yaml" python "$INSTALL_DIR/scripts/init_db.py"
|
||||
|
||||
cat > "$INSTALL_DIR/start.sh" << 'STARTEOF'
|
||||
#!/data/data/com.termux/files/usr/bin/bash
|
||||
cd "$HOME/esim-provisioning"
|
||||
echo "Starting nginx..."
|
||||
nginx
|
||||
echo "Starting Flask provisioning server..."
|
||||
CONFIG_PATH="$HOME/esim-provisioning/config.yaml" python -m server.app &
|
||||
echo ""
|
||||
echo "Server running. Connect to Android hotspot then open:"
|
||||
echo " http://192.168.43.1:8080/activate"
|
||||
echo ""
|
||||
echo "To stop: pkill nginx; pkill -f server.app"
|
||||
STARTEOF
|
||||
chmod +x "$INSTALL_DIR/start.sh"
|
||||
|
||||
# Termux:Boot integration
|
||||
BOOT_DIR="$HOME/.termux/boot"
|
||||
mkdir -p "$BOOT_DIR"
|
||||
cp "$INSTALL_DIR/start.sh" "$BOOT_DIR/esim-provisioning.sh"
|
||||
|
||||
echo ""
|
||||
echo "=== Setup Complete ==="
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Enable WiFi Hotspot in Android Settings (any SSID, no password)"
|
||||
echo " 2. Run: ~/esim-provisioning/start.sh"
|
||||
echo " 3. Clients connect to hotspot and visit:"
|
||||
echo " http://$AP_IP:$NGINX_PORT/activate"
|
||||
echo ""
|
||||
echo "Install Termux:Boot (from F-Droid) for auto-start on device reboot."
|
||||
418
claude.md
Normal file
418
claude.md
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
# eSIM Provisioning System - Project Setup Instructions
|
||||
|
||||
## Project Overview
|
||||
Build a closed-loop eSIM provisioning system for emergency/NTN 5G deployments. System runs on a UE device (Android or Raspberry Pi) that connects to a 5G gNB and provides WiFi AP with automatic eSIM provisioning.
|
||||
|
||||
## Repository Structure
|
||||
Create the following directory structure:
|
||||
|
||||
```
|
||||
esim-provisioning/
|
||||
├── README.md
|
||||
├── requirements.txt
|
||||
├── config.yaml.example
|
||||
├── docker-compose.yml (optional)
|
||||
├── server/
|
||||
│ ├── __init__.py
|
||||
│ ├── app.py # Main Flask application
|
||||
│ ├── profile_generator.py # eSIM profile generation using pySim
|
||||
│ ├── database.py # SQLite database interface
|
||||
│ ├── core_adapters/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── base.py # Base adapter class
|
||||
│ │ ├── open5gs.py # Open5GS adapter
|
||||
│ │ └── free5gc.py # free5gc adapter
|
||||
│ ├── models.py # Database models
|
||||
│ └── utils.py # Helper functions
|
||||
├── portal/
|
||||
│ ├── index.html # Captive portal landing page
|
||||
│ ├── css/
|
||||
│ │ └── style.css
|
||||
│ ├── js/
|
||||
│ │ └── script.js
|
||||
│ └── assets/
|
||||
│ └── (images if needed)
|
||||
├── scripts/
|
||||
│ ├── init_db.py # Initialize database
|
||||
│ ├── sync_subscribers.py # Sync to core network
|
||||
│ ├── export_subscribers.py # Export subscriber data
|
||||
│ └── setup_wifi_ap.sh # WiFi AP setup (for Pi)
|
||||
├── systemd/
|
||||
│ ├── esim-provisioning.service
|
||||
│ └── captive-portal.service
|
||||
├── configs/
|
||||
│ ├── nginx.conf # nginx captive portal config
|
||||
│ ├── dnsmasq.conf # DNS/DHCP config
|
||||
│ └── hostapd.conf # WiFi AP config (for Pi)
|
||||
├── android/
|
||||
│ ├── README.md # Android setup instructions
|
||||
│ └── termux_setup.sh # Termux installation script
|
||||
└── tests/
|
||||
├── test_profile_generator.py
|
||||
├── test_database.py
|
||||
└── test_core_adapters.py
|
||||
```
|
||||
|
||||
## Step 1: Initialize Project
|
||||
|
||||
Create the repository structure and initialize Python virtual environment:
|
||||
|
||||
```bash
|
||||
# Create project directory
|
||||
mkdir esim-provisioning
|
||||
cd esim-provisioning
|
||||
|
||||
# Initialize git
|
||||
git init
|
||||
|
||||
# Create virtual environment
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
# Create all directories
|
||||
mkdir -p server/core_adapters portal/css portal/js portal/assets scripts systemd configs android tests
|
||||
```
|
||||
|
||||
## Step 2: Create Requirements File
|
||||
|
||||
Create `requirements.txt` with:
|
||||
```
|
||||
Flask==3.0.0
|
||||
pySim==1.0.0
|
||||
cryptography==41.0.7
|
||||
pymongo==4.6.0
|
||||
requests==2.31.0
|
||||
pyyaml==6.0.1
|
||||
```
|
||||
|
||||
## Step 3: Create Configuration Template
|
||||
|
||||
Create `config.yaml.example`:
|
||||
```yaml
|
||||
network:
|
||||
mcc: "302"
|
||||
mnc: "720"
|
||||
plmn: "302720"
|
||||
op_key: "63bfa50ee6523365ff14c1f45f88737d" # Hex string
|
||||
|
||||
wifi:
|
||||
ssid: "Emergency-5G"
|
||||
channel: 6
|
||||
ip: "192.168.4.1"
|
||||
subnet: "192.168.4.0/24"
|
||||
dhcp_range: "192.168.4.10,192.168.4.250"
|
||||
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 5000
|
||||
debug: false
|
||||
|
||||
database:
|
||||
path: "/opt/esim-provisioning/data/subscribers.db"
|
||||
|
||||
rate_limiting:
|
||||
max_profiles_per_mac: 1
|
||||
window_hours: 1
|
||||
|
||||
core:
|
||||
type: "open5gs" # or "free5gc"
|
||||
mongodb_uri: "mongodb://localhost:27017"
|
||||
database_name: "open5gs"
|
||||
|
||||
profile:
|
||||
carrier_name: "Ad-Hoc Network"
|
||||
profile_name: "Emergency 5G"
|
||||
apn: "internet"
|
||||
```
|
||||
|
||||
## Step 4: Core Server Components
|
||||
|
||||
### database.py
|
||||
Create database interface with:
|
||||
- SQLite connection management
|
||||
- Schema creation (subscribers table)
|
||||
- CRUD operations for subscribers
|
||||
- Rate limiting checks by MAC address
|
||||
- Export functionality
|
||||
|
||||
Schema:
|
||||
```sql
|
||||
CREATE TABLE subscribers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
imsi TEXT UNIQUE NOT NULL,
|
||||
iccid TEXT UNIQUE NOT NULL,
|
||||
ki TEXT NOT NULL,
|
||||
opc TEXT NOT NULL,
|
||||
mac_address TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
synced_to_core BOOLEAN DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX idx_imsi ON subscribers(imsi);
|
||||
CREATE INDEX idx_mac_address ON subscribers(mac_address);
|
||||
```
|
||||
|
||||
### profile_generator.py
|
||||
Implement eSIM profile generation:
|
||||
- Generate random IMSI (15 digits: MCC + MNC + MSIN)
|
||||
- Generate random Ki (16 bytes)
|
||||
- Derive OPc from Ki and OP using Milenage algorithm
|
||||
- Generate ICCID (19-20 digits)
|
||||
- Create SGP.22 eSIM profile using pySim
|
||||
- Return profile bytes and credentials
|
||||
|
||||
Key functions:
|
||||
```python
|
||||
def generate_imsi(mcc, mnc) -> str
|
||||
def generate_iccid() -> str
|
||||
def generate_ki() -> bytes
|
||||
def derive_opc(ki: bytes, op: bytes) -> bytes
|
||||
def create_esim_profile(imsi, ki, opc, iccid, profile_name, carrier_name) -> bytes
|
||||
```
|
||||
|
||||
### core_adapters/base.py
|
||||
Define abstract base class:
|
||||
```python
|
||||
class CoreAdapter(ABC):
|
||||
@abstractmethod
|
||||
def add_subscriber(self, imsi, ki, opc):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def remove_subscriber(self, imsi):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def list_subscribers(self):
|
||||
pass
|
||||
```
|
||||
|
||||
### core_adapters/open5gs.py
|
||||
Implement Open5GS adapter using MongoDB:
|
||||
- Connect to MongoDB
|
||||
- Insert subscriber with proper schema
|
||||
- Configure slice, APN, AMBR settings
|
||||
- Handle errors gracefully
|
||||
|
||||
### app.py
|
||||
Main Flask application with endpoints:
|
||||
|
||||
```python
|
||||
GET / # Captive portal redirect
|
||||
GET /activate # Serve captive portal HTML
|
||||
POST /api/provision # Generate and provision eSIM
|
||||
GET /api/profile/:id # Download eSIM profile (LPA)
|
||||
GET /api/export # Export subscribers JSON
|
||||
GET /api/status # System status
|
||||
```
|
||||
|
||||
Implement:
|
||||
- Rate limiting middleware (check MAC address)
|
||||
- Profile generation workflow
|
||||
- LPA protocol handling
|
||||
- Error handling and logging
|
||||
|
||||
## Step 5: Captive Portal
|
||||
|
||||
### portal/index.html
|
||||
Simple single-page interface:
|
||||
- "Activate eSIM" button
|
||||
- Instructions for installation
|
||||
- Progress indicator
|
||||
- Success/error messages
|
||||
- Mobile-friendly responsive design
|
||||
|
||||
### portal/js/script.js
|
||||
Client-side logic:
|
||||
- POST to `/api/provision` with MAC address
|
||||
- Poll for profile generation status
|
||||
- Trigger eSIM download via LPA URL scheme
|
||||
- Handle errors
|
||||
|
||||
## Step 6: System Scripts
|
||||
|
||||
### scripts/init_db.py
|
||||
Initialize SQLite database with schema
|
||||
|
||||
### scripts/sync_subscribers.py
|
||||
Sync subscribers to core:
|
||||
```python
|
||||
# Read from SQLite
|
||||
# For each unsynced subscriber:
|
||||
# - Call core adapter
|
||||
# - Mark as synced
|
||||
# - Log result
|
||||
```
|
||||
|
||||
### scripts/export_subscribers.py
|
||||
Export to JSON format:
|
||||
```json
|
||||
{
|
||||
"exported_at": "2025-02-11T10:30:00Z",
|
||||
"count": 123,
|
||||
"subscribers": [
|
||||
{
|
||||
"imsi": "302720123456789",
|
||||
"ki": "00112233445566778899aabbccddeeff",
|
||||
"opc": "63bfa50ee6523365ff14c1f45f88737d",
|
||||
"iccid": "8910302720123456789",
|
||||
"created_at": "2025-02-11T09:15:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### scripts/setup_wifi_ap.sh
|
||||
Bash script for Raspberry Pi WiFi AP setup:
|
||||
- Install hostapd, dnsmasq, nginx
|
||||
- Configure network interfaces
|
||||
- Set up iptables for captive portal
|
||||
- Enable IP forwarding
|
||||
- Configure services
|
||||
|
||||
## Step 7: Configuration Files
|
||||
|
||||
### configs/nginx.conf
|
||||
Configure captive portal:
|
||||
- Listen on port 80
|
||||
- Catch-all server block
|
||||
- Redirect to /activate
|
||||
- Proxy /api/* to Flask backend
|
||||
|
||||
### configs/dnsmasq.conf
|
||||
DNS/DHCP configuration:
|
||||
- Interface binding
|
||||
- DHCP range
|
||||
- DNS hijacking (return WiFi AP IP for all domains)
|
||||
- Lease time
|
||||
|
||||
### configs/hostapd.conf
|
||||
WiFi AP configuration:
|
||||
- SSID
|
||||
- Channel
|
||||
- Interface
|
||||
- Open network (no password)
|
||||
|
||||
## Step 8: Systemd Services
|
||||
|
||||
### systemd/esim-provisioning.service
|
||||
Flask server service:
|
||||
- Run as dedicated user
|
||||
- Auto-restart on failure
|
||||
- Log to journal
|
||||
- Load config from /etc/esim-provisioning/config.yaml
|
||||
|
||||
### systemd/captive-portal.service
|
||||
Nginx + dnsmasq orchestration
|
||||
|
||||
## Step 9: Testing
|
||||
|
||||
Create unit tests for:
|
||||
- Profile generation (valid IMSI, Ki, OPc formats)
|
||||
- Database operations (CRUD, rate limiting)
|
||||
- Core adapters (mock MongoDB)
|
||||
- API endpoints (Flask test client)
|
||||
|
||||
## Step 10: Documentation
|
||||
|
||||
### README.md
|
||||
Include:
|
||||
- Project overview
|
||||
- System requirements
|
||||
- Installation instructions (Pi and Android)
|
||||
- Configuration guide
|
||||
- Usage instructions
|
||||
- Troubleshooting
|
||||
- Architecture diagram
|
||||
- License
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
1. **First:** Database layer + models
|
||||
2. **Second:** Profile generator (without pySim, use mock for testing)
|
||||
3. **Third:** Flask API with all endpoints
|
||||
4. **Fourth:** Core adapters (Open5GS first)
|
||||
5. **Fifth:** Captive portal frontend
|
||||
6. **Sixth:** Integration with pySim for real profile generation
|
||||
7. **Seventh:** System scripts and configs
|
||||
8. **Eighth:** Testing and documentation
|
||||
|
||||
## Key Implementation Notes
|
||||
|
||||
- Use environment variables or config.yaml for all configuration (never hardcode)
|
||||
- Log everything (use Python logging module)
|
||||
- Handle all errors gracefully (no stack traces to user)
|
||||
- Validate all inputs (IMSI format, MAC address format)
|
||||
- Use secrets module for cryptographic randomness
|
||||
- Make core adapters truly modular (easy to add new cores)
|
||||
- Keep captive portal extremely simple (mobile-first)
|
||||
- Rate limiting must be robust (prevent abuse)
|
||||
- Database operations should be atomic
|
||||
- Profile generation should be fast (<1 second)
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Can generate valid IMSI
|
||||
- [ ] Can generate valid Ki/OPc
|
||||
- [ ] Can create eSIM profile
|
||||
- [ ] Can store subscriber in database
|
||||
- [ ] Rate limiting works (same MAC can't request multiple times)
|
||||
- [ ] Can export subscribers to JSON
|
||||
- [ ] Open5GS adapter can add subscriber
|
||||
- [ ] API endpoints return correct responses
|
||||
- [ ] Captive portal loads on mobile
|
||||
- [ ] Profile downloads correctly
|
||||
- [ ] End-to-end: WiFi → provision → 5G connection
|
||||
|
||||
## Deployment Targets
|
||||
|
||||
**Raspberry Pi:**
|
||||
- Raspberry Pi 4 (4GB+ RAM)
|
||||
- Quectel RM500Q 5G modem
|
||||
- Raspberry Pi OS Lite 64-bit
|
||||
- Python 3.11+
|
||||
|
||||
**Android:**
|
||||
- Android 10+ with root/Termux
|
||||
- 5G modem built-in
|
||||
- Termux + proot environment
|
||||
- Python 3.11+
|
||||
|
||||
## Security Considerations (Future)
|
||||
|
||||
Currently implementing with no security for simplicity, but design should allow easy addition of:
|
||||
- WiFi password authentication
|
||||
- HTTPS for API endpoints
|
||||
- Activation code requirements
|
||||
- Subscriber audit logging
|
||||
- Profile encryption at rest
|
||||
|
||||
## Success Criteria
|
||||
|
||||
System is complete when:
|
||||
1. User connects to WiFi
|
||||
2. Captive portal loads automatically
|
||||
3. Click "Activate eSIM" button
|
||||
4. eSIM profile downloads to device
|
||||
5. Device installs profile
|
||||
6. Device connects to 5G using new eSIM
|
||||
7. Open5GS authenticates successfully
|
||||
|
||||
Total user time: <60 seconds
|
||||
|
||||
---
|
||||
|
||||
## Starting Point
|
||||
|
||||
Begin by creating the project structure, then implement in this order:
|
||||
|
||||
1. `database.py` - Get data persistence working
|
||||
2. `profile_generator.py` - Mock implementation first
|
||||
3. `app.py` - Flask API with all endpoints
|
||||
4. `portal/index.html` - Simple UI
|
||||
5. `core_adapters/open5gs.py` - Core integration
|
||||
6. Integration testing
|
||||
7. Real pySim integration
|
||||
8. Deployment scripts
|
||||
|
||||
Focus on getting a working end-to-end flow before optimizing or adding features.
|
||||
34
config.yaml.example
Normal file
34
config.yaml.example
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
network:
|
||||
mcc: "302"
|
||||
mnc: "720"
|
||||
plmn: "302720"
|
||||
op_key: "63bfa50ee6523365ff14c1f45f88737d" # 16-byte hex OP key
|
||||
|
||||
wifi:
|
||||
ssid: "Emergency-5G"
|
||||
channel: 6
|
||||
ip: "192.168.4.1"
|
||||
subnet: "192.168.4.0/24"
|
||||
dhcp_range: "192.168.4.10,192.168.4.250"
|
||||
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 5000
|
||||
debug: false
|
||||
|
||||
database:
|
||||
path: "/opt/esim-provisioning/data/subscribers.db"
|
||||
|
||||
rate_limiting:
|
||||
max_profiles_per_mac: 1
|
||||
window_hours: 1
|
||||
|
||||
core:
|
||||
type: "none" # "open5gs" | "free5gc" | "none"
|
||||
mongodb_uri: "mongodb://localhost:27017"
|
||||
database_name: "open5gs"
|
||||
|
||||
profile:
|
||||
carrier_name: "Ad-Hoc Network"
|
||||
profile_name: "Emergency 5G"
|
||||
apn: "internet"
|
||||
23
configs/dnsmasq.conf
Normal file
23
configs/dnsmasq.conf
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# dnsmasq configuration for eSIM provisioning captive portal
|
||||
# Install: cp configs/dnsmasq.conf /etc/dnsmasq.d/esim-provisioning.conf
|
||||
|
||||
# Only listen on the WiFi AP interface
|
||||
interface=wlan0
|
||||
bind-interfaces
|
||||
|
||||
# DHCP address pool for connecting clients
|
||||
dhcp-range=192.168.4.10,192.168.4.250,24h
|
||||
|
||||
# DNS hijacking: return the AP IP for every domain query
|
||||
# This triggers the OS captive-portal detection and redirects all HTTP traffic.
|
||||
address=/#/192.168.4.1
|
||||
|
||||
# Do not forward DNS queries upstream (keep it fully local)
|
||||
no-resolv
|
||||
|
||||
# DHCP lease file
|
||||
dhcp-leasefile=/var/lib/misc/dnsmasq.leases
|
||||
|
||||
# Logging (disable in production to reduce I/O)
|
||||
log-queries
|
||||
log-dhcp
|
||||
26
configs/hostapd.conf
Normal file
26
configs/hostapd.conf
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# hostapd configuration for eSIM provisioning WiFi AP
|
||||
# Install: cp configs/hostapd.conf /etc/hostapd/hostapd.conf
|
||||
# Also set: DAEMON_CONF="/etc/hostapd/hostapd.conf" in /etc/default/hostapd
|
||||
|
||||
interface=wlan0
|
||||
driver=nl80211
|
||||
|
||||
# Network identity
|
||||
ssid=Emergency-5G
|
||||
utf8_ssid=1
|
||||
|
||||
# Radio — 2.4 GHz 802.11n, channel 6
|
||||
hw_mode=g
|
||||
channel=6
|
||||
ieee80211n=1
|
||||
wmm_enabled=1
|
||||
|
||||
# Open network (no password) — intentional for emergency/NTN access
|
||||
auth_algs=1
|
||||
ignore_broadcast_ssid=0
|
||||
|
||||
# Adjust to the deployment country for regulatory compliance
|
||||
country_code=CA
|
||||
|
||||
# Limit concurrent clients
|
||||
max_num_sta=50
|
||||
44
configs/nginx.conf
Normal file
44
configs/nginx.conf
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# nginx configuration for eSIM provisioning captive portal
|
||||
# Install: cp configs/nginx.conf /etc/nginx/sites-available/esim-provisioning
|
||||
# ln -s /etc/nginx/sites-available/esim-provisioning /etc/nginx/sites-enabled/
|
||||
# rm -f /etc/nginx/sites-enabled/default
|
||||
|
||||
server {
|
||||
listen 80 default_server;
|
||||
server_name _;
|
||||
|
||||
# Redirect bare root to activation portal
|
||||
location = / {
|
||||
return 302 http://192.168.4.1/activate;
|
||||
}
|
||||
|
||||
# Serve captive portal static files
|
||||
location /activate {
|
||||
alias /opt/esim-provisioning/portal/;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /css/ {
|
||||
alias /opt/esim-provisioning/portal/css/;
|
||||
}
|
||||
|
||||
location /js/ {
|
||||
alias /opt/esim-provisioning/portal/js/;
|
||||
}
|
||||
|
||||
# Reverse proxy API calls to Flask backend
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
|
||||
# Catch-all: redirect anything else to the portal
|
||||
location / {
|
||||
return 302 http://192.168.4.1/activate;
|
||||
}
|
||||
}
|
||||
85
plan/00-project-structure.md
Normal file
85
plan/00-project-structure.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# Unit 00 — Project Structure
|
||||
|
||||
## Goal
|
||||
Scaffold all directories, `__init__` files, `requirements.txt`, and `config.yaml.example`. No logic yet — just the skeleton.
|
||||
|
||||
## Files to Create
|
||||
|
||||
### `requirements.txt`
|
||||
```
|
||||
Flask==3.0.0
|
||||
cryptography==41.0.7
|
||||
pymongo==4.6.0
|
||||
requests==2.31.0
|
||||
pyyaml==6.0.1
|
||||
pytest==7.4.0
|
||||
```
|
||||
> Note: `pySim` is deferred to Unit 06 (real eSIM profile generation) due to install complexity. Unit 02 uses a mock profile.
|
||||
|
||||
### `config.yaml.example`
|
||||
```yaml
|
||||
network:
|
||||
mcc: "302"
|
||||
mnc: "720"
|
||||
plmn: "302720"
|
||||
op_key: "63bfa50ee6523365ff14c1f45f88737d" # 16-byte hex OP key
|
||||
|
||||
wifi:
|
||||
ssid: "Emergency-5G"
|
||||
channel: 6
|
||||
ip: "192.168.4.1"
|
||||
subnet: "192.168.4.0/24"
|
||||
dhcp_range: "192.168.4.10,192.168.4.250"
|
||||
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 5000
|
||||
debug: false
|
||||
|
||||
database:
|
||||
path: "/opt/esim-provisioning/data/subscribers.db"
|
||||
|
||||
rate_limiting:
|
||||
max_profiles_per_mac: 1
|
||||
window_hours: 1
|
||||
|
||||
core:
|
||||
type: "open5gs" # "open5gs" | "free5gc" | "none"
|
||||
mongodb_uri: "mongodb://localhost:27017"
|
||||
database_name: "open5gs"
|
||||
|
||||
profile:
|
||||
carrier_name: "Ad-Hoc Network"
|
||||
profile_name: "Emergency 5G"
|
||||
apn: "internet"
|
||||
```
|
||||
|
||||
### Directory Structure
|
||||
```
|
||||
esim-provisioning/
|
||||
├── requirements.txt
|
||||
├── config.yaml.example
|
||||
├── server/
|
||||
│ ├── __init__.py (empty)
|
||||
│ └── core_adapters/
|
||||
│ └── __init__.py (empty — factory added in Unit 04)
|
||||
├── portal/
|
||||
│ ├── css/ (populated in Unit 06)
|
||||
│ ├── js/ (populated in Unit 06)
|
||||
│ └── assets/ (empty)
|
||||
├── scripts/ (populated in Unit 07)
|
||||
├── tests/
|
||||
│ └── __init__.py (empty)
|
||||
├── android/ (populated in Unit 09)
|
||||
├── systemd/ (populated in Unit 08)
|
||||
├── configs/ (populated in Unit 08)
|
||||
└── plan/ (these files — already done)
|
||||
```
|
||||
|
||||
## Verification
|
||||
```bash
|
||||
python3 -m venv venv && source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
# Should complete with no errors
|
||||
python -c "import flask, cryptography, pymongo, yaml; print('OK')"
|
||||
```
|
||||
90
plan/01-database.md
Normal file
90
plan/01-database.md
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# Unit 01 — Database Layer
|
||||
|
||||
## Goal
|
||||
SQLite persistence layer with CRUD operations and MAC-address-based rate limiting.
|
||||
|
||||
## Files
|
||||
- `server/database.py`
|
||||
- `server/models.py`
|
||||
|
||||
---
|
||||
|
||||
## `server/models.py`
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
@dataclass
|
||||
class Subscriber:
|
||||
id: Optional[int]
|
||||
imsi: str
|
||||
iccid: str
|
||||
ki: str # hex string
|
||||
opc: str # hex string
|
||||
mac_address: Optional[str]
|
||||
created_at: datetime
|
||||
synced_to_core: bool
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `server/database.py`
|
||||
|
||||
### Schema
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS subscribers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
imsi TEXT UNIQUE NOT NULL,
|
||||
iccid TEXT UNIQUE NOT NULL,
|
||||
ki TEXT NOT NULL,
|
||||
opc TEXT NOT NULL,
|
||||
mac_address TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
synced_to_core BOOLEAN DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_imsi ON subscribers(imsi);
|
||||
CREATE INDEX IF NOT EXISTS idx_mac_address ON subscribers(mac_address);
|
||||
```
|
||||
|
||||
### Functions
|
||||
|
||||
| Function | Signature | Returns | Notes |
|
||||
|----------|-----------|---------|-------|
|
||||
| `init_db` | `(db_path: str) -> None` | — | Creates schema idempotently |
|
||||
| `get_connection` | `(db_path: str) -> sqlite3.Connection` | connection | `row_factory = sqlite3.Row` |
|
||||
| `add_subscriber` | `(conn, imsi, iccid, ki, opc, mac_address) -> int` | row `id` | Raises `ValueError` on duplicate IMSI/ICCID |
|
||||
| `get_subscriber_by_imsi` | `(conn, imsi: str) -> Optional[Subscriber]` | Subscriber or None | |
|
||||
| `get_subscriber_by_id` | `(conn, sub_id: int) -> Optional[Subscriber]` | Subscriber or None | |
|
||||
| `list_unsynced` | `(conn) -> list[Subscriber]` | list | WHERE synced_to_core = 0 |
|
||||
| `mark_synced` | `(conn, imsi: str) -> None` | — | UPDATE synced_to_core = 1 |
|
||||
| `check_rate_limit` | `(conn, mac_address: str, window_hours: int) -> bool` | True = allowed | Counts rows for mac in last N hours |
|
||||
| `export_all` | `(conn) -> list[dict]` | list of dicts | All rows as serializable dicts |
|
||||
|
||||
### Rate Limiting Logic
|
||||
```python
|
||||
def check_rate_limit(conn, mac_address, window_hours):
|
||||
cutoff = datetime.utcnow() - timedelta(hours=window_hours)
|
||||
cur = conn.execute(
|
||||
"SELECT COUNT(*) FROM subscribers WHERE mac_address = ? AND created_at > ?",
|
||||
(mac_address, cutoff.isoformat())
|
||||
)
|
||||
count = cur.fetchone()[0]
|
||||
return count == 0 # True = allowed (no prior provisioning in window)
|
||||
```
|
||||
|
||||
## Verification
|
||||
```bash
|
||||
python -c "
|
||||
from server.database import init_db, get_connection, add_subscriber, check_rate_limit
|
||||
import tempfile, os
|
||||
db = tempfile.mktemp(suffix='.db')
|
||||
init_db(db)
|
||||
conn = get_connection(db)
|
||||
sid = add_subscriber(conn, '302720000000001', '8910302720000000001', 'aa'*16, 'bb'*16, 'aa:bb:cc:dd:ee:ff')
|
||||
print('inserted id:', sid)
|
||||
print('rate limit (should be False now):', check_rate_limit(conn, 'aa:bb:cc:dd:ee:ff', 1))
|
||||
conn.close(); os.unlink(db)
|
||||
"
|
||||
```
|
||||
106
plan/02-profile-generator.md
Normal file
106
plan/02-profile-generator.md
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
# Unit 02 — Profile Generator
|
||||
|
||||
## Goal
|
||||
Cryptographic credential generation (IMSI, Ki, OPc, ICCID) and Phase 1 mock eSIM profile. Real pySim integration is deferred to Unit 06.
|
||||
|
||||
## File
|
||||
- `server/profile_generator.py`
|
||||
|
||||
---
|
||||
|
||||
## Functions
|
||||
|
||||
### `generate_imsi(mcc: str, mnc: str) -> str`
|
||||
- Concatenate MCC + MNC + 10-digit random MSIN
|
||||
- MSIN generated with `secrets.randbelow(10**10)` zero-padded to 10 digits
|
||||
- Result must be exactly 15 digits
|
||||
- Validates format before returning
|
||||
|
||||
### `generate_ki() -> bytes`
|
||||
- `secrets.token_bytes(16)` — 16 cryptographically random bytes
|
||||
|
||||
### `generate_iccid() -> str`
|
||||
- Industry prefix: `8910` (Telecom, country code 10)
|
||||
- Append MCC+MNC from config
|
||||
- Fill remaining digits randomly
|
||||
- Total 18 digits + 1 Luhn check digit = 19 digits
|
||||
- Luhn algorithm:
|
||||
```python
|
||||
def luhn_checksum(number: str) -> int:
|
||||
digits = [int(d) for d in number]
|
||||
odd_digits = digits[-1::-2]
|
||||
even_digits = digits[-2::-2]
|
||||
total = sum(odd_digits)
|
||||
for d in even_digits:
|
||||
total += sum(divmod(d * 2, 10))
|
||||
return (10 - (total % 10)) % 10
|
||||
```
|
||||
|
||||
### `derive_opc(ki: bytes, op: bytes) -> bytes`
|
||||
Milenage OPc derivation — XOR of OP with AES_Ki(OP):
|
||||
```python
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
def derive_opc(ki: bytes, op: bytes) -> bytes:
|
||||
cipher = Cipher(algorithms.AES(ki), modes.ECB(), backend=default_backend())
|
||||
enc = cipher.encryptor()
|
||||
encrypted_op = enc.update(op) + enc.finalize()
|
||||
return bytes(a ^ b for a, b in zip(encrypted_op, op))
|
||||
```
|
||||
|
||||
### `create_esim_profile(imsi, ki, opc, iccid, profile_name, carrier_name) -> bytes`
|
||||
**Phase 1 (mock):** Returns JSON-encoded bytes with all credential fields:
|
||||
```python
|
||||
import json
|
||||
|
||||
def create_esim_profile(imsi, ki, opc, iccid, profile_name, carrier_name):
|
||||
profile = {
|
||||
"version": "1.0",
|
||||
"type": "mock",
|
||||
"iccid": iccid,
|
||||
"imsi": imsi,
|
||||
"ki": ki.hex() if isinstance(ki, bytes) else ki,
|
||||
"opc": opc.hex() if isinstance(opc, bytes) else opc,
|
||||
"profile_name": profile_name,
|
||||
"carrier_name": carrier_name,
|
||||
}
|
||||
return json.dumps(profile).encode("utf-8")
|
||||
```
|
||||
|
||||
**Phase 2 (Unit 06):** Replace with real pySim SGP.22 profile generation.
|
||||
|
||||
---
|
||||
|
||||
## Known Test Vectors (for Unit 10 tests)
|
||||
From 3GPP TS 35.208:
|
||||
- Ki = `000102030405060708090a0b0c0d0e0f`
|
||||
- OP = `63bfa50ee6523365ff14c1f45f88737d`
|
||||
- OPc = compute at test time: `AES_Ki(OP) XOR OP`
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
```bash
|
||||
python -c "
|
||||
from server.profile_generator import generate_imsi, generate_ki, generate_iccid, derive_opc, create_esim_profile
|
||||
import binascii
|
||||
|
||||
imsi = generate_imsi('302', '720')
|
||||
assert len(imsi) == 15 and imsi.startswith('302720'), f'bad IMSI: {imsi}'
|
||||
|
||||
ki = generate_ki()
|
||||
assert len(ki) == 16
|
||||
|
||||
iccid = generate_iccid()
|
||||
assert len(iccid) == 19, f'bad ICCID length: {len(iccid)}'
|
||||
|
||||
op = bytes.fromhex('63bfa50ee6523365ff14c1f45f88737d')
|
||||
opc = derive_opc(ki, op)
|
||||
assert len(opc) == 16
|
||||
|
||||
profile = create_esim_profile(imsi, ki, opc, iccid, 'Emergency 5G', 'Ad-Hoc Network')
|
||||
assert len(profile) > 0
|
||||
print('All checks passed')
|
||||
"
|
||||
```
|
||||
91
plan/03-utils.md
Normal file
91
plan/03-utils.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# Unit 03 — Utilities
|
||||
|
||||
## Goal
|
||||
Shared helper functions used across server modules. No business logic — pure helpers.
|
||||
|
||||
## File
|
||||
- `server/utils.py`
|
||||
|
||||
---
|
||||
|
||||
## Functions
|
||||
|
||||
### `validate_mac(mac: str) -> bool`
|
||||
```python
|
||||
import re
|
||||
MAC_RE = re.compile(r'^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$')
|
||||
def validate_mac(mac: str) -> bool:
|
||||
return bool(MAC_RE.match(mac)) if mac else False
|
||||
```
|
||||
|
||||
### `normalize_mac(mac: str) -> str`
|
||||
Lowercase, colon-separated. Input may be with dashes or no separator.
|
||||
```python
|
||||
def normalize_mac(mac: str) -> str:
|
||||
clean = mac.lower().replace('-', ':').replace('.', ':')
|
||||
# handle no-separator format (12 hex chars)
|
||||
if ':' not in clean and len(clean) == 12:
|
||||
clean = ':'.join(clean[i:i+2] for i in range(0, 12, 2))
|
||||
return clean
|
||||
```
|
||||
|
||||
### `validate_imsi(imsi: str) -> bool`
|
||||
```python
|
||||
def validate_imsi(imsi: str) -> bool:
|
||||
return bool(imsi) and imsi.isdigit() and len(imsi) == 15
|
||||
```
|
||||
|
||||
### `load_config(path: str) -> dict`
|
||||
```python
|
||||
import yaml, os
|
||||
|
||||
REQUIRED_KEYS = ['network', 'server', 'database', 'rate_limiting', 'profile']
|
||||
|
||||
def load_config(path: str) -> dict:
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(f"Config file not found: {path}")
|
||||
with open(path) as f:
|
||||
cfg = yaml.safe_load(f)
|
||||
for key in REQUIRED_KEYS:
|
||||
if key not in cfg:
|
||||
raise ValueError(f"Missing required config section: {key}")
|
||||
return cfg
|
||||
```
|
||||
|
||||
### `hex_to_bytes(h: str) -> bytes`
|
||||
```python
|
||||
def hex_to_bytes(h: str) -> bytes:
|
||||
return bytes.fromhex(h)
|
||||
```
|
||||
|
||||
### `bytes_to_hex(b: bytes) -> str`
|
||||
```python
|
||||
def bytes_to_hex(b: bytes) -> str:
|
||||
return b.hex()
|
||||
```
|
||||
|
||||
### `get_client_ip(request) -> str`
|
||||
Flask request helper — returns real IP behind proxy:
|
||||
```python
|
||||
def get_client_ip(request) -> str:
|
||||
if request.headers.get('X-Forwarded-For'):
|
||||
return request.headers['X-Forwarded-For'].split(',')[0].strip()
|
||||
return request.remote_addr
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
```bash
|
||||
python -c "
|
||||
from server.utils import validate_mac, normalize_mac, validate_imsi, load_config
|
||||
|
||||
assert validate_mac('aa:bb:cc:dd:ee:ff') == True
|
||||
assert validate_mac('AA:BB:CC:DD:EE:FF') == True
|
||||
assert validate_mac('bad') == False
|
||||
assert normalize_mac('AABBCCDDEEFF') == 'aa:bb:cc:dd:ee:ff'
|
||||
assert validate_imsi('302720123456789') == True
|
||||
assert validate_imsi('12345') == False
|
||||
print('All checks passed')
|
||||
"
|
||||
```
|
||||
145
plan/04-core-adapters.md
Normal file
145
plan/04-core-adapters.md
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
# Unit 04 — Core Adapters
|
||||
|
||||
## Goal
|
||||
Pluggable adapter pattern for Open5GS and free5gc. New cores can be added by subclassing `CoreAdapter`.
|
||||
|
||||
## Files
|
||||
- `server/core_adapters/base.py`
|
||||
- `server/core_adapters/open5gs.py`
|
||||
- `server/core_adapters/free5gc.py`
|
||||
- `server/core_adapters/__init__.py` (factory function)
|
||||
|
||||
---
|
||||
|
||||
## `server/core_adapters/base.py`
|
||||
|
||||
```python
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
|
||||
class CoreAdapter(ABC):
|
||||
@abstractmethod
|
||||
def add_subscriber(self, imsi: str, ki: str, opc: str) -> None:
|
||||
"""Add a subscriber to the core network. ki and opc are hex strings."""
|
||||
|
||||
@abstractmethod
|
||||
def remove_subscriber(self, imsi: str) -> None:
|
||||
"""Remove a subscriber from the core network."""
|
||||
|
||||
@abstractmethod
|
||||
def list_subscribers(self) -> list[dict]:
|
||||
"""Return list of subscriber dicts from the core."""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `server/core_adapters/open5gs.py`
|
||||
|
||||
Uses direct MongoDB insertion (same approach as Open5GS web UI).
|
||||
|
||||
```python
|
||||
import logging
|
||||
from pymongo import MongoClient
|
||||
from .base import CoreAdapter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class Open5GSAdapter(CoreAdapter):
|
||||
def __init__(self, mongodb_uri: str, database_name: str = "open5gs"):
|
||||
self.client = MongoClient(mongodb_uri)
|
||||
self.db = self.client[database_name]
|
||||
|
||||
def add_subscriber(self, imsi: str, ki: str, opc: str) -> None:
|
||||
doc = {
|
||||
"imsi": imsi,
|
||||
"security": {
|
||||
"k": ki,
|
||||
"opc": opc,
|
||||
"amf": "8000",
|
||||
"sqn": 0
|
||||
},
|
||||
"ambr": {
|
||||
"downlink": {"value": 1, "unit": 3},
|
||||
"uplink": {"value": 1, "unit": 3}
|
||||
},
|
||||
"slice": [{
|
||||
"sst": 1,
|
||||
"default_indicator": True,
|
||||
"session": [{
|
||||
"name": "internet",
|
||||
"type": 3,
|
||||
"ambr": {
|
||||
"downlink": {"value": 1, "unit": 3},
|
||||
"uplink": {"value": 1, "unit": 3}
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
self.db.subscribers.replace_one({"imsi": imsi}, doc, upsert=True)
|
||||
logger.info(f"Added subscriber {imsi} to Open5GS")
|
||||
|
||||
def remove_subscriber(self, imsi: str) -> None:
|
||||
self.db.subscribers.delete_one({"imsi": imsi})
|
||||
logger.info(f"Removed subscriber {imsi} from Open5GS")
|
||||
|
||||
def list_subscribers(self) -> list[dict]:
|
||||
return list(self.db.subscribers.find({}, {"_id": 0}))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `server/core_adapters/free5gc.py`
|
||||
|
||||
Stub — same document format, different DB name. Implement fully when free5gc is available.
|
||||
|
||||
```python
|
||||
from .open5gs import Open5GSAdapter
|
||||
|
||||
class Free5GCAdapter(Open5GSAdapter):
|
||||
def __init__(self, mongodb_uri: str, database_name: str = "free5gc"):
|
||||
super().__init__(mongodb_uri, database_name)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `server/core_adapters/__init__.py`
|
||||
|
||||
Factory function:
|
||||
```python
|
||||
from .base import CoreAdapter
|
||||
from .open5gs import Open5GSAdapter
|
||||
from .free5gc import Free5GCAdapter
|
||||
from typing import Optional
|
||||
|
||||
def get_adapter(config: dict) -> Optional[CoreAdapter]:
|
||||
core_cfg = config.get("core", {})
|
||||
core_type = core_cfg.get("type", "none")
|
||||
uri = core_cfg.get("mongodb_uri", "mongodb://localhost:27017")
|
||||
db_name = core_cfg.get("database_name", "open5gs")
|
||||
|
||||
if core_type == "open5gs":
|
||||
return Open5GSAdapter(uri, db_name)
|
||||
elif core_type == "free5gc":
|
||||
return Free5GCAdapter(uri, db_name)
|
||||
else:
|
||||
return None # No core integration (export-only mode)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
```bash
|
||||
# With no MongoDB running — verify import and factory
|
||||
python -c "
|
||||
from server.core_adapters import get_adapter
|
||||
cfg = {'core': {'type': 'none'}}
|
||||
adapter = get_adapter(cfg)
|
||||
assert adapter is None
|
||||
print('Factory returns None for type=none: OK')
|
||||
|
||||
# Test Open5GS adapter instantiation (no connection attempt until query)
|
||||
cfg2 = {'core': {'type': 'open5gs', 'mongodb_uri': 'mongodb://localhost:27017', 'database_name': 'open5gs'}}
|
||||
adapter2 = get_adapter(cfg2)
|
||||
print(f'Open5GS adapter: {adapter2.__class__.__name__}')
|
||||
"
|
||||
```
|
||||
166
plan/05-flask-app.md
Normal file
166
plan/05-flask-app.md
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
# Unit 05 — Flask Application
|
||||
|
||||
## Goal
|
||||
Main Flask server wiring together the database, profile generator, utils, and core adapters.
|
||||
|
||||
## File
|
||||
- `server/app.py`
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/` | none | 302 redirect → `/activate` |
|
||||
| GET | `/activate` | none | Serve `portal/index.html` |
|
||||
| POST | `/api/provision` | none | Generate + provision eSIM |
|
||||
| GET | `/api/profile/<int:id>` | none | Download profile bytes |
|
||||
| GET | `/api/export` | none | JSON export of all subscribers |
|
||||
| GET | `/api/status` | none | Health check |
|
||||
|
||||
---
|
||||
|
||||
## `POST /api/provision` — Full Flow
|
||||
|
||||
Request body (JSON):
|
||||
```json
|
||||
{"mac_address": "aa:bb:cc:dd:ee:ff"}
|
||||
```
|
||||
|
||||
Steps:
|
||||
1. Parse JSON body; if `mac_address` missing/empty, resolve from server-side ARP (`/proc/net/arp` lookup by client IP)
|
||||
2. `normalize_mac()` + `validate_mac()` → 400 if invalid
|
||||
3. `check_rate_limit(conn, mac, window_hours)` → 429 if exceeded
|
||||
4. `generate_imsi(mcc, mnc)`
|
||||
5. `generate_ki()`
|
||||
6. `generate_iccid()`
|
||||
7. `derive_opc(ki, op_bytes)`
|
||||
8. `create_esim_profile(...)` → `profile_bytes`
|
||||
9. `add_subscriber(conn, imsi, iccid, ki_hex, opc_hex, mac)` → `sub_id`
|
||||
10. Store `profile_bytes` in memory cache `{sub_id: bytes}` (or temp file)
|
||||
11. If `core_adapter` configured: attempt `add_subscriber(imsi, ki_hex, opc_hex)`; log failure, don't abort
|
||||
12. Return 200:
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"imsi": "302720XXXXXXXXXX",
|
||||
"iccid": "8910302720XXXXXXXXX",
|
||||
"profile_url": "/api/profile/1"
|
||||
}
|
||||
```
|
||||
|
||||
Error responses:
|
||||
- 400: `{"error": "Invalid MAC address"}`
|
||||
- 429: `{"error": "Rate limit exceeded. One profile per device per hour."}`
|
||||
- 500: `{"error": "Profile generation failed"}`
|
||||
|
||||
---
|
||||
|
||||
## `GET /api/profile/<id>` — Profile Download
|
||||
|
||||
- Look up subscriber by `id` in DB
|
||||
- Retrieve profile bytes from in-memory cache (keyed by `sub_id`)
|
||||
- 404 if not found or cache expired
|
||||
- Response: `Content-Type: application/octet-stream`, `Content-Disposition: attachment; filename=esim-profile.bin`
|
||||
|
||||
---
|
||||
|
||||
## `GET /api/export` — Subscriber Export
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"exported_at": "2025-02-11T10:30:00Z",
|
||||
"count": 123,
|
||||
"subscribers": [
|
||||
{
|
||||
"imsi": "302720123456789",
|
||||
"ki": "00112233445566778899aabbccddeeff",
|
||||
"opc": "63bfa50ee6523365ff14c1f45f88737d",
|
||||
"iccid": "8910302720123456789",
|
||||
"mac_address": "aa:bb:cc:dd:ee:ff",
|
||||
"created_at": "2025-02-11T09:15:00Z",
|
||||
"synced_to_core": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `GET /api/status` — Health
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"subscriber_count": 42,
|
||||
"uptime_seconds": 3600,
|
||||
"core_adapter": "open5gs"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Application Setup
|
||||
|
||||
```python
|
||||
import os, logging
|
||||
from flask import Flask, request, jsonify, send_file, redirect
|
||||
from server.database import init_db, get_connection, ...
|
||||
from server.profile_generator import ...
|
||||
from server.utils import load_config, ...
|
||||
from server.core_adapters import get_adapter
|
||||
|
||||
CONFIG_PATH = os.environ.get("CONFIG_PATH", "config.yaml")
|
||||
|
||||
def create_app(config_path=CONFIG_PATH):
|
||||
app = Flask(__name__, static_folder="../portal", static_url_path="")
|
||||
cfg = load_config(config_path)
|
||||
|
||||
db_path = cfg["database"]["path"]
|
||||
init_db(db_path)
|
||||
|
||||
adapter = get_adapter(cfg)
|
||||
profile_cache = {} # {sub_id: bytes}
|
||||
start_time = time.time()
|
||||
|
||||
# register routes ...
|
||||
return app
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = create_app()
|
||||
app.run(host=cfg["server"]["host"], port=cfg["server"]["port"])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MAC Address from ARP (server-side fallback)
|
||||
|
||||
When client doesn't send MAC:
|
||||
```python
|
||||
def get_mac_from_arp(ip: str) -> str | None:
|
||||
try:
|
||||
with open("/proc/net/arp") as f:
|
||||
for line in f:
|
||||
parts = line.split()
|
||||
if len(parts) >= 4 and parts[0] == ip:
|
||||
return parts[3]
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
```bash
|
||||
CONFIG_PATH=config.yaml python -m server.app &
|
||||
sleep 1
|
||||
curl -s http://localhost:5000/api/status | python -m json.tool
|
||||
curl -s -X POST http://localhost:5000/api/provision \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"mac_address":"aa:bb:cc:dd:ee:ff"}' | python -m json.tool
|
||||
curl -s http://localhost:5000/api/export | python -m json.tool
|
||||
kill %1
|
||||
```
|
||||
157
plan/06-captive-portal.md
Normal file
157
plan/06-captive-portal.md
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
# Unit 06 — Captive Portal Frontend
|
||||
|
||||
## Goal
|
||||
Mobile-first single-page UI served at `/activate`. Pure HTML/CSS/JS — no external dependencies.
|
||||
|
||||
## Files
|
||||
- `portal/index.html`
|
||||
- `portal/css/style.css`
|
||||
- `portal/js/script.js`
|
||||
|
||||
---
|
||||
|
||||
## UI States
|
||||
|
||||
```
|
||||
[idle] → "Activate eSIM" button visible
|
||||
[loading] → spinner, button disabled
|
||||
[success] → green checkmark, download link, instructions
|
||||
[error] → red message, "Try Again" button
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `portal/index.html`
|
||||
|
||||
Structure:
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>eSIM Activation</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">📡</div>
|
||||
<h1>Emergency 5G Network</h1>
|
||||
<p class="subtitle">Activate your free eSIM to connect</p>
|
||||
|
||||
<!-- Idle state -->
|
||||
<div id="state-idle">
|
||||
<button id="btn-activate" class="btn-primary">Activate eSIM</button>
|
||||
<p class="hint">Tap to generate your personal eSIM profile</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div id="state-loading" hidden>
|
||||
<div class="spinner"></div>
|
||||
<p>Generating your eSIM profile...</p>
|
||||
</div>
|
||||
|
||||
<!-- Success state -->
|
||||
<div id="state-success" hidden>
|
||||
<div class="checkmark">✓</div>
|
||||
<h2>eSIM Ready!</h2>
|
||||
<p>Your profile has been generated.</p>
|
||||
<a id="download-link" class="btn-primary" download="esim-profile.bin">
|
||||
Download Profile
|
||||
</a>
|
||||
<div class="instructions">
|
||||
<h3>Next Steps:</h3>
|
||||
<ol>
|
||||
<li>Download the profile above</li>
|
||||
<li>Go to <strong>Settings → Mobile Data → Add eSIM</strong></li>
|
||||
<li>Select "Import from file"</li>
|
||||
<li>Disconnect from this WiFi</li>
|
||||
<li>Your device will connect to 5G automatically</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div id="state-error" hidden>
|
||||
<div class="error-icon">✗</div>
|
||||
<h2>Activation Failed</h2>
|
||||
<p id="error-message">An error occurred. Please try again.</p>
|
||||
<button id="btn-retry" class="btn-secondary">Try Again</button>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/js/script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `portal/css/style.css`
|
||||
|
||||
Key rules:
|
||||
- `body`: `font-family: system-ui, sans-serif; background: #1a1a2e; color: #eee; display: flex; align-items: center; justify-content: center; min-height: 100vh;`
|
||||
- `.card`: `background: #16213e; border-radius: 16px; padding: 2rem; max-width: 400px; width: 90%; text-align: center; box-shadow: 0 8px 32px rgba(0,0,0,0.4);`
|
||||
- `.btn-primary`: `background: #0f3460; color: white; padding: 1rem 2rem; border-radius: 8px; font-size: 1.2rem; border: none; cursor: pointer; width: 100%; margin: 1rem 0;`
|
||||
- `.btn-primary:hover`: `background: #e94560;`
|
||||
- `.spinner`: CSS animation rotating border
|
||||
- `.checkmark`: `color: #4caf50; font-size: 4rem;`
|
||||
- `.error-icon`: `color: #f44336; font-size: 4rem;`
|
||||
- `.instructions`: `text-align: left; background: #0f3460; border-radius: 8px; padding: 1rem; margin-top: 1rem;`
|
||||
|
||||
---
|
||||
|
||||
## `portal/js/script.js`
|
||||
|
||||
```javascript
|
||||
const states = ['idle', 'loading', 'success', 'error'];
|
||||
function showState(name) {
|
||||
states.forEach(s => {
|
||||
document.getElementById(`state-${s}`).hidden = (s !== name);
|
||||
});
|
||||
}
|
||||
|
||||
async function activate() {
|
||||
showState('loading');
|
||||
try {
|
||||
const res = await fetch('/api/provision', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mac_address: '' }) // server resolves from ARP
|
||||
});
|
||||
if (res.status === 429) {
|
||||
showError('You already have an active eSIM for this network.');
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
showError(err.error || 'Server error. Please try again.');
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
document.getElementById('download-link').href = data.profile_url;
|
||||
showState('success');
|
||||
} catch (e) {
|
||||
showError('Could not reach the server. Are you connected to the WiFi?');
|
||||
}
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
document.getElementById('error-message').textContent = msg;
|
||||
showState('error');
|
||||
}
|
||||
|
||||
document.getElementById('btn-activate').addEventListener('click', activate);
|
||||
document.getElementById('btn-retry').addEventListener('click', () => {
|
||||
showState('idle');
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
```bash
|
||||
# With Flask running
|
||||
open http://localhost:5000/activate
|
||||
# Verify page loads, button works, cycling through all states
|
||||
# Check mobile responsiveness with browser DevTools device emulation
|
||||
```
|
||||
159
plan/07-scripts.md
Normal file
159
plan/07-scripts.md
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
# Unit 07 — Operational Scripts
|
||||
|
||||
## Goal
|
||||
CLI scripts for database initialization, subscriber sync, JSON export, and Raspberry Pi WiFi AP setup.
|
||||
|
||||
## Files
|
||||
- `scripts/init_db.py`
|
||||
- `scripts/sync_subscribers.py`
|
||||
- `scripts/export_subscribers.py`
|
||||
- `scripts/setup_wifi_ap.sh`
|
||||
|
||||
---
|
||||
|
||||
## `scripts/init_db.py`
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""Initialize the SQLite subscriber database."""
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
from server.utils import load_config
|
||||
from server.database import init_db
|
||||
|
||||
def main():
|
||||
config_path = os.environ.get("CONFIG_PATH", "config.yaml")
|
||||
cfg = load_config(config_path)
|
||||
db_path = cfg["database"]["path"]
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
init_db(db_path)
|
||||
print(f"Database initialized at {db_path}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `scripts/sync_subscribers.py`
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""Sync unsynced subscribers to the configured core network."""
|
||||
import sys, os, logging
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
from server.utils import load_config
|
||||
from server.database import get_connection, list_unsynced, mark_synced
|
||||
from server.core_adapters import get_adapter
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def main():
|
||||
config_path = os.environ.get("CONFIG_PATH", "config.yaml")
|
||||
cfg = load_config(config_path)
|
||||
adapter = get_adapter(cfg)
|
||||
if adapter is None:
|
||||
logger.error("No core adapter configured. Set core.type in config.")
|
||||
sys.exit(1)
|
||||
|
||||
conn = get_connection(cfg["database"]["path"])
|
||||
unsynced = list_unsynced(conn)
|
||||
logger.info(f"Found {len(unsynced)} unsynced subscribers")
|
||||
|
||||
success = 0
|
||||
for sub in unsynced:
|
||||
try:
|
||||
adapter.add_subscriber(sub.imsi, sub.ki, sub.opc)
|
||||
mark_synced(conn, sub.imsi)
|
||||
success += 1
|
||||
logger.info(f"Synced {sub.imsi}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to sync {sub.imsi}: {e}")
|
||||
|
||||
conn.close()
|
||||
logger.info(f"Sync complete: {success}/{len(unsynced)} succeeded")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `scripts/export_subscribers.py`
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""Export all subscribers to JSON on stdout."""
|
||||
import sys, os, json
|
||||
from datetime import datetime
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
from server.utils import load_config
|
||||
from server.database import get_connection, export_all
|
||||
|
||||
def main():
|
||||
config_path = os.environ.get("CONFIG_PATH", "config.yaml")
|
||||
cfg = load_config(config_path)
|
||||
conn = get_connection(cfg["database"]["path"])
|
||||
subscribers = export_all(conn)
|
||||
conn.close()
|
||||
|
||||
output = {
|
||||
"exported_at": datetime.utcnow().isoformat() + "Z",
|
||||
"count": len(subscribers),
|
||||
"subscribers": subscribers
|
||||
}
|
||||
print(json.dumps(output, indent=2, default=str))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `scripts/setup_wifi_ap.sh`
|
||||
|
||||
Raspberry Pi WiFi AP setup. Must be run as root.
|
||||
|
||||
Steps:
|
||||
1. Install packages: `hostapd dnsmasq nginx python3 python3-pip`
|
||||
2. Configure static IP on `wlan0`: `/etc/dhcpcd.conf` entry `interface wlan0 static ip_address=192.168.4.1/24`
|
||||
3. Write `/etc/hostapd/hostapd.conf` (SSID=Emergency-5G, open auth, channel 6)
|
||||
4. Write `/etc/dnsmasq.conf` (DHCP range 192.168.4.10-250, `address=/#/192.168.4.1`)
|
||||
5. Write nginx site config (proxy `/api/` → Flask, serve portal static)
|
||||
6. Enable `net.ipv4.ip_forward=1` in `/etc/sysctl.conf`
|
||||
7. Add iptables rules: all port 80 from `wlan0` → `192.168.4.1:80` (captive portal)
|
||||
8. Enable and start: `hostapd`, `dnsmasq`, `nginx`, `esim-provisioning`
|
||||
9. Install Python dependencies: `pip3 install -r /opt/esim-provisioning/requirements.txt`
|
||||
10. Print success message and next steps
|
||||
|
||||
Script structure:
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
[ "$(id -u)" = "0" ] || { echo "Must run as root"; exit 1; }
|
||||
|
||||
SSID="Emergency-5G"
|
||||
CHANNEL=6
|
||||
AP_IP="192.168.4.1"
|
||||
DHCP_RANGE="192.168.4.10,192.168.4.250,24h"
|
||||
INSTALL_DIR="/opt/esim-provisioning"
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
```bash
|
||||
# init_db
|
||||
CONFIG_PATH=config.yaml python scripts/init_db.py
|
||||
|
||||
# export (after some provisioning)
|
||||
CONFIG_PATH=config.yaml python scripts/export_subscribers.py | python -m json.tool
|
||||
|
||||
# sync (requires core adapter running)
|
||||
CONFIG_PATH=config.yaml python scripts/sync_subscribers.py
|
||||
```
|
||||
208
plan/08-system-configs.md
Normal file
208
plan/08-system-configs.md
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
# Unit 08 — System Configuration Files
|
||||
|
||||
## Goal
|
||||
Production-ready nginx, dnsmasq, hostapd configs and systemd service units.
|
||||
|
||||
## Files
|
||||
- `configs/nginx.conf`
|
||||
- `configs/dnsmasq.conf`
|
||||
- `configs/hostapd.conf`
|
||||
- `systemd/esim-provisioning.service`
|
||||
- `systemd/captive-portal.service`
|
||||
|
||||
---
|
||||
|
||||
## `configs/nginx.conf`
|
||||
|
||||
```nginx
|
||||
# Captive portal + reverse proxy for eSIM provisioning server
|
||||
|
||||
server {
|
||||
listen 80 default_server;
|
||||
server_name _;
|
||||
|
||||
# Redirect any request not matching known paths to the activation portal
|
||||
location = / {
|
||||
return 302 http://192.168.4.1/activate;
|
||||
}
|
||||
|
||||
# Serve the captive portal static files
|
||||
location /activate {
|
||||
alias /opt/esim-provisioning/portal/;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /css/ {
|
||||
alias /opt/esim-provisioning/portal/css/;
|
||||
}
|
||||
|
||||
location /js/ {
|
||||
alias /opt/esim-provisioning/portal/js/;
|
||||
}
|
||||
|
||||
# Proxy API calls to Flask backend
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
|
||||
# Catch-all: redirect everything else to activate
|
||||
location / {
|
||||
return 302 http://192.168.4.1/activate;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `configs/dnsmasq.conf`
|
||||
|
||||
```conf
|
||||
# eSIM Provisioning - DNS/DHCP configuration
|
||||
|
||||
# Only listen on WiFi AP interface
|
||||
interface=wlan0
|
||||
bind-interfaces
|
||||
|
||||
# DHCP range
|
||||
dhcp-range=192.168.4.10,192.168.4.250,24h
|
||||
|
||||
# DNS: return WiFi AP IP for ALL domains (captive portal hijacking)
|
||||
address=/#/192.168.4.1
|
||||
|
||||
# Prevent upstream DNS resolution (keep it local)
|
||||
no-resolv
|
||||
|
||||
# DHCP lease file
|
||||
dhcp-leasefile=/var/lib/misc/dnsmasq.leases
|
||||
|
||||
# Log queries (useful for debugging)
|
||||
log-queries
|
||||
log-dhcp
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `configs/hostapd.conf`
|
||||
|
||||
```conf
|
||||
# WiFi AP configuration for eSIM provisioning drop node
|
||||
|
||||
interface=wlan0
|
||||
driver=nl80211
|
||||
|
||||
# Network identity
|
||||
ssid=Emergency-5G
|
||||
utf8_ssid=1
|
||||
|
||||
# Radio settings
|
||||
hw_mode=g
|
||||
channel=6
|
||||
ieee80211n=1
|
||||
wmm_enabled=1
|
||||
|
||||
# Open network (no password) — intentional for emergency access
|
||||
auth_algs=1
|
||||
ignore_broadcast_ssid=0
|
||||
|
||||
# Country code — adjust per deployment region
|
||||
country_code=CA
|
||||
|
||||
# Max clients
|
||||
max_num_sta=50
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `systemd/esim-provisioning.service`
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=eSIM Provisioning Server
|
||||
After=network.target
|
||||
Wants=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=esim
|
||||
Group=esim
|
||||
WorkingDirectory=/opt/esim-provisioning
|
||||
Environment=CONFIG_PATH=/etc/esim-provisioning/config.yaml
|
||||
ExecStart=/opt/esim-provisioning/venv/bin/python -m server.app
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=esim-provisioning
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/opt/esim-provisioning/data
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `systemd/captive-portal.service`
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Captive Portal (nginx + dnsmasq)
|
||||
After=network.target hostapd.service
|
||||
Wants=nginx.service dnsmasq.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/bin/systemctl start nginx dnsmasq
|
||||
ExecStop=/bin/systemctl stop nginx dnsmasq
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment Notes
|
||||
|
||||
Install configs:
|
||||
```bash
|
||||
# nginx
|
||||
sudo cp configs/nginx.conf /etc/nginx/sites-available/esim-provisioning
|
||||
sudo ln -sf /etc/nginx/sites-available/esim-provisioning /etc/nginx/sites-enabled/
|
||||
sudo rm -f /etc/nginx/sites-enabled/default
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
|
||||
# dnsmasq
|
||||
sudo cp configs/dnsmasq.conf /etc/dnsmasq.d/esim-provisioning.conf
|
||||
sudo systemctl restart dnsmasq
|
||||
|
||||
# hostapd
|
||||
sudo cp configs/hostapd.conf /etc/hostapd/hostapd.conf
|
||||
sudo systemctl unmask hostapd
|
||||
sudo systemctl enable --now hostapd
|
||||
|
||||
# systemd services
|
||||
sudo cp systemd/esim-provisioning.service /etc/systemd/system/
|
||||
sudo cp systemd/captive-portal.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now esim-provisioning captive-portal
|
||||
```
|
||||
|
||||
## Verification
|
||||
```bash
|
||||
sudo nginx -t # Config syntax check
|
||||
sudo systemctl status esim-provisioning
|
||||
sudo systemctl status captive-portal
|
||||
nslookup google.com 192.168.4.1 # Should return 192.168.4.1
|
||||
curl http://192.168.4.1/api/status # Should return JSON status
|
||||
```
|
||||
155
plan/09-android.md
Normal file
155
plan/09-android.md
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
# Unit 09 — Android / Termux Deployment
|
||||
|
||||
## Goal
|
||||
Automated setup script and instructions for running the provisioning stack on Android via Termux (no root required for basic operation).
|
||||
|
||||
## Files
|
||||
- `android/README.md`
|
||||
- `android/termux_setup.sh`
|
||||
|
||||
---
|
||||
|
||||
## Constraints & Differences from Raspberry Pi
|
||||
|
||||
| Feature | Raspberry Pi | Android (Termux) |
|
||||
|---------|-------------|-----------------|
|
||||
| WiFi AP | hostapd (full control) | Android Settings hotspot (limited) |
|
||||
| DNS hijacking | dnsmasq full control | Limited without root |
|
||||
| Port 80 | Direct | Need port >1024 or root; use 8080 |
|
||||
| Captive portal trigger | iptables redirect | Relies on OS captive portal detection |
|
||||
| Systemd | Yes | No — use Termux boot scripts |
|
||||
|
||||
---
|
||||
|
||||
## `android/termux_setup.sh`
|
||||
|
||||
```bash
|
||||
#!/data/data/com.termux/files/usr/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "=== eSIM Provisioning Server — Termux Setup ==="
|
||||
|
||||
# Update Termux packages
|
||||
pkg update -y && pkg upgrade -y
|
||||
|
||||
# Install core dependencies
|
||||
pkg install -y python python-pip nginx
|
||||
|
||||
# Install Python dependencies
|
||||
pip install flask cryptography pymongo pyyaml requests
|
||||
|
||||
# Create directory structure
|
||||
INSTALL_DIR="$HOME/esim-provisioning"
|
||||
mkdir -p "$INSTALL_DIR"/{server/core_adapters,portal/{css,js,assets},scripts,tests,data}
|
||||
|
||||
# Copy server code (assumes script run from repo root)
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cp -r "$SCRIPT_DIR/server" "$INSTALL_DIR/"
|
||||
cp -r "$SCRIPT_DIR/portal" "$INSTALL_DIR/"
|
||||
cp -r "$SCRIPT_DIR/scripts" "$INSTALL_DIR/"
|
||||
cp "$SCRIPT_DIR/config.yaml.example" "$INSTALL_DIR/config.yaml"
|
||||
|
||||
# Configure for Android (port 8080, data path in home)
|
||||
sed -i 's|port: 5000|port: 8080|' "$INSTALL_DIR/config.yaml"
|
||||
sed -i "s|/opt/esim-provisioning/data|$INSTALL_DIR/data|" "$INSTALL_DIR/config.yaml"
|
||||
|
||||
# Configure nginx for Android (port 8080 as reverse proxy target)
|
||||
cat > "$PREFIX/etc/nginx/nginx.conf" << 'EOF'
|
||||
worker_processes 1;
|
||||
events { worker_connections 64; }
|
||||
http {
|
||||
server {
|
||||
listen 8080 default_server;
|
||||
server_name _;
|
||||
location /activate {
|
||||
alias /data/data/com.termux/files/home/esim-provisioning/portal/;
|
||||
index index.html;
|
||||
}
|
||||
location /css/ { alias /data/data/com.termux/files/home/esim-provisioning/portal/css/; }
|
||||
location /js/ { alias /data/data/com.termux/files/home/esim-provisioning/portal/js/; }
|
||||
location /api/ { proxy_pass http://127.0.0.1:5000; }
|
||||
location / { return 302 http://192.168.43.1:8080/activate; }
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Initialize database
|
||||
cd "$INSTALL_DIR"
|
||||
CONFIG_PATH=config.yaml python scripts/init_db.py
|
||||
|
||||
# Create start script
|
||||
cat > "$INSTALL_DIR/start.sh" << 'STARTEOF'
|
||||
#!/data/data/com.termux/files/usr/bin/bash
|
||||
cd ~/esim-provisioning
|
||||
echo "Starting eSIM provisioning server..."
|
||||
nginx
|
||||
CONFIG_PATH=config.yaml python -m server.app &
|
||||
echo "Server started. Access at http://192.168.43.1:8080/activate"
|
||||
echo "To stop: pkill nginx && pkill python"
|
||||
STARTEOF
|
||||
chmod +x "$INSTALL_DIR/start.sh"
|
||||
|
||||
# Termux:Boot integration (auto-start on reboot)
|
||||
mkdir -p "$HOME/.termux/boot"
|
||||
cp "$INSTALL_DIR/start.sh" "$HOME/.termux/boot/esim-provisioning.sh"
|
||||
|
||||
echo ""
|
||||
echo "=== Setup Complete ==="
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Enable WiFi Hotspot in Android Settings"
|
||||
echo " - SSID: Emergency-5G (or any name)"
|
||||
echo " - No password"
|
||||
echo "2. Run: ~/esim-provisioning/start.sh"
|
||||
echo "3. Users connect to hotspot and visit http://192.168.43.1:8080/activate"
|
||||
echo ""
|
||||
echo "Note: Install Termux:Boot app for auto-start on reboot"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `android/README.md`
|
||||
|
||||
### Prerequisites
|
||||
- Android 9+
|
||||
- Termux app (from F-Droid, NOT Play Store)
|
||||
- Optional: Termux:Boot (for auto-start)
|
||||
- 5G-capable device with pre-provisioned SIM for gNB backhaul
|
||||
|
||||
### Installation
|
||||
```bash
|
||||
# In Termux:
|
||||
cd /path/to/esim-provisioning
|
||||
bash android/termux_setup.sh
|
||||
```
|
||||
|
||||
### Starting the Server
|
||||
```bash
|
||||
~/esim-provisioning/start.sh
|
||||
```
|
||||
|
||||
### WiFi Hotspot Setup
|
||||
1. Android Settings → Network → Hotspot
|
||||
2. Set name to `Emergency-5G` (optional, any name works)
|
||||
3. Remove password (open network)
|
||||
4. Enable hotspot
|
||||
|
||||
### Captive Portal Limitation
|
||||
Android's captive portal detection requires DNS manipulation. Without root, clients must navigate manually to `http://192.168.43.1:8080/activate`. With root + Termux, dnsmasq can be used for full DNS hijacking.
|
||||
|
||||
### Known Android Hotspot IPs
|
||||
- Most Android: `192.168.43.1`
|
||||
- Samsung: `192.168.0.1` or `192.168.43.1`
|
||||
- Pixel: `192.168.43.1`
|
||||
|
||||
Update `config.yaml` and nginx config with your device's hotspot IP.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
```bash
|
||||
# On Android in Termux:
|
||||
~/esim-provisioning/start.sh
|
||||
curl http://127.0.0.1:5000/api/status
|
||||
curl http://127.0.0.1:8080/activate # through nginx
|
||||
```
|
||||
248
plan/10-testing.md
Normal file
248
plan/10-testing.md
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
# Unit 10 — Testing
|
||||
|
||||
## Goal
|
||||
Unit tests covering the database layer, profile generator, and core adapters. Run with `pytest`.
|
||||
|
||||
## Files
|
||||
- `tests/test_database.py`
|
||||
- `tests/test_profile_generator.py`
|
||||
- `tests/test_core_adapters.py`
|
||||
|
||||
---
|
||||
|
||||
## `tests/test_database.py`
|
||||
|
||||
```python
|
||||
import pytest, tempfile, os
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import patch
|
||||
from server.database import init_db, get_connection, add_subscriber, \
|
||||
get_subscriber_by_imsi, get_subscriber_by_id, check_rate_limit, \
|
||||
list_unsynced, mark_synced, export_all
|
||||
|
||||
@pytest.fixture
|
||||
def db_conn(tmp_path):
|
||||
db_path = str(tmp_path / "test.db")
|
||||
init_db(db_path)
|
||||
conn = get_connection(db_path)
|
||||
yield conn
|
||||
conn.close()
|
||||
|
||||
def sub_data(**kwargs):
|
||||
defaults = dict(imsi="302720000000001", iccid="8910302720000000001",
|
||||
ki="aa" * 16, opc="bb" * 16, mac_address="aa:bb:cc:dd:ee:ff")
|
||||
defaults.update(kwargs)
|
||||
return defaults
|
||||
|
||||
class TestCRUD:
|
||||
def test_add_and_retrieve_by_imsi(self, db_conn):
|
||||
d = sub_data()
|
||||
sid = add_subscriber(db_conn, **d)
|
||||
sub = get_subscriber_by_imsi(db_conn, d["imsi"])
|
||||
assert sub is not None
|
||||
assert sub.imsi == d["imsi"]
|
||||
assert sub.id == sid
|
||||
|
||||
def test_add_and_retrieve_by_id(self, db_conn):
|
||||
d = sub_data()
|
||||
sid = add_subscriber(db_conn, **d)
|
||||
sub = get_subscriber_by_id(db_conn, sid)
|
||||
assert sub.iccid == d["iccid"]
|
||||
|
||||
def test_duplicate_imsi_raises(self, db_conn):
|
||||
add_subscriber(db_conn, **sub_data())
|
||||
with pytest.raises(ValueError):
|
||||
add_subscriber(db_conn, **sub_data())
|
||||
|
||||
def test_not_found_returns_none(self, db_conn):
|
||||
assert get_subscriber_by_imsi(db_conn, "999999999999999") is None
|
||||
assert get_subscriber_by_id(db_conn, 99999) is None
|
||||
|
||||
class TestRateLimiting:
|
||||
def test_first_request_allowed(self, db_conn):
|
||||
assert check_rate_limit(db_conn, "aa:bb:cc:dd:ee:ff", 1) == True
|
||||
|
||||
def test_second_request_blocked(self, db_conn):
|
||||
add_subscriber(db_conn, **sub_data())
|
||||
assert check_rate_limit(db_conn, "aa:bb:cc:dd:ee:ff", 1) == False
|
||||
|
||||
def test_different_mac_allowed(self, db_conn):
|
||||
add_subscriber(db_conn, **sub_data())
|
||||
assert check_rate_limit(db_conn, "11:22:33:44:55:66", 1) == True
|
||||
|
||||
def test_expired_window_allows_again(self, db_conn):
|
||||
# Insert a subscriber with a timestamp 2 hours in the past
|
||||
add_subscriber(db_conn, **sub_data())
|
||||
past = (datetime.utcnow() - timedelta(hours=2)).isoformat()
|
||||
db_conn.execute("UPDATE subscribers SET created_at = ?", (past,))
|
||||
db_conn.commit()
|
||||
assert check_rate_limit(db_conn, "aa:bb:cc:dd:ee:ff", 1) == True
|
||||
|
||||
class TestSync:
|
||||
def test_list_unsynced(self, db_conn):
|
||||
add_subscriber(db_conn, **sub_data())
|
||||
assert len(list_unsynced(db_conn)) == 1
|
||||
|
||||
def test_mark_synced(self, db_conn):
|
||||
add_subscriber(db_conn, **sub_data())
|
||||
mark_synced(db_conn, sub_data()["imsi"])
|
||||
assert len(list_unsynced(db_conn)) == 0
|
||||
|
||||
class TestExport:
|
||||
def test_export_returns_list(self, db_conn):
|
||||
add_subscriber(db_conn, **sub_data())
|
||||
rows = export_all(db_conn)
|
||||
assert len(rows) == 1
|
||||
assert "imsi" in rows[0]
|
||||
assert "ki" in rows[0]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `tests/test_profile_generator.py`
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from server.profile_generator import (
|
||||
generate_imsi, generate_ki, generate_iccid, derive_opc, create_esim_profile
|
||||
)
|
||||
|
||||
class TestIMSI:
|
||||
def test_length(self):
|
||||
assert len(generate_imsi("302", "720")) == 15
|
||||
|
||||
def test_prefix(self):
|
||||
imsi = generate_imsi("302", "720")
|
||||
assert imsi.startswith("302720")
|
||||
|
||||
def test_digits_only(self):
|
||||
assert generate_imsi("302", "720").isdigit()
|
||||
|
||||
def test_uniqueness(self):
|
||||
imsis = {generate_imsi("302", "720") for _ in range(100)}
|
||||
assert len(imsis) > 90 # Very unlikely to get 10+ collisions
|
||||
|
||||
class TestKi:
|
||||
def test_length(self):
|
||||
assert len(generate_ki()) == 16
|
||||
|
||||
def test_randomness(self):
|
||||
keys = {generate_ki() for _ in range(50)}
|
||||
assert len(keys) == 50
|
||||
|
||||
class TestICCID:
|
||||
def test_length(self):
|
||||
assert len(generate_iccid()) == 19
|
||||
|
||||
def test_digits_only(self):
|
||||
assert generate_iccid().isdigit()
|
||||
|
||||
def test_luhn_valid(self):
|
||||
iccid = generate_iccid()
|
||||
# Verify last digit is valid Luhn check digit
|
||||
from server.profile_generator import luhn_checksum
|
||||
assert luhn_checksum(iccid[:-1]) == int(iccid[-1])
|
||||
|
||||
class TestOPc:
|
||||
# Test vector from 3GPP TS 35.208
|
||||
KI_HEX = "000102030405060708090a0b0c0d0e0f"
|
||||
OP_HEX = "63bfa50ee6523365ff14c1f45f88737d"
|
||||
|
||||
def test_output_length(self):
|
||||
ki = bytes.fromhex(self.KI_HEX)
|
||||
op = bytes.fromhex(self.OP_HEX)
|
||||
opc = derive_opc(ki, op)
|
||||
assert len(opc) == 16
|
||||
|
||||
def test_deterministic(self):
|
||||
ki = bytes.fromhex(self.KI_HEX)
|
||||
op = bytes.fromhex(self.OP_HEX)
|
||||
assert derive_opc(ki, op) == derive_opc(ki, op)
|
||||
|
||||
def test_known_vector(self):
|
||||
# Pre-computed: AES_Ki(OP) XOR OP
|
||||
ki = bytes.fromhex(self.KI_HEX)
|
||||
op = bytes.fromhex(self.OP_HEX)
|
||||
opc = derive_opc(ki, op)
|
||||
# Verify it's not trivially wrong
|
||||
assert opc != op
|
||||
assert opc != ki
|
||||
|
||||
class TestProfile:
|
||||
def test_returns_bytes(self):
|
||||
ki = generate_ki()
|
||||
op = bytes.fromhex("63bfa50ee6523365ff14c1f45f88737d")
|
||||
opc = derive_opc(ki, op)
|
||||
imsi = generate_imsi("302", "720")
|
||||
iccid = generate_iccid()
|
||||
profile = create_esim_profile(imsi, ki, opc, iccid, "Test", "Test Carrier")
|
||||
assert isinstance(profile, bytes)
|
||||
assert len(profile) > 0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `tests/test_core_adapters.py`
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from server.core_adapters import get_adapter
|
||||
from server.core_adapters.open5gs import Open5GSAdapter
|
||||
|
||||
class TestFactory:
|
||||
def test_none_type(self):
|
||||
cfg = {"core": {"type": "none"}}
|
||||
assert get_adapter(cfg) is None
|
||||
|
||||
def test_open5gs_type(self):
|
||||
cfg = {"core": {"type": "open5gs", "mongodb_uri": "mongodb://localhost:27017", "database_name": "open5gs"}}
|
||||
with patch("server.core_adapters.open5gs.MongoClient"):
|
||||
adapter = get_adapter(cfg)
|
||||
assert isinstance(adapter, Open5GSAdapter)
|
||||
|
||||
class TestOpen5GSAdapter:
|
||||
@pytest.fixture
|
||||
def adapter(self):
|
||||
with patch("server.core_adapters.open5gs.MongoClient") as mock_client:
|
||||
a = Open5GSAdapter("mongodb://localhost:27017", "open5gs")
|
||||
a.db = MagicMock()
|
||||
yield a
|
||||
|
||||
def test_add_subscriber_calls_replace_one(self, adapter):
|
||||
adapter.add_subscriber("302720000000001", "aa" * 16, "bb" * 16)
|
||||
adapter.db.subscribers.replace_one.assert_called_once()
|
||||
call_args = adapter.db.subscribers.replace_one.call_args
|
||||
doc = call_args[0][1]
|
||||
assert doc["imsi"] == "302720000000001"
|
||||
assert "security" in doc
|
||||
assert doc["security"]["k"] == "aa" * 16
|
||||
assert "slice" in doc
|
||||
assert len(doc["slice"]) == 1
|
||||
|
||||
def test_remove_subscriber(self, adapter):
|
||||
adapter.remove_subscriber("302720000000001")
|
||||
adapter.db.subscribers.delete_one.assert_called_once_with({"imsi": "302720000000001"})
|
||||
|
||||
def test_list_subscribers(self, adapter):
|
||||
adapter.db.subscribers.find.return_value = [{"imsi": "302720000000001"}]
|
||||
result = adapter.list_subscribers()
|
||||
assert len(result) == 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# All tests
|
||||
pytest tests/ -v
|
||||
|
||||
# With coverage
|
||||
pytest tests/ -v --cov=server --cov-report=term-missing
|
||||
|
||||
# Single file
|
||||
pytest tests/test_profile_generator.py -v
|
||||
```
|
||||
|
||||
Expected output: all green, ~30 tests.
|
||||
160
plan/11-readme.md
Normal file
160
plan/11-readme.md
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
# Unit 11 — README.md
|
||||
|
||||
## Goal
|
||||
Write the main `README.md` at the project root. This is the last unit — written after all code exists.
|
||||
|
||||
## File
|
||||
- `README.md`
|
||||
|
||||
---
|
||||
|
||||
## Required Sections
|
||||
|
||||
### Header
|
||||
- Project name + one-line description
|
||||
- Badges (optional): Python version, license
|
||||
|
||||
### Architecture Diagram
|
||||
Copy from `design.md`:
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Server Host (srsRAN gNB) │
|
||||
│ - srsRAN gNB │
|
||||
│ - Open5GS Core (or free5gc) │
|
||||
│ - Subscriber database │
|
||||
└─────────────┬───────────────────────────┘
|
||||
│ 5G NR
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ UE WiFi AP Drop │
|
||||
│ (Android device or Raspberry Pi) │
|
||||
│ - WiFi AP (open network) │
|
||||
│ - eSIM Provisioning Server │
|
||||
│ - Captive Portal │
|
||||
└─────────────┬───────────────────────────┘
|
||||
│ WiFi
|
||||
▼
|
||||
End User Devices
|
||||
```
|
||||
|
||||
### User Flow (numbered list)
|
||||
1. User connects to open WiFi `Emergency-5G`
|
||||
2. Captive portal auto-redirects to activation page
|
||||
3. User taps "Activate eSIM"
|
||||
4. Server generates IMSI/Ki/OPc/ICCID
|
||||
5. eSIM profile downloaded to device
|
||||
6. User installs profile via Settings
|
||||
7. Device connects to 5G network automatically
|
||||
|
||||
### System Requirements
|
||||
|
||||
**Raspberry Pi:**
|
||||
- Raspberry Pi 4 (4GB+ RAM)
|
||||
- Quectel RM500Q 5G modem (or compatible)
|
||||
- Raspberry Pi OS Lite 64-bit
|
||||
- Python 3.11+
|
||||
|
||||
**Android:**
|
||||
- Android 9+ with Termux (F-Droid)
|
||||
- 5G-capable device
|
||||
- Pre-provisioned SIM for gNB backhaul
|
||||
|
||||
**End-user devices:**
|
||||
- eSIM-capable device (eUICC support)
|
||||
- Any OS (Android, iOS, Windows)
|
||||
|
||||
### Quick Start (5 commands)
|
||||
```bash
|
||||
git clone <repo>
|
||||
cd esim-provisioning
|
||||
python3 -m venv venv && source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
cp config.yaml.example config.yaml # edit as needed
|
||||
python scripts/init_db.py
|
||||
python -m server.app
|
||||
# → Server running at http://localhost:5000
|
||||
```
|
||||
|
||||
### Configuration Guide
|
||||
|
||||
Key `config.yaml` fields:
|
||||
|
||||
| Field | Description | Example |
|
||||
|-------|-------------|---------|
|
||||
| `network.mcc` | Mobile Country Code | `"302"` |
|
||||
| `network.mnc` | Mobile Network Code | `"720"` |
|
||||
| `network.op_key` | Operator key (hex, 16 bytes) | `"63bfa..."` |
|
||||
| `wifi.ip` | WiFi AP IP address | `"192.168.4.1"` |
|
||||
| `database.path` | SQLite database path | `"/opt/.../subscribers.db"` |
|
||||
| `rate_limiting.window_hours` | Rate limit window | `1` |
|
||||
| `core.type` | Core adapter | `"open5gs"` / `"none"` |
|
||||
| `core.mongodb_uri` | MongoDB connection | `"mongodb://localhost:27017"` |
|
||||
|
||||
### Raspberry Pi Installation
|
||||
Reference `scripts/setup_wifi_ap.sh` for automated setup, or follow manual steps from `configs/` directory.
|
||||
|
||||
### Android / Termux Installation
|
||||
```bash
|
||||
bash android/termux_setup.sh
|
||||
~/esim-provisioning/start.sh
|
||||
```
|
||||
See `android/README.md` for detailed instructions and limitations.
|
||||
|
||||
### API Reference
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/activate` | GET | Captive portal page |
|
||||
| `/api/provision` | POST | Generate eSIM profile |
|
||||
| `/api/profile/<id>` | GET | Download profile |
|
||||
| `/api/export` | GET | Export all subscribers (JSON) |
|
||||
| `/api/status` | GET | Health check |
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
**Profile generation fails:**
|
||||
```
|
||||
Check: python -c "from server.profile_generator import generate_ki; print(generate_ki().hex())"
|
||||
Check: OP key is 32 hex chars (16 bytes) in config.yaml
|
||||
```
|
||||
|
||||
**Captive portal not showing:**
|
||||
```
|
||||
Check: nslookup google.com 192.168.4.1 → should return 192.168.4.1
|
||||
Check: systemctl status dnsmasq nginx
|
||||
Check: iptables -t nat -L PREROUTING
|
||||
```
|
||||
|
||||
**5G authentication fails after eSIM install:**
|
||||
```
|
||||
Check: IMSI/Ki/OPc in subscriber database matches what's in Open5GS
|
||||
Check: PLMN (MCC+MNC) matches gNB configuration
|
||||
Check: python scripts/sync_subscribers.py
|
||||
Check: Open5GS UDM/AMF logs: journalctl -u open5gs-udmd
|
||||
```
|
||||
|
||||
**Rate limit blocking legitimate user:**
|
||||
```sql
|
||||
-- Find and clear rate limit for a MAC:
|
||||
sqlite3 /path/to/subscribers.db \
|
||||
"DELETE FROM subscribers WHERE mac_address='aa:bb:cc:dd:ee:ff';"
|
||||
```
|
||||
|
||||
### Architecture Notes
|
||||
- **Adapter pattern**: Adding a new core (e.g., srsRAN-CN) requires only a new class in `server/core_adapters/`
|
||||
- **IMSI space**: 10^10 possible MSINs per MCC+MNC — effectively unlimited for field use
|
||||
- **Rate limiting**: Per MAC address, 1 profile per hour window. SQLite-backed, survives restarts
|
||||
- **Profile format**: Phase 1 mock (JSON bytes). Phase 2: real SGP.22 via pySim for eUICC compatibility
|
||||
|
||||
### License
|
||||
MIT (or as appropriate for deployment context)
|
||||
|
||||
---
|
||||
|
||||
## Writing Notes
|
||||
|
||||
When writing the actual README.md:
|
||||
- Keep it scannable — use tables, code blocks, headers
|
||||
- Lead with the architecture diagram
|
||||
- Quick Start must work in <5 minutes on a fresh Pi
|
||||
- Troubleshooting section is critical — include it prominently
|
||||
- Do not duplicate design.md — link to it for deep dives
|
||||
496
plan/design.md
Normal file
496
plan/design.md
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
# Closed-Loop eSIM Provisioning System for Emergency/NTN 5G Deployment
|
||||
|
||||
## Overview
|
||||
Self-contained eSIM provisioning system for ad-hoc 5G network deployment in emergency or NTN scenarios. Users connect to WiFi AP, automatically receive eSIM profile, then connect to 5G network.
|
||||
|
||||
## System Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Server Host (srsRAN gNB) │
|
||||
│ - srsRAN gNB │
|
||||
│ - Open5GS Core (or free5gc) │
|
||||
│ - Subscriber database │
|
||||
└─────────────┬───────────────────────────┘
|
||||
│ 5G NR
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ UE WiFi AP Drop │
|
||||
│ (Android device or Raspberry Pi) │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ 5G Modem (pre-provisioned SIM) │ │
|
||||
│ │ Connects to gNB for backhaul │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ WiFi AP (open network) │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ eSIM Provisioning Server │ │
|
||||
│ │ - Profile generator │ │
|
||||
│ │ - Captive portal │ │
|
||||
│ │ - Credential database │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────┘
|
||||
│ WiFi
|
||||
▼
|
||||
End User Devices
|
||||
```
|
||||
|
||||
## User Flow
|
||||
|
||||
1. User device detects open WiFi network
|
||||
2. Connects to WiFi → Auto-redirected to captive portal
|
||||
3. Captive portal displays "Activate eSIM" button
|
||||
4. Click → Server generates unique IMSI/Ki/OPc credentials
|
||||
5. Server generates SGP.22 eSIM profile with credentials
|
||||
6. Profile delivered to device via LPA protocol
|
||||
7. Device installs eSIM profile
|
||||
8. User disconnects from WiFi
|
||||
9. Device connects to 5G network using new eSIM
|
||||
10. Open5GS/free5gc authenticates user
|
||||
|
||||
## Components
|
||||
|
||||
### 1. UE WiFi AP Drop Device
|
||||
|
||||
**Hardware Options:**
|
||||
- **Primary**: Android phone (cheapest if app is simple)
|
||||
- **Fallback**: Raspberry Pi 4 + Quectel RM500Q 5G modem
|
||||
|
||||
**Requirements:**
|
||||
- Pre-provisioned "admin" SIM to connect to gNB
|
||||
- WiFi hotspot capability
|
||||
- Runs provisioning server stack
|
||||
|
||||
**Software Stack:**
|
||||
- Cellular connection to gNB (uses pre-provisioned SIM)
|
||||
- WiFi AP (hostapd or Android hotspot)
|
||||
- Captive portal (dnsmasq + nginx)
|
||||
- eSIM provisioning server (Python)
|
||||
|
||||
### 2. eSIM Provisioning Server
|
||||
|
||||
**Technology:** Python + Flask
|
||||
|
||||
**Core Functions:**
|
||||
- Generate IMSI/Ki/OPc credentials on-demand
|
||||
- Create SGP.22-compliant eSIM profiles using pySim
|
||||
- Serve profiles via LPA protocol
|
||||
- Store generated credentials in SQLite
|
||||
- Rate limiting (1 profile per MAC address per hour)
|
||||
- Export credentials for core sync
|
||||
|
||||
**Dependencies:**
|
||||
- `pySim` - SIM/eSIM profile generation
|
||||
- `Flask` - Web server
|
||||
- `sqlite3` - Local credential storage
|
||||
- `cryptography` - Key generation
|
||||
|
||||
**Endpoints:**
|
||||
```
|
||||
GET / - Captive portal landing page
|
||||
POST /activate - Trigger eSIM generation
|
||||
GET /profile/:id - Download eSIM profile (LPA)
|
||||
GET /export - Export credentials for core sync
|
||||
```
|
||||
|
||||
**Profile Generation Logic:**
|
||||
```python
|
||||
# Generate credentials
|
||||
IMSI = generate_imsi(mcc=302, mnc=720) # Adjust for your network
|
||||
Ki = random_bytes(16)
|
||||
OPc = derive_opc(Ki, OP)
|
||||
|
||||
# Create eSIM profile
|
||||
profile = create_esim_profile(
|
||||
iccid=generate_iccid(),
|
||||
imsi=IMSI,
|
||||
ki=Ki,
|
||||
opc=OPc,
|
||||
profile_name="Emergency 5G"
|
||||
)
|
||||
|
||||
# Store in database
|
||||
store_subscriber(imsi, ki, opc, timestamp, mac_address)
|
||||
|
||||
# Return profile for LPA download
|
||||
return profile
|
||||
```
|
||||
|
||||
### 3. Captive Portal
|
||||
|
||||
**Technology:** HTML + JavaScript + nginx
|
||||
|
||||
**Features:**
|
||||
- Auto-redirect any HTTP request to portal
|
||||
- Single-button interface: "Activate eSIM"
|
||||
- Instructions for eSIM installation
|
||||
- Minimal UI, works on any device
|
||||
|
||||
**DNS Hijacking:**
|
||||
```
|
||||
# dnsmasq configuration
|
||||
address=/#/192.168.4.1
|
||||
```
|
||||
|
||||
**nginx Configuration:**
|
||||
```nginx
|
||||
server {
|
||||
listen 80 default_server;
|
||||
server_name _;
|
||||
|
||||
location / {
|
||||
return 302 http://192.168.4.1/activate;
|
||||
}
|
||||
|
||||
location /activate {
|
||||
root /var/www/captive;
|
||||
index index.html;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Core Integration Module
|
||||
|
||||
**Design:** Adapter pattern for portability
|
||||
|
||||
**Supported Cores (initially):**
|
||||
- Open5GS
|
||||
- free5gc
|
||||
|
||||
**Interface:**
|
||||
```python
|
||||
class CoreAdapter:
|
||||
def add_subscriber(self, imsi, ki, opc):
|
||||
"""Add subscriber to core network"""
|
||||
pass
|
||||
|
||||
def remove_subscriber(self, imsi):
|
||||
"""Remove subscriber from core network"""
|
||||
pass
|
||||
|
||||
def list_subscribers(self):
|
||||
"""List all subscribers"""
|
||||
pass
|
||||
```
|
||||
|
||||
**Open5GS Implementation:**
|
||||
```python
|
||||
class Open5GSAdapter(CoreAdapter):
|
||||
def add_subscriber(self, imsi, ki, opc):
|
||||
# Direct MongoDB insertion
|
||||
db.subscribers.insert_one({
|
||||
"imsi": imsi,
|
||||
"security": {
|
||||
"k": ki,
|
||||
"opc": opc,
|
||||
"amf": "8000",
|
||||
"sqn": 0
|
||||
},
|
||||
"ambr": {
|
||||
"downlink": {"value": 1, "unit": 3}, # 1 Gbps
|
||||
"uplink": {"value": 1, "unit": 3}
|
||||
},
|
||||
"slice": [{
|
||||
"sst": 1,
|
||||
"default_indicator": True,
|
||||
"session": [{
|
||||
"name": "internet",
|
||||
"type": 3, # IPv4v6
|
||||
"ambr": {
|
||||
"downlink": {"value": 1, "unit": 3},
|
||||
"uplink": {"value": 1, "unit": 3}
|
||||
}
|
||||
}]
|
||||
}]
|
||||
})
|
||||
```
|
||||
|
||||
**Sync Options:**
|
||||
1. **Push**: Provisioning server calls core adapter immediately
|
||||
2. **Pull**: Core polls provisioning server API periodically
|
||||
3. **Manual**: Export JSON, manually import to core
|
||||
|
||||
**Initial Implementation:** Export JSON format
|
||||
```json
|
||||
{
|
||||
"subscribers": [
|
||||
{
|
||||
"imsi": "302720123456789",
|
||||
"ki": "00112233445566778899aabbccddeeff",
|
||||
"opc": "63bfa50ee6523365ff14c1f45f88737d",
|
||||
"timestamp": "2025-02-11T10:30:00Z",
|
||||
"mac_address": "aa:bb:cc:dd:ee:ff"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 5. eSIM Profile Format
|
||||
|
||||
**Standard:** SGP.22 compliant eSIM profile
|
||||
|
||||
**Profile Contents:**
|
||||
- ICCID (SIM card identifier)
|
||||
- IMSI (subscriber identity)
|
||||
- Ki (authentication key)
|
||||
- OPc (derived operator key)
|
||||
- Profile metadata (name, description)
|
||||
- APN configuration (if needed)
|
||||
|
||||
**Generation Method:**
|
||||
Use pySim's eUICC profile generator:
|
||||
```python
|
||||
from pySim.esim import EsimProfile
|
||||
|
||||
profile = EsimProfile(
|
||||
iccid=iccid,
|
||||
imsi=imsi,
|
||||
ki=ki,
|
||||
opc=opc,
|
||||
profile_name="Emergency 5G",
|
||||
carrier_name="Ad-Hoc Network"
|
||||
)
|
||||
|
||||
profile_bytes = profile.encode()
|
||||
```
|
||||
|
||||
**Fallback:** If SGP.22 generation fails, serve simplified profile (may have compatibility issues with some devices)
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Core Server Components
|
||||
- [ ] eSIM provisioning server (Python/Flask)
|
||||
- [ ] Credential generation and storage
|
||||
- [ ] Profile generation using pySim
|
||||
- [ ] LPA protocol implementation
|
||||
- [ ] SQLite database schema
|
||||
|
||||
### Phase 2: Captive Portal
|
||||
- [ ] HTML/JS frontend
|
||||
- [ ] nginx configuration
|
||||
- [ ] DNS hijacking setup
|
||||
- [ ] Auto-redirect logic
|
||||
|
||||
### Phase 3: Core Integration
|
||||
- [ ] Open5GS adapter
|
||||
- [ ] JSON export format
|
||||
- [ ] Sync script
|
||||
|
||||
### Phase 4: UE WiFi AP Drop
|
||||
- [ ] Android app (if feasible)
|
||||
- [ ] OR Raspberry Pi setup scripts
|
||||
- [ ] Auto-connection to gNB
|
||||
- [ ] WiFi AP configuration
|
||||
- [ ] Service orchestration
|
||||
|
||||
### Phase 5: Testing & Hardening
|
||||
- [ ] End-to-end flow testing
|
||||
- [ ] Rate limiting verification
|
||||
- [ ] Profile compatibility testing
|
||||
- [ ] Network isolation verification
|
||||
|
||||
## Deployment Instructions
|
||||
|
||||
### Prerequisites
|
||||
- srsRAN gNB running and configured
|
||||
- Open5GS core running (or free5gc)
|
||||
- One pre-provisioned "admin" SIM for backhaul UE
|
||||
- UE device (Android phone or Raspberry Pi)
|
||||
|
||||
### UE WiFi AP Drop Setup
|
||||
|
||||
**If Android:**
|
||||
1. Install Termux
|
||||
2. Install required packages (Python, hostapd, dnsmasq, nginx)
|
||||
3. Deploy provisioning server
|
||||
4. Configure WiFi hotspot
|
||||
5. Start services
|
||||
|
||||
**If Raspberry Pi:**
|
||||
1. Install Raspberry Pi OS Lite
|
||||
2. Install Quectel RM500Q modem drivers
|
||||
3. Configure modem for gNB connection
|
||||
4. Install provisioning server stack
|
||||
5. Configure hostapd + dnsmasq
|
||||
6. Start services
|
||||
|
||||
### Provisioning Server Setup
|
||||
```bash
|
||||
# Install dependencies
|
||||
pip install flask pySim cryptography
|
||||
|
||||
# Create directory structure
|
||||
mkdir -p /opt/esim-provisioning/{server,portal,data}
|
||||
|
||||
# Deploy server code
|
||||
cp provisioning_server.py /opt/esim-provisioning/server/
|
||||
cp -r templates/ /opt/esim-provisioning/server/
|
||||
|
||||
# Deploy captive portal
|
||||
cp -r portal/* /opt/esim-provisioning/portal/
|
||||
|
||||
# Initialize database
|
||||
python init_db.py
|
||||
|
||||
# Start server
|
||||
systemctl start esim-provisioning
|
||||
```
|
||||
|
||||
### Core Integration
|
||||
```bash
|
||||
# Export subscribers from provisioning server
|
||||
curl http://192.168.4.1:5000/export > subscribers.json
|
||||
|
||||
# Import to Open5GS
|
||||
python open5gs_import.py subscribers.json
|
||||
|
||||
# OR for free5gc
|
||||
python free5gc_import.py subscribers.json
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Network Parameters
|
||||
```python
|
||||
# config.py
|
||||
MCC = "302" # Canada (adjust for your region)
|
||||
MNC = "720" # Adjust for your network
|
||||
PLMN = f"{MCC}{MNC}"
|
||||
|
||||
# IMSI format: MCC + MNC + MSIN
|
||||
# Example: 302720123456789
|
||||
IMSI_PREFIX = PLMN
|
||||
MSIN_LENGTH = 10
|
||||
|
||||
# Operator key (OP) - used to derive OPc
|
||||
OP = bytes.fromhex("63bfa50ee6523365ff14c1f45f88737d")
|
||||
|
||||
# WiFi AP configuration
|
||||
WIFI_SSID = "Emergency-5G"
|
||||
WIFI_CHANNEL = 6
|
||||
WIFI_IP = "192.168.4.1"
|
||||
WIFI_SUBNET = "192.168.4.0/24"
|
||||
WIFI_DHCP_RANGE = "192.168.4.10,192.168.4.250"
|
||||
|
||||
# Rate limiting
|
||||
MAX_PROFILES_PER_MAC = 1
|
||||
RATE_LIMIT_HOURS = 1
|
||||
```
|
||||
|
||||
### Open5GS Connection
|
||||
```python
|
||||
# open5gs_config.py
|
||||
MONGODB_URI = "mongodb://localhost:27017"
|
||||
DATABASE_NAME = "open5gs"
|
||||
COLLECTION_NAME = "subscribers"
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Current Implementation (Phase 1)
|
||||
- Open WiFi network (no authentication)
|
||||
- Rate limiting by MAC address
|
||||
- No encryption on provisioning server traffic (local network only)
|
||||
|
||||
### Future Enhancements
|
||||
- Optional WiFi password (distribute via physical signage)
|
||||
- Pre-qualified activation codes
|
||||
- HTTPS for provisioning server
|
||||
- Profile encryption
|
||||
- Audit logging
|
||||
|
||||
## Scalability
|
||||
|
||||
### On-Demand Generation
|
||||
- Generates credentials only when requested
|
||||
- No pre-generation overhead
|
||||
- Limited only by:
|
||||
- IMSI space (10^10 possible MSINs with 10-digit MSIN)
|
||||
- Storage (SQLite can handle millions of records)
|
||||
- Processing power (generates ~10 profiles/second on modest hardware)
|
||||
|
||||
### Multi-Core Session Handover
|
||||
**Design Consideration:** Profile format supports core mobility
|
||||
|
||||
Current design doesn't preclude future session handover between cores:
|
||||
- IMSI/Ki/OPc are standardized, portable between cores
|
||||
- Export format is core-agnostic JSON
|
||||
- Core adapters can be extended to support handover signaling
|
||||
|
||||
**Future Implementation:**
|
||||
- Add `home_core_id` field to subscriber record
|
||||
- Export includes core affinity metadata
|
||||
- Handover protocol exchanges subscriber credentials between cores
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Profile Generation Fails
|
||||
- Check pySim installation: `python -c "import pySim"`
|
||||
- Verify OP key is 16 bytes
|
||||
- Check IMSI format (15 digits total)
|
||||
|
||||
### Device Won't Download Profile
|
||||
- Verify device supports eSIM (eUICC)
|
||||
- Check LPA protocol implementation
|
||||
- Verify profile format is SGP.22 compliant
|
||||
- Try simplified profile format
|
||||
|
||||
### Can't Connect to 5G After eSIM Install
|
||||
- Verify subscriber added to Open5GS/free5gc
|
||||
- Check IMSI/Ki/OPc match between profile and core
|
||||
- Verify PLMN configuration matches gNB
|
||||
- Check AMF/UDM logs for authentication failures
|
||||
|
||||
### WiFi Captive Portal Not Triggering
|
||||
- Verify DNS hijacking: `nslookup google.com 192.168.4.1`
|
||||
- Check nginx is running: `systemctl status nginx`
|
||||
- Verify iptables rules for captive portal
|
||||
|
||||
## File Structure
|
||||
```
|
||||
/opt/esim-provisioning/
|
||||
├── server/
|
||||
│ ├── provisioning_server.py # Main Flask application
|
||||
│ ├── profile_generator.py # eSIM profile generation
|
||||
│ ├── database.py # SQLite interface
|
||||
│ ├── core_adapter.py # Core integration adapters
|
||||
│ └── config.py # Configuration
|
||||
├── portal/
|
||||
│ ├── index.html # Captive portal page
|
||||
│ ├── style.css
|
||||
│ └── script.js
|
||||
├── data/
|
||||
│ ├── subscribers.db # SQLite database
|
||||
│ └── profiles/ # Generated profiles (temp)
|
||||
├── scripts/
|
||||
│ ├── sync_open5gs.py # Open5GS sync script
|
||||
│ ├── sync_free5gc.py # free5gc sync script
|
||||
│ └── export_subscribers.py # Export to JSON
|
||||
└── systemd/
|
||||
├── esim-provisioning.service # Systemd unit file
|
||||
└── captive-portal.service # Captive portal service
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Implement provisioning server (Python/Flask)
|
||||
2. Implement profile generator using pySim
|
||||
3. Create captive portal frontend
|
||||
4. Build Open5GS adapter
|
||||
5. Test end-to-end flow
|
||||
6. Deploy to UE WiFi AP drop device
|
||||
|
||||
## References
|
||||
- SGP.22 eSIM Specification: https://www.gsma.com/esim/
|
||||
- pySim Documentation: https://github.com/osmocom/pysim
|
||||
- Open5GS Documentation: https://open5gs.org/
|
||||
- srsRAN Documentation: https://docs.srsran.com/
|
||||
167
portal/css/style.css
Normal file
167
portal/css/style.css
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #e0e0e0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #16213e;
|
||||
border-radius: 16px;
|
||||
padding: 2.5rem 2rem;
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 3.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.95rem;
|
||||
color: #9ca3af;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ---- Buttons ---- */
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
border-radius: 10px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
margin: 1rem 0;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s, transform 0.1s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #0f3460;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #e94560;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
border: 1px solid #374151;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #374151;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.8rem;
|
||||
color: #6b7280;
|
||||
margin-top: -0.5rem;
|
||||
}
|
||||
|
||||
/* ---- Spinner ---- */
|
||||
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid #374151;
|
||||
border-top-color: #e94560;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin: 1rem auto;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ---- Status icons ---- */
|
||||
|
||||
.icon-success {
|
||||
font-size: 4rem;
|
||||
color: #22c55e;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.icon-error {
|
||||
font-size: 4rem;
|
||||
color: #ef4444;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.3rem;
|
||||
color: #ffffff;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
/* ---- Instructions ---- */
|
||||
|
||||
.instructions {
|
||||
text-align: left;
|
||||
background: #0f3460;
|
||||
border-radius: 10px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.instructions h3 {
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #9ca3af;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.instructions ol {
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.instructions li {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.instructions li + li {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
#error-message {
|
||||
color: #fca5a5;
|
||||
margin: 0.5rem 0 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
58
portal/index.html
Normal file
58
portal/index.html
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Emergency 5G — eSIM Activation</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">📡</div>
|
||||
<h1>Emergency 5G Network</h1>
|
||||
<p class="subtitle">Activate your free eSIM to connect to the 5G network</p>
|
||||
|
||||
<!-- Idle state -->
|
||||
<div id="state-idle">
|
||||
<button id="btn-activate" class="btn-primary">Activate eSIM</button>
|
||||
<p class="hint">Tap to generate your personal eSIM profile</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div id="state-loading" hidden>
|
||||
<div class="spinner"></div>
|
||||
<p>Generating your eSIM profile…</p>
|
||||
</div>
|
||||
|
||||
<!-- Success state -->
|
||||
<div id="state-success" hidden>
|
||||
<div class="icon-success">✓</div>
|
||||
<h2>eSIM Ready!</h2>
|
||||
<p>Your profile has been generated successfully.</p>
|
||||
<a id="download-link" class="btn-primary" download="esim-profile.bin">
|
||||
Download Profile
|
||||
</a>
|
||||
<div class="instructions">
|
||||
<h3>Next Steps</h3>
|
||||
<ol>
|
||||
<li>Tap <strong>Download Profile</strong> above</li>
|
||||
<li>Go to <strong>Settings → Mobile Data → Add eSIM</strong></li>
|
||||
<li>Choose <em>Import from file</em> and select the downloaded file</li>
|
||||
<li>Disconnect from this WiFi</li>
|
||||
<li>Your device will connect to 5G automatically</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div id="state-error" hidden>
|
||||
<div class="icon-error">✗</div>
|
||||
<h2>Activation Failed</h2>
|
||||
<p id="error-message">An error occurred. Please try again.</p>
|
||||
<button id="btn-retry" class="btn-secondary">Try Again</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
61
portal/js/script.js
Normal file
61
portal/js/script.js
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
'use strict';
|
||||
|
||||
const STATES = ['idle', 'loading', 'success', 'error'];
|
||||
|
||||
function showState(name) {
|
||||
STATES.forEach(function(s) {
|
||||
var el = document.getElementById('state-' + s);
|
||||
if (el) el.hidden = (s !== name);
|
||||
});
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
var el = document.getElementById('error-message');
|
||||
if (el) el.textContent = msg;
|
||||
showState('error');
|
||||
}
|
||||
|
||||
async function activate() {
|
||||
showState('loading');
|
||||
|
||||
try {
|
||||
var res = await fetch('/api/provision', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
// MAC address left empty — server resolves it via ARP from client IP
|
||||
body: JSON.stringify({ mac_address: '' }),
|
||||
});
|
||||
|
||||
var data;
|
||||
try { data = await res.json(); } catch (_) { data = {}; }
|
||||
|
||||
if (res.status === 429) {
|
||||
showError(data.error || 'You already have an eSIM for this network. Only one per device per hour.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
showError(data.error || 'Server error (' + res.status + '). Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Wire up download link
|
||||
var link = document.getElementById('download-link');
|
||||
if (link && data.profile_url) {
|
||||
link.href = data.profile_url;
|
||||
}
|
||||
|
||||
showState('success');
|
||||
|
||||
} catch (err) {
|
||||
showError('Could not reach the server. Make sure you are connected to the WiFi network.');
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var btnActivate = document.getElementById('btn-activate');
|
||||
var btnRetry = document.getElementById('btn-retry');
|
||||
|
||||
if (btnActivate) btnActivate.addEventListener('click', activate);
|
||||
if (btnRetry) btnRetry.addEventListener('click', function() { showState('idle'); });
|
||||
});
|
||||
7
requirements.txt
Normal file
7
requirements.txt
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
Flask==3.0.0
|
||||
cryptography==41.0.7
|
||||
pymongo==4.6.0
|
||||
requests==2.31.0
|
||||
pyyaml==6.0.1
|
||||
pytest==7.4.0
|
||||
pytest-cov==4.1.0
|
||||
33
scripts/export_subscribers.py
Normal file
33
scripts/export_subscribers.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Export all subscribers to JSON on stdout."""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from server.database import export_all, get_connection
|
||||
from server.utils import load_config
|
||||
|
||||
|
||||
def main():
|
||||
config_path = os.environ.get("CONFIG_PATH", "config.yaml")
|
||||
cfg = load_config(config_path)
|
||||
|
||||
conn = get_connection(cfg["database"]["path"])
|
||||
try:
|
||||
subscribers = export_all(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
output = {
|
||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||
"count": len(subscribers),
|
||||
"subscribers": subscribers,
|
||||
}
|
||||
print(json.dumps(output, indent=2, default=str))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
22
scripts/init_db.py
Normal file
22
scripts/init_db.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Initialize the SQLite subscriber database."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from server.database import init_db
|
||||
from server.utils import load_config
|
||||
|
||||
|
||||
def main():
|
||||
config_path = os.environ.get("CONFIG_PATH", "config.yaml")
|
||||
cfg = load_config(config_path)
|
||||
db_path = cfg["database"]["path"]
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
init_db(db_path)
|
||||
print(f"Database initialized at {db_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
170
scripts/setup_wifi_ap.sh
Executable file
170
scripts/setup_wifi_ap.sh
Executable file
|
|
@ -0,0 +1,170 @@
|
|||
#!/bin/bash
|
||||
# setup_wifi_ap.sh — Configure a Raspberry Pi as a WiFi AP + captive portal
|
||||
# for the eSIM provisioning system.
|
||||
# Run as root: sudo bash scripts/setup_wifi_ap.sh
|
||||
set -euo pipefail
|
||||
|
||||
[ "$(id -u)" = "0" ] || { echo "ERROR: Must run as root (sudo)."; exit 1; }
|
||||
|
||||
SSID="${ESIM_SSID:-Emergency-5G}"
|
||||
CHANNEL="${ESIM_CHANNEL:-6}"
|
||||
AP_IP="${ESIM_AP_IP:-192.168.4.1}"
|
||||
DHCP_RANGE="${ESIM_DHCP_RANGE:-192.168.4.10,192.168.4.250,24h}"
|
||||
INSTALL_DIR="${ESIM_INSTALL_DIR:-/opt/esim-provisioning}"
|
||||
IFACE="${ESIM_IFACE:-wlan0}"
|
||||
FLASK_PORT="${ESIM_FLASK_PORT:-5000}"
|
||||
|
||||
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
echo "=== eSIM Provisioning — WiFi AP Setup ==="
|
||||
echo " SSID: $SSID"
|
||||
echo " Channel: $CHANNEL"
|
||||
echo " AP IP: $AP_IP"
|
||||
echo " Interface: $IFACE"
|
||||
echo " Install dir: $INSTALL_DIR"
|
||||
echo ""
|
||||
|
||||
# ---- 1. Install packages ----
|
||||
echo "[1/8] Installing packages..."
|
||||
apt-get update -qq
|
||||
apt-get install -y hostapd dnsmasq nginx python3 python3-pip python3-venv
|
||||
|
||||
# ---- 2. Static IP on wlan0 ----
|
||||
echo "[2/8] Configuring static IP on $IFACE..."
|
||||
DHCPCD_CONF=/etc/dhcpcd.conf
|
||||
if ! grep -q "interface $IFACE" "$DHCPCD_CONF" 2>/dev/null; then
|
||||
cat >> "$DHCPCD_CONF" << EOF
|
||||
|
||||
interface $IFACE
|
||||
static ip_address=$AP_IP/24
|
||||
nohook wpa_supplicant
|
||||
EOF
|
||||
fi
|
||||
|
||||
# ---- 3. hostapd ----
|
||||
echo "[3/8] Configuring hostapd..."
|
||||
cat > /etc/hostapd/hostapd.conf << EOF
|
||||
interface=$IFACE
|
||||
driver=nl80211
|
||||
ssid=$SSID
|
||||
utf8_ssid=1
|
||||
hw_mode=g
|
||||
channel=$CHANNEL
|
||||
ieee80211n=1
|
||||
wmm_enabled=1
|
||||
auth_algs=1
|
||||
ignore_broadcast_ssid=0
|
||||
max_num_sta=50
|
||||
EOF
|
||||
|
||||
sed -i 's|#DAEMON_CONF=.*|DAEMON_CONF="/etc/hostapd/hostapd.conf"|' /etc/default/hostapd
|
||||
|
||||
# ---- 4. dnsmasq ----
|
||||
echo "[4/8] Configuring dnsmasq..."
|
||||
# Back up original if present
|
||||
[ -f /etc/dnsmasq.conf ] && mv /etc/dnsmasq.conf /etc/dnsmasq.conf.bak
|
||||
cat > /etc/dnsmasq.conf << EOF
|
||||
interface=$IFACE
|
||||
bind-interfaces
|
||||
dhcp-range=$DHCP_RANGE
|
||||
address=/#/$AP_IP
|
||||
no-resolv
|
||||
dhcp-leasefile=/var/lib/misc/dnsmasq.leases
|
||||
log-queries
|
||||
log-dhcp
|
||||
EOF
|
||||
|
||||
# ---- 5. nginx ----
|
||||
echo "[5/8] Configuring nginx..."
|
||||
cat > /etc/nginx/sites-available/esim-provisioning << EOF
|
||||
server {
|
||||
listen 80 default_server;
|
||||
server_name _;
|
||||
|
||||
location = / {
|
||||
return 302 http://$AP_IP/activate;
|
||||
}
|
||||
|
||||
location /activate {
|
||||
alias $INSTALL_DIR/portal/;
|
||||
index index.html;
|
||||
try_files \$uri \$uri/ /index.html;
|
||||
}
|
||||
|
||||
location /css/ { alias $INSTALL_DIR/portal/css/; }
|
||||
location /js/ { alias $INSTALL_DIR/portal/js/; }
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:$FLASK_PORT;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 302 http://$AP_IP/activate;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
ln -sf /etc/nginx/sites-available/esim-provisioning /etc/nginx/sites-enabled/
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
nginx -t
|
||||
|
||||
# ---- 6. IP forwarding ----
|
||||
echo "[6/8] Enabling IP forwarding..."
|
||||
sed -i 's|#net.ipv4.ip_forward=1|net.ipv4.ip_forward=1|' /etc/sysctl.conf
|
||||
sysctl -w net.ipv4.ip_forward=1
|
||||
|
||||
# ---- 7. iptables — captive portal redirect ----
|
||||
echo "[7/8] Configuring iptables..."
|
||||
# Redirect all HTTP traffic from wlan0 clients to nginx (captive portal)
|
||||
iptables -t nat -A PREROUTING -i "$IFACE" -p tcp --dport 80 -j DNAT \
|
||||
--to-destination "$AP_IP:80" 2>/dev/null || true
|
||||
# Persist iptables rules
|
||||
apt-get install -y iptables-persistent
|
||||
netfilter-persistent save
|
||||
|
||||
# ---- 8. Deploy server and services ----
|
||||
echo "[8/8] Deploying server..."
|
||||
mkdir -p "$INSTALL_DIR" /etc/esim-provisioning
|
||||
|
||||
cp -r "$REPO_DIR/server" "$INSTALL_DIR/"
|
||||
cp -r "$REPO_DIR/portal" "$INSTALL_DIR/"
|
||||
cp -r "$REPO_DIR/scripts" "$INSTALL_DIR/"
|
||||
cp -r "$REPO_DIR/requirements.txt" "$INSTALL_DIR/"
|
||||
|
||||
[ -f /etc/esim-provisioning/config.yaml ] || \
|
||||
cp "$REPO_DIR/config.yaml.example" /etc/esim-provisioning/config.yaml
|
||||
|
||||
# Create venv and install deps
|
||||
python3 -m venv "$INSTALL_DIR/venv"
|
||||
"$INSTALL_DIR/venv/bin/pip" install -q -r "$INSTALL_DIR/requirements.txt"
|
||||
|
||||
# Create system user
|
||||
id esim &>/dev/null || useradd --system --no-create-home --shell /usr/sbin/nologin esim
|
||||
|
||||
mkdir -p "$INSTALL_DIR/data"
|
||||
chown -R esim:esim "$INSTALL_DIR/data"
|
||||
|
||||
# Install systemd services
|
||||
cp "$REPO_DIR/systemd/esim-provisioning.service" /etc/systemd/system/
|
||||
cp "$REPO_DIR/systemd/captive-portal.service" /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
|
||||
# Initialise DB
|
||||
CONFIG_PATH=/etc/esim-provisioning/config.yaml \
|
||||
"$INSTALL_DIR/venv/bin/python" "$INSTALL_DIR/scripts/init_db.py"
|
||||
|
||||
# Enable everything
|
||||
systemctl unmask hostapd
|
||||
systemctl enable hostapd dnsmasq nginx esim-provisioning captive-portal
|
||||
systemctl restart hostapd dnsmasq nginx esim-provisioning
|
||||
|
||||
echo ""
|
||||
echo "=== Setup Complete ==="
|
||||
echo ""
|
||||
echo "Edit /etc/esim-provisioning/config.yaml to set your MCC/MNC/OP key."
|
||||
echo "Then: systemctl restart esim-provisioning"
|
||||
echo ""
|
||||
echo "Clients can connect to WiFi '$SSID' and will be redirected to the"
|
||||
echo "eSIM activation portal at http://$AP_IP/activate"
|
||||
45
scripts/sync_subscribers.py
Normal file
45
scripts/sync_subscribers.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Sync unsynced subscribers from SQLite to the configured core network."""
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from server.core_adapters import get_adapter
|
||||
from server.database import get_connection, list_unsynced, mark_synced
|
||||
from server.utils import load_config
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def main():
|
||||
config_path = os.environ.get("CONFIG_PATH", "config.yaml")
|
||||
cfg = load_config(config_path)
|
||||
|
||||
adapter = get_adapter(cfg)
|
||||
if adapter is None:
|
||||
logger.error("No core adapter configured. Set core.type to 'open5gs' or 'free5gc' in config.")
|
||||
sys.exit(1)
|
||||
|
||||
conn = get_connection(cfg["database"]["path"])
|
||||
unsynced = list_unsynced(conn)
|
||||
logger.info(f"Found {len(unsynced)} unsynced subscriber(s)")
|
||||
|
||||
success = 0
|
||||
for sub in unsynced:
|
||||
try:
|
||||
adapter.add_subscriber(sub.imsi, sub.ki, sub.opc)
|
||||
mark_synced(conn, sub.imsi)
|
||||
success += 1
|
||||
logger.info(f" Synced {sub.imsi}")
|
||||
except Exception as exc:
|
||||
logger.error(f" Failed to sync {sub.imsi}: {exc}")
|
||||
|
||||
conn.close()
|
||||
logger.info(f"Sync complete: {success}/{len(unsynced)} succeeded")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
0
server/__init__.py
Normal file
0
server/__init__.py
Normal file
207
server/app.py
Normal file
207
server/app.py
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import logging
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from flask import Flask, jsonify, redirect, request, send_file, send_from_directory
|
||||
|
||||
from server.core_adapters import get_adapter
|
||||
from server.database import (
|
||||
add_subscriber,
|
||||
check_rate_limit,
|
||||
export_all,
|
||||
get_connection,
|
||||
get_subscriber_by_id,
|
||||
init_db,
|
||||
)
|
||||
from server.profile_generator import (
|
||||
create_esim_profile,
|
||||
derive_opc,
|
||||
generate_iccid,
|
||||
generate_imsi,
|
||||
generate_ki,
|
||||
)
|
||||
from server.utils import (
|
||||
bytes_to_hex,
|
||||
get_client_ip,
|
||||
get_mac_from_arp,
|
||||
hex_to_bytes,
|
||||
load_config,
|
||||
normalize_mac,
|
||||
validate_mac,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_PATH = os.environ.get("CONFIG_PATH", "config.yaml")
|
||||
|
||||
_profile_cache: dict[int, bytes] = {}
|
||||
_start_time = time.time()
|
||||
|
||||
|
||||
def create_app(config_path: str = CONFIG_PATH) -> Flask:
|
||||
cfg = load_config(config_path)
|
||||
|
||||
portal_dir = os.path.join(os.path.dirname(__file__), "..", "portal")
|
||||
portal_dir = os.path.abspath(portal_dir)
|
||||
|
||||
app = Flask(__name__, static_folder=portal_dir, static_url_path="")
|
||||
|
||||
db_path = cfg["database"]["path"]
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
init_db(db_path)
|
||||
|
||||
adapter = get_adapter(cfg)
|
||||
if adapter:
|
||||
logger.info(f"Core adapter: {adapter.__class__.__name__}")
|
||||
else:
|
||||
logger.info("Core adapter: none (export-only mode)")
|
||||
|
||||
net_cfg = cfg["network"]
|
||||
mcc = net_cfg["mcc"]
|
||||
mnc = net_cfg["mnc"]
|
||||
op_bytes = hex_to_bytes(net_cfg["op_key"])
|
||||
|
||||
profile_cfg = cfg["profile"]
|
||||
profile_name = profile_cfg["profile_name"]
|
||||
carrier_name = profile_cfg["carrier_name"]
|
||||
|
||||
rl_cfg = cfg["rate_limiting"]
|
||||
window_hours = rl_cfg["window_hours"]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Routes
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return redirect("/activate", code=302)
|
||||
|
||||
@app.route("/activate")
|
||||
def activate():
|
||||
return send_from_directory(portal_dir, "index.html")
|
||||
|
||||
@app.route("/api/provision", methods=["POST"])
|
||||
def provision():
|
||||
body = request.get_json(silent=True) or {}
|
||||
|
||||
mac = body.get("mac_address", "").strip()
|
||||
if not mac:
|
||||
client_ip = get_client_ip(request)
|
||||
mac = get_mac_from_arp(client_ip) or ""
|
||||
|
||||
if not mac:
|
||||
return jsonify({"error": "Could not determine MAC address"}), 400
|
||||
|
||||
mac = normalize_mac(mac)
|
||||
if not validate_mac(mac):
|
||||
return jsonify({"error": "Invalid MAC address"}), 400
|
||||
|
||||
conn = get_connection(db_path)
|
||||
try:
|
||||
if not check_rate_limit(conn, mac, window_hours):
|
||||
return jsonify(
|
||||
{"error": "Rate limit exceeded. One eSIM profile per device per hour."}
|
||||
), 429
|
||||
|
||||
imsi = generate_imsi(mcc, mnc)
|
||||
ki = generate_ki()
|
||||
iccid = generate_iccid()
|
||||
opc = derive_opc(ki, op_bytes)
|
||||
|
||||
ki_hex = bytes_to_hex(ki)
|
||||
opc_hex = bytes_to_hex(opc)
|
||||
|
||||
profile_bytes = create_esim_profile(
|
||||
imsi, ki, opc, iccid, profile_name, carrier_name
|
||||
)
|
||||
|
||||
sub_id = add_subscriber(conn, imsi, iccid, ki_hex, opc_hex, mac)
|
||||
_profile_cache[sub_id] = profile_bytes
|
||||
|
||||
if adapter:
|
||||
try:
|
||||
adapter.add_subscriber(imsi, ki_hex, opc_hex)
|
||||
except Exception as exc:
|
||||
logger.error(f"Core adapter sync failed for {imsi}: {exc}")
|
||||
|
||||
logger.info(f"Provisioned IMSI {imsi} for MAC {mac} (id={sub_id})")
|
||||
return jsonify(
|
||||
{
|
||||
"id": sub_id,
|
||||
"imsi": imsi,
|
||||
"iccid": iccid,
|
||||
"profile_url": f"/api/profile/{sub_id}",
|
||||
}
|
||||
), 200
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception(f"Provision failed: {exc}")
|
||||
return jsonify({"error": "Profile generation failed"}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@app.route("/api/profile/<int:sub_id>")
|
||||
def get_profile(sub_id: int):
|
||||
profile_bytes = _profile_cache.get(sub_id)
|
||||
if profile_bytes is None:
|
||||
conn = get_connection(db_path)
|
||||
sub = get_subscriber_by_id(conn, sub_id)
|
||||
conn.close()
|
||||
if sub is None:
|
||||
return jsonify({"error": "Profile not found"}), 404
|
||||
return jsonify({"error": "Profile no longer cached; re-provision to download"}), 410
|
||||
|
||||
import io
|
||||
return send_file(
|
||||
io.BytesIO(profile_bytes),
|
||||
mimetype="application/octet-stream",
|
||||
as_attachment=True,
|
||||
download_name="esim-profile.bin",
|
||||
)
|
||||
|
||||
@app.route("/api/export")
|
||||
def export():
|
||||
conn = get_connection(db_path)
|
||||
try:
|
||||
subscribers = export_all(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||
"count": len(subscribers),
|
||||
"subscribers": subscribers,
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/status")
|
||||
def status():
|
||||
conn = get_connection(db_path)
|
||||
try:
|
||||
count = conn.execute("SELECT COUNT(*) FROM subscribers").fetchone()[0]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"subscriber_count": count,
|
||||
"uptime_seconds": int(time.time() - _start_time),
|
||||
"core_adapter": adapter.__class__.__name__ if adapter else "none",
|
||||
}
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
cfg = load_config(CONFIG_PATH)
|
||||
app = create_app(CONFIG_PATH)
|
||||
app.run(
|
||||
host=cfg["server"]["host"],
|
||||
port=cfg["server"]["port"],
|
||||
debug=cfg["server"].get("debug", False),
|
||||
)
|
||||
19
server/core_adapters/__init__.py
Normal file
19
server/core_adapters/__init__.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from .base import CoreAdapter
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def get_adapter(config: dict) -> Optional[CoreAdapter]:
|
||||
"""Factory: return the configured core adapter, or None for export-only mode."""
|
||||
core_cfg = config.get("core", {})
|
||||
core_type = core_cfg.get("type", "none")
|
||||
uri = core_cfg.get("mongodb_uri", "mongodb://localhost:27017")
|
||||
db_name = core_cfg.get("database_name", "open5gs")
|
||||
|
||||
if core_type == "open5gs":
|
||||
from .open5gs import Open5GSAdapter
|
||||
return Open5GSAdapter(uri, db_name)
|
||||
elif core_type == "free5gc":
|
||||
from .free5gc import Free5GCAdapter
|
||||
return Free5GCAdapter(uri, db_name)
|
||||
else:
|
||||
return None
|
||||
15
server/core_adapters/base.py
Normal file
15
server/core_adapters/base.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class CoreAdapter(ABC):
|
||||
@abstractmethod
|
||||
def add_subscriber(self, imsi: str, ki: str, opc: str) -> None:
|
||||
"""Add a subscriber to the core network. ki and opc are hex strings."""
|
||||
|
||||
@abstractmethod
|
||||
def remove_subscriber(self, imsi: str) -> None:
|
||||
"""Remove a subscriber from the core network."""
|
||||
|
||||
@abstractmethod
|
||||
def list_subscribers(self) -> list:
|
||||
"""Return a list of subscriber dicts from the core."""
|
||||
11
server/core_adapters/free5gc.py
Normal file
11
server/core_adapters/free5gc.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from .open5gs import Open5GSAdapter
|
||||
|
||||
|
||||
class Free5GCAdapter(Open5GSAdapter):
|
||||
"""free5gc adapter — uses the same MongoDB document schema as Open5GS.
|
||||
|
||||
Override methods here as free5gc-specific behaviour is needed.
|
||||
"""
|
||||
|
||||
def __init__(self, mongodb_uri: str, database_name: str = "free5gc"):
|
||||
super().__init__(mongodb_uri, database_name)
|
||||
54
server/core_adapters/open5gs.py
Normal file
54
server/core_adapters/open5gs.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import logging
|
||||
|
||||
from pymongo import MongoClient
|
||||
|
||||
from .base import CoreAdapter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Open5GSAdapter(CoreAdapter):
|
||||
def __init__(self, mongodb_uri: str, database_name: str = "open5gs"):
|
||||
self.client = MongoClient(mongodb_uri)
|
||||
self.db = self.client[database_name]
|
||||
|
||||
def add_subscriber(self, imsi: str, ki: str, opc: str) -> None:
|
||||
"""Upsert a subscriber into the Open5GS MongoDB collection."""
|
||||
doc = {
|
||||
"imsi": imsi,
|
||||
"security": {
|
||||
"k": ki,
|
||||
"opc": opc,
|
||||
"amf": "8000",
|
||||
"sqn": 0,
|
||||
},
|
||||
"ambr": {
|
||||
"downlink": {"value": 1, "unit": 3},
|
||||
"uplink": {"value": 1, "unit": 3},
|
||||
},
|
||||
"slice": [
|
||||
{
|
||||
"sst": 1,
|
||||
"default_indicator": True,
|
||||
"session": [
|
||||
{
|
||||
"name": "internet",
|
||||
"type": 3,
|
||||
"ambr": {
|
||||
"downlink": {"value": 1, "unit": 3},
|
||||
"uplink": {"value": 1, "unit": 3},
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
self.db.subscribers.replace_one({"imsi": imsi}, doc, upsert=True)
|
||||
logger.info(f"Added subscriber {imsi} to Open5GS")
|
||||
|
||||
def remove_subscriber(self, imsi: str) -> None:
|
||||
self.db.subscribers.delete_one({"imsi": imsi})
|
||||
logger.info(f"Removed subscriber {imsi} from Open5GS")
|
||||
|
||||
def list_subscribers(self) -> list:
|
||||
return list(self.db.subscribers.find({}, {"_id": 0}))
|
||||
144
server/database.py
Normal file
144
server/database.py
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import sqlite3
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from server.models import Subscriber
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS subscribers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
imsi TEXT UNIQUE NOT NULL,
|
||||
iccid TEXT UNIQUE NOT NULL,
|
||||
ki TEXT NOT NULL,
|
||||
opc TEXT NOT NULL,
|
||||
mac_address TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
synced_to_core BOOLEAN DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_imsi ON subscribers(imsi);
|
||||
CREATE INDEX IF NOT EXISTS idx_mac_address ON subscribers(mac_address);
|
||||
"""
|
||||
|
||||
|
||||
def init_db(db_path: str) -> None:
|
||||
"""Create the database schema if it does not already exist."""
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
conn.executescript(_SCHEMA)
|
||||
conn.commit()
|
||||
logger.info(f"Database initialized at {db_path}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_connection(db_path: str) -> sqlite3.Connection:
|
||||
"""Return an open connection with Row factory enabled."""
|
||||
conn = sqlite3.connect(db_path, detect_types=sqlite3.PARSE_DECLTYPES)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def _row_to_subscriber(row: sqlite3.Row) -> Subscriber:
|
||||
created = row["created_at"]
|
||||
if isinstance(created, str):
|
||||
created = datetime.fromisoformat(created)
|
||||
return Subscriber(
|
||||
id=row["id"],
|
||||
imsi=row["imsi"],
|
||||
iccid=row["iccid"],
|
||||
ki=row["ki"],
|
||||
opc=row["opc"],
|
||||
mac_address=row["mac_address"],
|
||||
created_at=created,
|
||||
synced_to_core=bool(row["synced_to_core"]),
|
||||
)
|
||||
|
||||
|
||||
def add_subscriber(
|
||||
conn: sqlite3.Connection,
|
||||
imsi: str,
|
||||
iccid: str,
|
||||
ki: str,
|
||||
opc: str,
|
||||
mac_address: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Insert a new subscriber. Returns the new row id.
|
||||
|
||||
Raises ValueError if imsi or iccid already exists.
|
||||
"""
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO subscribers (imsi, iccid, ki, opc, mac_address)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(imsi, iccid, ki, opc, mac_address),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise ValueError(f"Duplicate subscriber (imsi or iccid already exists): {exc}") from exc
|
||||
|
||||
|
||||
def get_subscriber_by_imsi(conn: sqlite3.Connection, imsi: str) -> Optional[Subscriber]:
|
||||
row = conn.execute("SELECT * FROM subscribers WHERE imsi = ?", (imsi,)).fetchone()
|
||||
return _row_to_subscriber(row) if row else None
|
||||
|
||||
|
||||
def get_subscriber_by_id(conn: sqlite3.Connection, sub_id: int) -> Optional[Subscriber]:
|
||||
row = conn.execute("SELECT * FROM subscribers WHERE id = ?", (sub_id,)).fetchone()
|
||||
return _row_to_subscriber(row) if row else None
|
||||
|
||||
|
||||
def list_unsynced(conn: sqlite3.Connection) -> list:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM subscribers WHERE synced_to_core = 0"
|
||||
).fetchall()
|
||||
return [_row_to_subscriber(r) for r in rows]
|
||||
|
||||
|
||||
def mark_synced(conn: sqlite3.Connection, imsi: str) -> None:
|
||||
conn.execute(
|
||||
"UPDATE subscribers SET synced_to_core = 1 WHERE imsi = ?", (imsi,)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def check_rate_limit(
|
||||
conn: sqlite3.Connection, mac_address: str, window_hours: int
|
||||
) -> bool:
|
||||
"""Return True if the MAC address is allowed to provision (no record in the window).
|
||||
|
||||
Uses SQLite's ``datetime()`` on both sides so the comparison is a true
|
||||
chronological one regardless of whether ``created_at`` was written by
|
||||
``CURRENT_TIMESTAMP`` (``"YYYY-MM-DD HH:MM:SS"``) or as a Python
|
||||
``isoformat()`` string (``"YYYY-MM-DDTHH:MM:SS.ffffff"``).
|
||||
"""
|
||||
cur = conn.execute(
|
||||
"SELECT COUNT(*) FROM subscribers "
|
||||
"WHERE mac_address = ? AND datetime(created_at) > datetime('now', ?)",
|
||||
(mac_address, f"-{window_hours} hours"),
|
||||
)
|
||||
count = cur.fetchone()[0]
|
||||
return count == 0
|
||||
|
||||
|
||||
def export_all(conn: sqlite3.Connection) -> list:
|
||||
"""Return all subscriber rows as a list of plain dicts."""
|
||||
rows = conn.execute("SELECT * FROM subscribers").fetchall()
|
||||
result = []
|
||||
for row in rows:
|
||||
result.append({
|
||||
"id": row["id"],
|
||||
"imsi": row["imsi"],
|
||||
"iccid": row["iccid"],
|
||||
"ki": row["ki"],
|
||||
"opc": row["opc"],
|
||||
"mac_address": row["mac_address"],
|
||||
"created_at": str(row["created_at"]),
|
||||
"synced_to_core": bool(row["synced_to_core"]),
|
||||
})
|
||||
return result
|
||||
15
server/models.py
Normal file
15
server/models.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class Subscriber:
|
||||
id: Optional[int]
|
||||
imsi: str
|
||||
iccid: str
|
||||
ki: str # hex string
|
||||
opc: str # hex string
|
||||
mac_address: Optional[str]
|
||||
created_at: datetime
|
||||
synced_to_core: bool
|
||||
92
server/profile_generator.py
Normal file
92
server/profile_generator.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import json
|
||||
import secrets
|
||||
import logging
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def generate_imsi(mcc: str, mnc: str) -> str:
|
||||
"""Generate a random 15-digit IMSI: MCC + MNC + MSIN.
|
||||
|
||||
IMSI is always 15 digits. MCC is 3 digits; MNC is 2 or 3 digits, so the
|
||||
MSIN fills the remaining 9 or 10 digits.
|
||||
"""
|
||||
msin_len = 15 - len(mcc) - len(mnc)
|
||||
if msin_len <= 0:
|
||||
raise ValueError(f"MCC/MNC too long for a 15-digit IMSI: {mcc!r}/{mnc!r}")
|
||||
msin = str(secrets.randbelow(10**msin_len)).zfill(msin_len)
|
||||
imsi = mcc + mnc + msin
|
||||
assert len(imsi) == 15, f"IMSI length error: {imsi!r}"
|
||||
return imsi
|
||||
|
||||
|
||||
def generate_ki() -> bytes:
|
||||
"""Generate a 16-byte cryptographically random authentication key."""
|
||||
return secrets.token_bytes(16)
|
||||
|
||||
|
||||
def luhn_checksum(number: str) -> int:
|
||||
"""Compute the Luhn check digit for a string of digits."""
|
||||
digits = [int(d) for d in number]
|
||||
odd_digits = digits[-1::-2]
|
||||
even_digits = digits[-2::-2]
|
||||
total = sum(odd_digits)
|
||||
for d in even_digits:
|
||||
total += sum(divmod(d * 2, 10))
|
||||
return (10 - (total % 10)) % 10
|
||||
|
||||
|
||||
def generate_iccid() -> str:
|
||||
"""Generate a 19-digit ICCID with a valid Luhn check digit.
|
||||
|
||||
Format: 8910 (telecom prefix) + 10 random digits + 5 more + Luhn digit = 19 total.
|
||||
"""
|
||||
prefix = "8910"
|
||||
body = prefix + str(secrets.randbelow(10**14)).zfill(14) # 18 digits
|
||||
check = luhn_checksum(body)
|
||||
return body + str(check)
|
||||
|
||||
|
||||
def derive_opc(ki: bytes, op: bytes) -> bytes:
|
||||
"""Derive OPc from Ki and OP using the Milenage algorithm.
|
||||
|
||||
OPc = AES_Ki(OP) XOR OP
|
||||
"""
|
||||
cipher = Cipher(algorithms.AES(ki), modes.ECB(), backend=default_backend())
|
||||
enc = cipher.encryptor()
|
||||
encrypted_op = enc.update(op) + enc.finalize()
|
||||
return bytes(a ^ b for a, b in zip(encrypted_op, op))
|
||||
|
||||
|
||||
def create_esim_profile(
|
||||
imsi: str,
|
||||
ki: bytes,
|
||||
opc: bytes,
|
||||
iccid: str,
|
||||
profile_name: str,
|
||||
carrier_name: str,
|
||||
) -> bytes:
|
||||
"""Create an eSIM profile.
|
||||
|
||||
Phase 1: returns a JSON-encoded mock profile suitable for end-to-end
|
||||
testing. Phase 2 (Unit 06 upgrade): replace with real pySim SGP.22
|
||||
profile generation.
|
||||
"""
|
||||
ki_hex = ki.hex() if isinstance(ki, bytes) else ki
|
||||
opc_hex = opc.hex() if isinstance(opc, bytes) else opc
|
||||
|
||||
profile = {
|
||||
"version": "1.0",
|
||||
"type": "mock",
|
||||
"iccid": iccid,
|
||||
"imsi": imsi,
|
||||
"ki": ki_hex,
|
||||
"opc": opc_hex,
|
||||
"profile_name": profile_name,
|
||||
"carrier_name": carrier_name,
|
||||
}
|
||||
logger.debug(f"Created mock eSIM profile for IMSI {imsi}")
|
||||
return json.dumps(profile, indent=2).encode("utf-8")
|
||||
77
server/utils.py
Normal file
77
server/utils.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import os
|
||||
import re
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MAC_RE = re.compile(r"^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$")
|
||||
|
||||
_REQUIRED_CONFIG_KEYS = ["network", "server", "database", "rate_limiting", "profile"]
|
||||
|
||||
|
||||
def validate_mac(mac: str) -> bool:
|
||||
"""Return True if mac is a valid colon-separated MAC address."""
|
||||
return bool(mac and _MAC_RE.match(mac))
|
||||
|
||||
|
||||
def normalize_mac(mac: str) -> str:
|
||||
"""Return a lowercase, colon-separated MAC address.
|
||||
|
||||
Accepts input with dashes, dots, or no separators.
|
||||
"""
|
||||
clean = mac.lower().replace("-", ":").replace(".", ":")
|
||||
# Handle 12-char no-separator format
|
||||
if ":" not in clean and len(clean) == 12:
|
||||
clean = ":".join(clean[i : i + 2] for i in range(0, 12, 2))
|
||||
return clean
|
||||
|
||||
|
||||
def validate_imsi(imsi: str) -> bool:
|
||||
"""Return True if imsi is exactly 15 decimal digits."""
|
||||
return bool(imsi) and imsi.isdigit() and len(imsi) == 15
|
||||
|
||||
|
||||
def load_config(path: str) -> dict:
|
||||
"""Load and minimally validate a YAML config file."""
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(f"Config file not found: {path}")
|
||||
with open(path) as f:
|
||||
cfg = yaml.safe_load(f)
|
||||
for key in _REQUIRED_CONFIG_KEYS:
|
||||
if key not in cfg:
|
||||
raise ValueError(f"Missing required config section: '{key}'")
|
||||
return cfg
|
||||
|
||||
|
||||
def hex_to_bytes(h: str) -> bytes:
|
||||
return bytes.fromhex(h)
|
||||
|
||||
|
||||
def bytes_to_hex(b: bytes) -> str:
|
||||
return b.hex()
|
||||
|
||||
|
||||
def get_client_ip(request) -> str:
|
||||
"""Return the real client IP, respecting X-Forwarded-For from nginx."""
|
||||
forwarded = request.headers.get("X-Forwarded-For")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.remote_addr or ""
|
||||
|
||||
|
||||
def get_mac_from_arp(ip: str) -> Optional[str]:
|
||||
"""Attempt to resolve a MAC address from /proc/net/arp for the given IP."""
|
||||
try:
|
||||
with open("/proc/net/arp") as f:
|
||||
for line in f:
|
||||
parts = line.split()
|
||||
if len(parts) >= 4 and parts[0] == ip:
|
||||
mac = parts[3]
|
||||
if validate_mac(mac):
|
||||
return normalize_mac(mac)
|
||||
except Exception as exc:
|
||||
logger.debug(f"ARP lookup failed for {ip}: {exc}")
|
||||
return None
|
||||
13
systemd/captive-portal.service
Normal file
13
systemd/captive-portal.service
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
[Unit]
|
||||
Description=Captive Portal (nginx + dnsmasq)
|
||||
After=network.target hostapd.service
|
||||
Wants=nginx.service dnsmasq.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/bin/systemctl start nginx dnsmasq
|
||||
ExecStop=/bin/systemctl stop nginx dnsmasq
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
26
systemd/esim-provisioning.service
Normal file
26
systemd/esim-provisioning.service
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
[Unit]
|
||||
Description=eSIM Provisioning Server
|
||||
After=network.target
|
||||
Wants=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=esim
|
||||
Group=esim
|
||||
WorkingDirectory=/opt/esim-provisioning
|
||||
Environment=CONFIG_PATH=/etc/esim-provisioning/config.yaml
|
||||
ExecStart=/opt/esim-provisioning/venv/bin/python -m server.app
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=esim-provisioning
|
||||
|
||||
# Basic hardening
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/opt/esim-provisioning/data
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
76
tests/test_core_adapters.py
Normal file
76
tests/test_core_adapters.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from server.core_adapters import get_adapter
|
||||
from server.core_adapters.open5gs import Open5GSAdapter
|
||||
|
||||
|
||||
class TestFactory:
|
||||
def test_none_type_returns_none(self):
|
||||
cfg = {"core": {"type": "none"}}
|
||||
assert get_adapter(cfg) is None
|
||||
|
||||
def test_missing_core_section_returns_none(self):
|
||||
assert get_adapter({}) is None
|
||||
|
||||
def test_open5gs_type_returns_adapter(self):
|
||||
cfg = {"core": {"type": "open5gs", "mongodb_uri": "mongodb://localhost:27017", "database_name": "open5gs"}}
|
||||
with patch("server.core_adapters.open5gs.MongoClient"):
|
||||
adapter = get_adapter(cfg)
|
||||
assert isinstance(adapter, Open5GSAdapter)
|
||||
|
||||
def test_free5gc_type_returns_adapter(self):
|
||||
from server.core_adapters.free5gc import Free5GCAdapter
|
||||
cfg = {"core": {"type": "free5gc", "mongodb_uri": "mongodb://localhost:27017", "database_name": "free5gc"}}
|
||||
with patch("server.core_adapters.open5gs.MongoClient"):
|
||||
adapter = get_adapter(cfg)
|
||||
assert isinstance(adapter, Free5GCAdapter)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def open5gs_adapter():
|
||||
with patch("server.core_adapters.open5gs.MongoClient"):
|
||||
adapter = Open5GSAdapter("mongodb://localhost:27017", "open5gs")
|
||||
adapter.db = MagicMock()
|
||||
yield adapter
|
||||
|
||||
|
||||
class TestOpen5GSAdapter:
|
||||
def test_add_subscriber_calls_replace_one(self, open5gs_adapter):
|
||||
open5gs_adapter.add_subscriber("302720000000001", "aa" * 16, "bb" * 16)
|
||||
open5gs_adapter.db.subscribers.replace_one.assert_called_once()
|
||||
|
||||
def test_add_subscriber_document_shape(self, open5gs_adapter):
|
||||
open5gs_adapter.add_subscriber("302720000000001", "aabbcc", "ddeeff")
|
||||
call_args = open5gs_adapter.db.subscribers.replace_one.call_args
|
||||
filter_doc = call_args[0][0]
|
||||
sub_doc = call_args[0][1]
|
||||
|
||||
assert filter_doc == {"imsi": "302720000000001"}
|
||||
assert sub_doc["imsi"] == "302720000000001"
|
||||
assert sub_doc["security"]["k"] == "aabbcc"
|
||||
assert sub_doc["security"]["opc"] == "ddeeff"
|
||||
assert sub_doc["security"]["amf"] == "8000"
|
||||
assert "slice" in sub_doc
|
||||
assert len(sub_doc["slice"]) == 1
|
||||
assert sub_doc["slice"][0]["sst"] == 1
|
||||
assert sub_doc["slice"][0]["session"][0]["name"] == "internet"
|
||||
|
||||
def test_add_subscriber_upsert_true(self, open5gs_adapter):
|
||||
open5gs_adapter.add_subscriber("302720000000001", "aa", "bb")
|
||||
call_kwargs = open5gs_adapter.db.subscribers.replace_one.call_args[1]
|
||||
assert call_kwargs.get("upsert") is True
|
||||
|
||||
def test_remove_subscriber(self, open5gs_adapter):
|
||||
open5gs_adapter.remove_subscriber("302720000000001")
|
||||
open5gs_adapter.db.subscribers.delete_one.assert_called_once_with(
|
||||
{"imsi": "302720000000001"}
|
||||
)
|
||||
|
||||
def test_list_subscribers(self, open5gs_adapter):
|
||||
open5gs_adapter.db.subscribers.find.return_value = [
|
||||
{"imsi": "302720000000001"}
|
||||
]
|
||||
result = open5gs_adapter.list_subscribers()
|
||||
assert len(result) == 1
|
||||
assert result[0]["imsi"] == "302720000000001"
|
||||
120
tests/test_database.py
Normal file
120
tests/test_database.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import pytest
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from server.database import (
|
||||
add_subscriber,
|
||||
check_rate_limit,
|
||||
export_all,
|
||||
get_connection,
|
||||
get_subscriber_by_id,
|
||||
get_subscriber_by_imsi,
|
||||
init_db,
|
||||
list_unsynced,
|
||||
mark_synced,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_conn(tmp_path):
|
||||
db_path = str(tmp_path / "test.db")
|
||||
init_db(db_path)
|
||||
conn = get_connection(db_path)
|
||||
yield conn
|
||||
conn.close()
|
||||
|
||||
|
||||
def _sub(**overrides):
|
||||
defaults = dict(
|
||||
imsi="302720000000001",
|
||||
iccid="8910302720000000001",
|
||||
ki="aa" * 16,
|
||||
opc="bb" * 16,
|
||||
mac_address="aa:bb:cc:dd:ee:ff",
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return defaults
|
||||
|
||||
|
||||
class TestCRUD:
|
||||
def test_add_and_retrieve_by_imsi(self, db_conn):
|
||||
d = _sub()
|
||||
sid = add_subscriber(db_conn, **d)
|
||||
sub = get_subscriber_by_imsi(db_conn, d["imsi"])
|
||||
assert sub is not None
|
||||
assert sub.imsi == d["imsi"]
|
||||
assert sub.id == sid
|
||||
|
||||
def test_add_and_retrieve_by_id(self, db_conn):
|
||||
d = _sub()
|
||||
sid = add_subscriber(db_conn, **d)
|
||||
sub = get_subscriber_by_id(db_conn, sid)
|
||||
assert sub is not None
|
||||
assert sub.iccid == d["iccid"]
|
||||
|
||||
def test_duplicate_imsi_raises(self, db_conn):
|
||||
add_subscriber(db_conn, **_sub())
|
||||
with pytest.raises(ValueError, match="Duplicate"):
|
||||
add_subscriber(db_conn, **_sub())
|
||||
|
||||
def test_duplicate_iccid_raises(self, db_conn):
|
||||
add_subscriber(db_conn, **_sub())
|
||||
with pytest.raises(ValueError):
|
||||
add_subscriber(db_conn, **_sub(imsi="302720000000002"))
|
||||
|
||||
def test_not_found_returns_none(self, db_conn):
|
||||
assert get_subscriber_by_imsi(db_conn, "999999999999999") is None
|
||||
assert get_subscriber_by_id(db_conn, 99999) is None
|
||||
|
||||
def test_synced_to_core_defaults_false(self, db_conn):
|
||||
add_subscriber(db_conn, **_sub())
|
||||
sub = get_subscriber_by_imsi(db_conn, _sub()["imsi"])
|
||||
assert sub.synced_to_core is False
|
||||
|
||||
|
||||
class TestRateLimiting:
|
||||
def test_first_request_allowed(self, db_conn):
|
||||
assert check_rate_limit(db_conn, "aa:bb:cc:dd:ee:ff", 1) is True
|
||||
|
||||
def test_second_request_blocked(self, db_conn):
|
||||
add_subscriber(db_conn, **_sub())
|
||||
assert check_rate_limit(db_conn, "aa:bb:cc:dd:ee:ff", 1) is False
|
||||
|
||||
def test_different_mac_allowed(self, db_conn):
|
||||
add_subscriber(db_conn, **_sub())
|
||||
assert check_rate_limit(db_conn, "11:22:33:44:55:66", 1) is True
|
||||
|
||||
def test_expired_window_allows_again(self, db_conn):
|
||||
add_subscriber(db_conn, **_sub())
|
||||
past = (datetime.utcnow() - timedelta(hours=2)).isoformat()
|
||||
db_conn.execute("UPDATE subscribers SET created_at = ?", (past,))
|
||||
db_conn.commit()
|
||||
assert check_rate_limit(db_conn, "aa:bb:cc:dd:ee:ff", 1) is True
|
||||
|
||||
|
||||
class TestSync:
|
||||
def test_list_unsynced_after_insert(self, db_conn):
|
||||
add_subscriber(db_conn, **_sub())
|
||||
assert len(list_unsynced(db_conn)) == 1
|
||||
|
||||
def test_mark_synced_removes_from_unsynced(self, db_conn):
|
||||
add_subscriber(db_conn, **_sub())
|
||||
mark_synced(db_conn, _sub()["imsi"])
|
||||
assert len(list_unsynced(db_conn)) == 0
|
||||
|
||||
def test_synced_flag_set(self, db_conn):
|
||||
add_subscriber(db_conn, **_sub())
|
||||
mark_synced(db_conn, _sub()["imsi"])
|
||||
sub = get_subscriber_by_imsi(db_conn, _sub()["imsi"])
|
||||
assert sub.synced_to_core is True
|
||||
|
||||
|
||||
class TestExport:
|
||||
def test_export_returns_list_with_correct_keys(self, db_conn):
|
||||
add_subscriber(db_conn, **_sub())
|
||||
rows = export_all(db_conn)
|
||||
assert len(rows) == 1
|
||||
for key in ("imsi", "iccid", "ki", "opc", "mac_address", "created_at", "synced_to_core"):
|
||||
assert key in rows[0]
|
||||
|
||||
def test_export_empty_db(self, db_conn):
|
||||
assert export_all(db_conn) == []
|
||||
117
tests/test_profile_generator.py
Normal file
117
tests/test_profile_generator.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import json
|
||||
import pytest
|
||||
|
||||
from server.profile_generator import (
|
||||
create_esim_profile,
|
||||
derive_opc,
|
||||
generate_iccid,
|
||||
generate_imsi,
|
||||
generate_ki,
|
||||
luhn_checksum,
|
||||
)
|
||||
|
||||
_OP = bytes.fromhex("63bfa50ee6523365ff14c1f45f88737d")
|
||||
|
||||
|
||||
class TestIMSI:
|
||||
def test_length(self):
|
||||
assert len(generate_imsi("302", "720")) == 15
|
||||
|
||||
def test_prefix(self):
|
||||
imsi = generate_imsi("302", "720")
|
||||
assert imsi.startswith("302720")
|
||||
|
||||
def test_digits_only(self):
|
||||
assert generate_imsi("302", "720").isdigit()
|
||||
|
||||
def test_uniqueness(self):
|
||||
imsis = {generate_imsi("302", "720") for _ in range(100)}
|
||||
assert len(imsis) > 90
|
||||
|
||||
def test_different_mcc_mnc(self):
|
||||
imsi = generate_imsi("001", "01")
|
||||
assert imsi.startswith("00101")
|
||||
assert len(imsi) == 15
|
||||
|
||||
|
||||
class TestKi:
|
||||
def test_length(self):
|
||||
assert len(generate_ki()) == 16
|
||||
|
||||
def test_randomness(self):
|
||||
keys = {generate_ki() for _ in range(50)}
|
||||
assert len(keys) == 50
|
||||
|
||||
|
||||
class TestICCID:
|
||||
def test_length(self):
|
||||
assert len(generate_iccid()) == 19
|
||||
|
||||
def test_digits_only(self):
|
||||
assert generate_iccid().isdigit()
|
||||
|
||||
def test_luhn_valid(self):
|
||||
iccid = generate_iccid()
|
||||
assert luhn_checksum(iccid[:-1]) == int(iccid[-1])
|
||||
|
||||
def test_starts_with_prefix(self):
|
||||
assert generate_iccid().startswith("8910")
|
||||
|
||||
def test_uniqueness(self):
|
||||
iccids = {generate_iccid() for _ in range(50)}
|
||||
assert len(iccids) == 50
|
||||
|
||||
|
||||
class TestOPc:
|
||||
KI_HEX = "000102030405060708090a0b0c0d0e0f"
|
||||
|
||||
def test_output_length(self):
|
||||
ki = bytes.fromhex(self.KI_HEX)
|
||||
assert len(derive_opc(ki, _OP)) == 16
|
||||
|
||||
def test_deterministic(self):
|
||||
ki = bytes.fromhex(self.KI_HEX)
|
||||
assert derive_opc(ki, _OP) == derive_opc(ki, _OP)
|
||||
|
||||
def test_different_from_inputs(self):
|
||||
ki = bytes.fromhex(self.KI_HEX)
|
||||
opc = derive_opc(ki, _OP)
|
||||
assert opc != _OP
|
||||
assert opc != ki
|
||||
|
||||
def test_different_ki_gives_different_opc(self):
|
||||
ki1 = bytes.fromhex(self.KI_HEX)
|
||||
ki2 = bytes.fromhex("0f0e0d0c0b0a09080706050403020100")
|
||||
assert derive_opc(ki1, _OP) != derive_opc(ki2, _OP)
|
||||
|
||||
|
||||
class TestProfile:
|
||||
def _make_profile(self):
|
||||
ki = generate_ki()
|
||||
opc = derive_opc(ki, _OP)
|
||||
imsi = generate_imsi("302", "720")
|
||||
iccid = generate_iccid()
|
||||
return create_esim_profile(imsi, ki, opc, iccid, "Test Profile", "Test Carrier")
|
||||
|
||||
def test_returns_bytes(self):
|
||||
assert isinstance(self._make_profile(), bytes)
|
||||
|
||||
def test_non_empty(self):
|
||||
assert len(self._make_profile()) > 0
|
||||
|
||||
def test_valid_json(self):
|
||||
data = json.loads(self._make_profile())
|
||||
assert "imsi" in data
|
||||
assert "ki" in data
|
||||
assert "opc" in data
|
||||
assert "iccid" in data
|
||||
|
||||
def test_imsi_in_profile(self):
|
||||
ki = generate_ki()
|
||||
opc = derive_opc(ki, _OP)
|
||||
imsi = generate_imsi("302", "720")
|
||||
iccid = generate_iccid()
|
||||
profile = create_esim_profile(imsi, ki, opc, iccid, "P", "C")
|
||||
data = json.loads(profile)
|
||||
assert data["imsi"] == imsi
|
||||
assert data["iccid"] == iccid
|
||||
Loading…
Reference in New Issue
Block a user