Skip to main content
Glama
README.md
# EuiccBridge

A CLI (and MCP server) for managing eSIM profiles, SIM SMS, and cellular
modem diagnostics on Linux — local-first, scriptable, and safe to point an
AI agent at.

- **eSIM lifecycle**: list, download, enable, disable, delete, switch Profiles
- **SMS**: send/receive over AT/PDU directly, no `ModemManager` dependency
- **Diagnostics**: registration, APN/PDP, interface, and connectivity checks,
  plus a `doctor` command for environment pre-flight checks
- **Safety by default**: confirmations, redaction, allowlists, audit logging
- **One core library, three fronts**: CLI today, MCP server for AI tooling,
  Web API planned — all calling the same code, not shelling out to each other

Built on top of proven lower-level tools (`lpac`, `libqmi/qmicli`, standard
Linux networking and AT commands) rather than reimplementing GSMA RSP, QMI,
or AT protocol stacks from scratch.

## Goals

- Keep eSIM activation codes, Profile operations, SMS, and SIM identifiers under
  local, auditable control.
- Use a Linux VM or small Linux host as the hardware controller.
- Expose one shared core library through CLI first, then MCP, then optional Web
  API/WebUI.
- Make every dangerous operation explicit, confirmed, logged safely, and
  reversible where possible.

## Non-Goals

- Do not implement GSMA RSP, APDU, QMI, MBIM, IMS, ePDG, IPsec, or SIP stacks.
- Do not promise VoWiFi calling or SMS over WiFi Calling.
- Do not build a multi-tenant/commercial traffic pool, IP-rotation-for-hire
  service, or Bot platform. (A single-SIM, self-use SOCKS5/HTTP proxy bound to
  whichever cellular interface is currently active is a different, smaller
  thing — see Roadmap. The line being drawn is multi-tenant/pool vs.
  self-use, not "proxy vs. no proxy".)

## Architecture

```text
euiccbridge core library
  |-- CLI          first deliverable, machine-readable JSON
  |-- MCP server   AI/tooling entrypoint, same core library
  `-- Web API      later layer, same core library
```

The CLI is an entrypoint, not the business logic layer. Web API and MCP call
the same Python core library directly rather than shelling out to the CLI.

Core modules:

- `core.device` / `core.device_paths`: AT port / QMI device / cellular
  interface discovery, AT command execution, modem status parsing.
- `core.qmi`: QMI device discovery and status via `qmicli`.
- `core.esim`: `lpac` wrapper for eUICC/Profile operations (info, list,
  download, enable, disable, delete, switch).
- `core.gsm_pdu`: self-contained GSM 03.40/03.38 SMS PDU codec (no
  `ModemManager` dependency — see "SMS backend" below).
- `core.sms` / `core.sms_store`: SMS sync/list/send, backed by a local
  SQLite store that's the decoupling point for downstream consumers.
- `core.data`: APN, registration, PDP, interface, and public-IP diagnostics.
- `core.doctor`: read-only environment pre-flight checks.
- `core.config`: config loading + SMS allowlist management.
- `core.security`: confirmation, masking, allowlist, rate limits, audit.

### SMS backend: AT/PDU-direct, not ModemManager

SMS is implemented from scratch against `AT+CMGF`/`AT+CPMS`/`AT+CMGL`/`AT+CMGS`
in PDU mode, not via `ModemManager`/`mmcli`. This was a deliberate choice: a
`ModemManager` instance and this project's own direct QMI/AT usage can end up
contending for the same control channel, and comparable closed-source modem
managers in this space document the same conflict and instruct disabling
`ModemManager` rather than depending on it. Going AT/PDU-direct avoids adding
a persistent background service and keeps this project's "auditable,
local-first, no unnecessary daemons" posture consistent across eSIM, SMS, and
diagnostics.

## Relationship with EasyLPAC

EasyLPAC is a mature open-source `lpac` GUI frontend. EuiccBridge uses it as
a reference and optional manual fallback, not as a runtime dependency.

Useful parts borrowed from EasyLPAC's design: `lpac` discovery behavior (same
directory, system `PATH`, then `/usr/bin/lpac`), eSIM operation flow and
user-facing error handling, eUICC manufacturer/certificate/SAS display ideas,
and profile-notification handling (process after operations by default, with
a setting to disable it).

EuiccBridge still calls `lpac` directly because it needs machine-readable
CLI/MCP/Web API output, SMS, modem status, APN/PDP diagnosis, allowlists,
audit, and redaction — those are service/controller concerns, not GUI
concerns.

## v1 Scope

- Device discovery: AT port, QMI device, cellular interface, IMEI, ICCID, IMSI,
  subscriber number (when the SIM has one written to it — many data-only/IoT
  SIMs don't).
- Radio state: operator, PLMN, registration state, signal, LTE band, APN, PDP,
  interface IP.
- eSIM management: EID, Profile list, download, enable, disable, delete,
  switch (one-step disable-current + enable-target).
- SMS: sync/list/send, allowlist, daily limit, safe audit, incremental
  (`--since-id`) reads for downstream consumers.
- Data diagnosis: distinguish no registration, empty APN, inactive PDP, no
  interface IP, and public connectivity failure.
- `doctor`: read-only pre-flight checks (AT/QMI reachability, `lpac`
  presence and backend health, device-node integrity, QMI raw-ip mode).
- CLI and MCP: both use the same core library and default to redacted output.

v1 does not attempt: WiFi Calling SMS, VoWiFi outbound calls, IMS/ePDG/
IPsec/SIP implementation, or compatibility with any specific closed-source
modem manager's API.

## v1 CLI

The CLI supports JSON output so MCP, Web API, and scripts can consume the
same result shape.

```bash
python3 -m euiccbridge doctor --json
python3 -m euiccbridge device list --json
python3 -m euiccbridge device status --json
python3 -m euiccbridge esim info --json
python3 -m euiccbridge esim list --json
python3 -m euiccbridge esim download --activation-code-stdin --confirm --json
python3 -m euiccbridge esim enable --iccid <iccid> --confirm --json
python3 -m euiccbridge esim disable --iccid <iccid> --confirm --json
python3 -m euiccbridge esim switch --iccid <iccid> --confirm --json
python3 -m euiccbridge esim delete --iccid <iccid> --confirm --force --json
python3 -m euiccbridge sms sync --json
python3 -m euiccbridge sms list --json
python3 -m euiccbridge sms send --to <number> --text <text> --confirm --json
python3 -m euiccbridge sms allowlist add --number <number> --json
python3 -m euiccbridge data diagnose --apn <apn> --json
python3 -m euiccbridge data test --interface <iface> --json
```

`esim download` prompts for the activation code on stdin (paste + Enter, no
Ctrl+D needed) rather than taking it as an argument; add `-v/--verbose` to
stream `lpac`'s progress live instead of waiting for one final JSON blob.

### Multiple devices

If more than one physical modem is attached, every command above needs a
top-level `--device <label>` (before the subcommand) to say which one to
target:

```bash
python3 -m euiccbridge device list --json
python3 -m euiccbridge --device 5-3 device status --json
python3 -m euiccbridge --device 5-3 esim list --json
python3 -m euiccbridge --device 5-3 sms send --to <number> --text <text> --confirm --json
```

`device list` shows each attached device's label (its USB serial number if
the hardware reports one, otherwise its USB bus-port path e.g. `5-3` --
some rebranded/OEM modules don't report a serial). Devices are identified
by correlating AT port + QMI device + network interface via Linux USB
topology (sysfs), not by enumeration order, since `/dev/ttyUSB*` numbering
isn't stable across reconnects.

Omitting `--device` works exactly as before when exactly one device is
attached (nothing changes for single-device setups). With more than one
attached and no `--device` given, every command fails explicitly with
`"N devices found (...) but none selected"` rather than silently guessing
-- auto-discovering AT port and QMI device independently, with no
guarantee they picked the same physical modem, previously produced a
`device status` response silently mixing fields from two different modems
together (see `docs/TROUBLESHOOTING.md`). Set `default_device` in config
to avoid passing `--device` on every call.

SMS messages are tagged with the device they were sent/received through,
and the daily send limit (`sms_daily_limit`) is tracked per device, not
globally -- two modems each get their own budget.

## Safety Rules

- eSIM activation codes are read from stdin/interactive input, never shell
  arguments, and are never written to logs, audit files, tracebacks, or JSON
  responses.
- eSIM write operations refuse to run while a conflicting modem-manager
  service is active.
- Profile deletion requires both `--confirm` and `--force`.
- SMS sending requires explicit confirmation and an allowlisted recipient,
  plus a configurable daily send limit.
- EID, ICCID, IMSI, IMEI, phone numbers, activation codes, and SMS bodies are
  redacted by default in every command's output; an explicit opt-out flag
  (audited when used) reveals it per-call — `--reveal` on `esim`/`sms`
  commands, `--no-redact` on `device status`.
- MCP dangerous tools require `confirm: true`.
- Services do not listen on public interfaces.

## Compatibility

Validated against a Quectel EC25/EG25-class USB modem (AT + QMI) paired with
an eUICC exposing a GSMA RSP2 Root CI1 certificate, on a Debian Linux
controller. The design is not hardcoded to that one device.

Compatibility tiers:

- Tier 1: Quectel EC25/EG25/EC20-like AT + QMI modems.
- Tier 2: Other cellular modems exposing standard AT registration/SMS commands.
- Tier 3: `lpac`-supported eUICC readers, eSIM management only.

Tiers 1 and 2 need only a SIM (physical or eSIM) to work — device status,
signal, registration, data diagnosis, and SMS all operate at the AT/QMI
layer and don't care what kind of SIM is inserted. Tier 3 (the `esim`
command family) specifically needs an actual eUICC with a GSMA ISD-R
applet; a plain (non-eUICC) SIM makes every `esim` command fail, and
`doctor`'s `lpac_backend` check reports this distinctly (`warn`, "no eUICC
detected") rather than treating it as a broken environment.

Not supported: phone built-in eSIM interfaces, native macOS/Windows USB modem
management, generic VoWiFi calling/SMS.

## Roadmap

- v1: local CLI + MCP, device status, eSIM management, SMS, data diagnosis,
  `doctor`, multi-device selection (`--device`, per-device SMS attribution
  and daily limits) — **complete**, every item unit-tested and real-hardware
  validated (including with two physical modems attached simultaneously)
  except eSIM `download`'s success path, which is validated but depends on
  network conditions outside this project's control (see
  `docs/TROUBLESHOOTING.md`).
- v1.1: scheduled keepalive SMS with allowlist, limits, retries, and reports.
- v1.2: local Web API backed by the same core library.
- v1.3: lightweight WebUI for status, eSIM, SMS, and diagnostics.
- v1.x: self-use single-card SOCKS5/HTTP proxy bound to whichever cellular
  interface is currently active (explicitly not the multi-tenant/pool
  feature described in Non-Goals).
- v2: human-friendly device labels/nicknames (beyond the raw serial/USB
  path), per-device queues, and cross-device aggregation views.
- v3: VoWiFi/IMS status diagnostics and log collection only.
- v4: advanced data routing and per-interface outbound testing.

Near-term, more granular candidates: `+CMTI` real-time SMS notification
(deferred — needs a persistent AT-port listener, a different execution model
than the rest of this project; a periodic `sms sync` + notification hook
covers most of the same need at much less complexity), optional GSM 7-bit
*encode* path for outgoing SMS (currently UCS2-only by design), install/
dependency guidance per distro, an `uqmi` backend for OpenWrt.

## Status

All v1 Scope items are implemented, unit-tested, and validated against real
hardware (eSIM info/list/download/enable/disable/delete/switch; SMS sync/
list/send including multi-part concatenation and non-ASCII content; device
status; data diagnosis; `doctor`).

Two real bugs were found and fixed during hardware validation that are worth
knowing about if you're bringing up your own hardware — see
`docs/TROUBLESHOOTING.md` for the full symptom/cause/fix writeups:

- USB re-enumeration can corrupt AT-port device nodes into plain regular
  files; `doctor`'s `device_node_types` check catches this directly.
- QMI WWAN interfaces in Ethernet/802.3 mode can fail to route reliably on
  some hardware; `doctor`'s `qmi_raw_ip` check flags this, and
  `scripts/80-euiccbridge-qmi-rawip.rules` is a working fix you can adapt to
  your modem's VID:PID.

## Current Runtime Notes

- A conflicting modem-manager service (if one is installed) should remain
  stopped while running EuiccBridge — it will contend for the same QMI
  control channel and AT port.
- `lpac` is not vendored in this repository; install it separately and point
  `lpac_path` at it.
- `sms_allowed_numbers` can be managed without hand-editing the config file:
  `euiccbridge sms allowlist add/remove/list --number <number> --json`.
- Scheduled SMS sync, single device: `scripts/euiccbridge-sms-sync.service` +
  `.timer` (copy both to `/etc/systemd/system/`, `systemctl daemon-reload`,
  `systemctl enable --now euiccbridge-sms-sync.timer`). Runs
  `euiccbridge sms sync` every 30s by default; adjust `OnUnitActiveSec` in
  the `.timer` file to match `sms_poll_interval_seconds` if you change that
  config value.
- Scheduled SMS sync, multiple devices: the plain service above has no
  `--device`, so with more than one physical device attached it fails
  every run (`"N devices found but none selected"`) instead of silently
  syncing the wrong one. Use the templated units instead --
  `scripts/euiccbridge-sms-sync@.service` + `@.timer` -- one instance per
  device, named after its `device list` label:
  ```bash
  systemctl enable --now euiccbridge-sms-sync@5-3.timer
  systemctl enable --now euiccbridge-sms-sync@5-2.timer
  ```
  Since device labels can be a USB bus-port path (when the hardware
  reports no serial number), replugging a device into a different port
  changes its label -- re-check `device list` and re-enable the timer
  under the new label if that happens.
- `scripts/sms_notify.py` is a separate, optional consumer script
  (deliberately **not** part of the `euiccbridge` package — it's
  policy/orchestration, reading the stable CLI JSON contract, not a
  capability). It runs with `--reveal`, so point `hook_command`/
  `webhook_url` at a target you trust with real numbers/text. See its
  docstring for the three config shapes it accepts (auto-discover,
  explicit multi-device, legacy single-device) -- the Docker deployment
  below generates the auto-discover shape for you from `.env`, so most
  setups never need to hand-write this file at all.
- `scripts/sms_email_notify.py` is a ready-made `hook_command` target that
  emails each new message via SMTP (see
  `scripts/sms_email_notify.config.example.json` -- use an app-specific
  SMTP password, not your real account password, and keep that config file
  root-readable only). Wire it in from `sms_notify.config.json`:
  `"hook_command": "python3 scripts/sms_email_notify.py /path/to/email_notify.config.json"`.
  Run `sms_notify.py` on its own schedule, separate from
  `euiccbridge-sms-sync[@.]timer` (which only pulls messages off the modem,
  it doesn't dispatch notifications) -- there's no packaged systemd unit
  for it yet, add one following the `euiccbridge-sms-sync.timer` pattern.
- The cellular data interface commonly needs QMI raw-ip mode to route real
  traffic reliably — see `scripts/80-euiccbridge-qmi-rawip.rules` and
  `docs/TROUBLESHOOTING.md`.

## Example Config

Create `euiccbridge.config.json` in this directory, or `/opt/euiccbridge/config.json`.

Minimal single-modem config:

```json
{
  "lpac_path": "/opt/euiccbridge/bin/lpac",
  "lpac_apdu": "qmi",
  "lpac_http": "curl",
  "auto_process_notifications": false
}
```

Pinned config for multi-device or debugging:

```json
{
  "at_port": "/dev/ttyUSB2",
  "qmi_device": "/dev/cdc-wdm0",
  "interface": "wwp0s3u3i4",
  "lpac_path": "/opt/euiccbridge/bin/lpac",
  "lpac_apdu": "qmi",
  "lpac_http": "curl",
  "lpac_qmi_device": "/dev/cdc-wdm0",
  "lpac_at_device": "/dev/ttyUSB2",
  "sms_allowed_numbers": ["10086"],
  "sms_daily_limit": 5,
  "sms_storage": "ME",
  "sms_db_path": "/var/lib/euiccbridge/sms.db",
  "sms_poll_interval_seconds": 30,
  "auto_process_notifications": true,
  "lpac_commands": {
    "info": ["chip", "info"],
    "list": ["profile", "list"],
    "download": ["profile", "download", "-a", "{activation_code}"],
    "enable": ["profile", "enable", "{iccid}"],
    "disable": ["profile", "disable", "{iccid}"],
    "delete": ["profile", "delete", "{iccid}"],
    "notifications": ["notification", "process"]
  }
}
```

The `lpac_commands` section exists because `lpac` command flags can vary by
build/distribution. Calibrate it on your target host with `lpac --help` and
`lpac profile --help` before using write operations.

## Docker Deployment

For running on a host that also does other things (e.g. an OpenWrt router
you don't want to touch the base system of) -- `Dockerfile` + `compose.yaml`
in the repo root build a container with `python3`, `qmicli`, `socat`, and
`lpac` (fetched from its official GitHub release at build time, not
vendored). The container only gets the AT/QMI character devices passed
through (`--device`, no `--network host`, no `--privileged`); it does not
manage the actual cellular data connection -- that stays with the host's own
network stack (e.g. OpenWrt's `netifd`/`ModemManager` managing `wwan0`), same
separation of concerns as running euiccbridge directly on a VM.

Deployment needs exactly two manual edits -- one file for secrets/settings,
one script run for hardware -- everything else is generated or
auto-discovered:

```bash
cp .env.example .env                # fill in SMS allowlist / SMTP settings
./scripts/detect-devices.sh         # fills in devices: in compose.yaml from attached hardware
docker compose build
docker compose up -d
docker compose exec euiccbridge python3 -m euiccbridge --config /opt/euiccbridge/config.json doctor --json
```

`.env` is the single source of truth for settings (SMS allowlist/daily
limit, SMTP credentials for email notification, sync/notify intervals) --
`scripts/generate-config.py` writes `config.json`/`notify.config.json`/
`email_notify.config.json` under `/opt/euiccbridge` inside the container
from it on every start. Leave `EUICCBRIDGE_SMTP_HOST` empty to skip email
notification entirely (`sms sync` still runs). Don't hand-edit those
generated JSON files -- edit `.env` and restart
(`docker compose up -d --force-recreate` picks up `.env` changes; a plain
restart of an already-running container does not re-read it).

`scripts/detect-devices.sh` regenerates the `devices:` block in
`compose.yaml` (between marker comments) from whatever `/dev/ttyUSB*`/
`/dev/cdc-wdm*` character devices are currently attached -- re-run it (then
`docker compose up -d --force-recreate`) whenever a physical modem is
plugged in or removed. This is the one step that can't be generated away:
Docker needs a device's character devices passed through before the
container can see it at all, so there's an unavoidable chicken-and-egg
step here (attach hardware -> tell Docker about it -> only then can
anything inside the container discover it).

Once a device's character devices are passed through, everything else is
automatic: the entrypoint (`scripts/docker-entrypoint.sh`, bind-mounted so
editing it and restarting the container is enough -- no rebuild needed)
auto-discovers every device `device list` can see and loops `sms sync`
(`EUICCBRIDGE_SYNC_INTERVAL`, default 30s) and, if SMTP is configured,
notify per device -- the in-container equivalent of the host
`euiccbridge-sms-sync[@.]timer` systemd units, useful on hosts with no
systemd. Set `EUICCBRIDGE_DEVICES` (space-separated labels, in `.env`)
only to override auto-discovery, e.g. to run a subset. One-off commands go
through `docker compose exec` as shown above, not through the entrypoint.

## Troubleshooting

See `docs/TROUBLESHOOTING.md` for real-hardware failure modes and fixes
(corrupted AT-port device nodes, QMI raw-ip mode, DNS injection affecting
specific domains on some networks).

## License

MIT — see `LICENSE`.