MMS
1xBTS ships an optional MMS stack built around Mbuni, the open-source OMA/WAP MMSC. It’s enabled through the mms compose profile and reuses the existing SMS pipeline for the M-Notification.ind WAP Push that tells handsets a new MMS is available.
Bringing it up
docker compose --profile mms up -d --build
The mms profile adds a single container that runs Mbuni alongside an in-tree mbuni-msc-bridge shim. The bridge terminates Mbuni’s Kannel-style sendsms HTTP API on loopback and forwards each call to MscManagementService.SendSms so the MSC pages the destination handset over A1 in the same request — no SMSC retry-sweep wait.
You’ll also need:
mgmt_grpc_addrinconfig/msc.jsonset to0.0.0.0:17017so the mbuni container reaches the MSC over the docker bridge.grpc_listen_addrinconfig/management.jsonset to0.0.0.0:17016so thefou-natcontainer reaches the BSC management gRPC for the MSISDN edge-resolver (see Sender attribution below).
Handset configuration
Two values must be provisioned on the MS:
- MMSC URL:
http://mmsc.local.1xbts.org/ - MMS proxy: (leave empty)
The handset resolves mmsc.local.1xbts.org via the captive DNS at the PDSN gateway (10.55.0.1), reaches the Mbuni container through the compose network, and uses HTTP for both upload (M-Send.req POST) and retrieval (M-Retrieve.conf GET). No WAP 1.x proxy is involved.
Modern Android
Settings → Mobile networks → Access Point Names → Add APN
| Field | Value |
|---|---|
| Name | 1xBTS MMS |
| APN | internet (any non-empty string) |
| MMSC | http://mmsc.local.1xbts.org/ |
| MMS proxy | (empty) |
| MMS port | 80 |
| APN type | default,mms |
| APN protocol | IPv4 |
| MMS protocol | IPv4 |
Set this as the default carrier APN, then power-cycle data.
Older feature phones
For LG VX/Cosmos/Env, Kyocera slider/clamshell, and older Samsung clamshells, dial the model-specific service menu (commonly 2945#*#, ##78737#, or ##778#) and set the MMSC URL to http://mmsc.local.1xbts.org/ with the proxy field blank.
Carrier MMSC hostnames
Many CDMA handsets have a carrier MMSC URL baked into firmware (mms.vtext.com, mmsc.mobile.att.net, mmsc.sprintpcs.com, etc.) and can’t be reprovisioned without OMA Client Provisioning. The captive Unbound in fou-nat redirects those hostnames to the local Mbuni container at startup, so a Verizon-bundled handset that dials mms.vtext.com still lands on the cell’s MMSC. Override the list with MMSC_HIJACK_HOSTS on the fou-nat service. HTTP only — HTTPS upgrade is out of scope.
Sender attribution (X-1xBTS-MSISDN)
The MO M-Send.req that the handset POSTs to Mbuni carries no trustworthy sender. Mbuni’s default mms-client-msisdn-header is a header that handsets don’t send, and the phone-supplied X-Vzw-MDN / X-Up-Calling-Line-ID headers are placeholder values that vary by firmware. Without help the queue envelope’s F field would just read back a NAT’d bridge IP.
1xBTS solves this with a server-authoritative header injected at the edge. The captive DNS redirects the carrier MMSC hostname to the fou-nat container, where an nginx vhost owns the inbound port-80 traffic. For every request, nginx fires an internal auth_request to a loopback edge-resolver sidecar that looks up the request’s source IP in the PDSN session table (via pdsn_management.GetPdsnSessionByIp on the BSC) and returns the subscriber’s phone number in an X-1xBTS-MSISDN response header. nginx copies that header onto the request before proxying to Mbuni, which reads it via mms-client-msisdn-header = "X-1xBTS-MSISDN" and writes it into the queue envelope’s F field.
The handset can’t fake the header — nginx unconditionally overwrites whatever it sent — and the /_msisdn location is marked internal; so phones can’t query the resolver directly. PDSN miss or gRPC error degrades gracefully: the resolver returns 200 OK with an empty header and Mbuni falls back to its IP-based default. The same auth_request pattern works for any future internal HTTP service that wants MSISDN attribution.
Delivery flow
The handset POSTs an M-Send.req to the (hijacked) MMSC hostname. fou-nat nginx injects the X-1xBTS-MSISDN header and forwards to Mbuni, which accepts the body into its global relay queue and replies with an M-Send.conf carrying the assigned Message-ID. The global queue runner resolves the recipient as local and enqueues an M-Notification.ind for the MM1 queue runner to dispatch. That runner POSTs the notification through mbuni-msc-bridge — a Kannel-sendsms-compatible shim that re-frames the WSP PDU per WAP-259 §6.5 and calls MscManagementService.SendSms with teleservice 0x1004 (CATPT / WAP Push) and the framed bytes as opaque User Data. The MSC creates an SMSC submission, allocates a delivery attempt, and pages the BSC over A1 in the same request. The BSC delivers the notification as a regular SMS Data Burst on F-PCH (idle MS) or F-TCH (active packet session). The handset’s WAP Push receiver decodes the notification, opens packet data, and GETs the M-Retrieve.conf URL, which Mbuni serves from its queue before clearing the entry.
The bridge handles two Mbuni-specific quirks:
- Mbuni URL-encodes the WAP Push PDU as raw percent-escaped bytes in the
textquery parameter (rather than the Kannel-spec hexdatafield) and never setscoding=1. The bridge parses the raw query string itself so binary NULs survive, treats any UDH-bearing request as binary, and accepts the PDU from either field. - Mbuni’s UDH is raw percent-encoded bytes rather than the hex string Kannel expects. The bridge tries hex first and falls back to raw bytes when that fails.
The MSC encodes the resulting bearer-data SDU with MSG_ENCODING=0x00 (octet) and teleservice_id=0x1004, framed per WAP-259 §6.5: MSG_TYPE TOTAL_SEGMENTS SEGMENT_NUMBER SOURCE_PORT DESTINATION_PORT DATA. The BSC delivers it as a Data Burst exactly like a text SMS.
MS-to-MS messages
MO SMS and MMS for a destination that resolves to a provisioned subscriber are forwarded as MT automatically. After the MO submission is recorded the MSC re-runs HLR resolution on the destination phone number and, if a subscriber matches, queues an MT delivery on the spot through send_sms. Offline recipients are handled by the regular retry sweep — the MT submission stays in Accepted and re-pages when the handset re-registers. MO submissions whose destination has no provisioned subscriber stay in accepted for now; a follow-up will mark them failed so they don’t loiter.
MMSC tuning
docker/mbuni/mbuni.conf.template ships with intervals tuned for tight retry on a small cell:
| Setting | Value | Effect |
|---|---|---|
queue-run-interval | 10s | Global relay queue scanner |
mm1-queue-run-interval | 10s | MM1 delivery queue scanner (WAP Push dispatch) |
send-attempt-back-off | 10s | Minimum gap between retries for the same item |
maximum-send-attempts | 5 | Overall retry cap |
mm1-maximum-notify-attempts | 3 | M-Notification.ind retry cap |
default-message-expiry | 86400s | Body expiry in the relay queue |
content-adaptation | false | Skip the UAProf fetch that adds ~75s to each retrieval |
The bridge container’s /etc/hosts blackholes well-known carrier UAProf hosts (uaprof.vtext.com, uaprof.mobile.att.net, etc.) so a handset that advertises x-wap-profile doesn’t make Mbuni hang on an unreachable HTTP lookup. Override with UAPROF_BLACKHOLE_HOSTS on the mbuni service.
Testing — sending an MT MMS
Mbuni ships an mmssend CLI that injects a MIME-formatted MMS as if it had arrived over MM3/MM4. Prepare a small payload on the host (mms-test.txt):
Subject: hello from 1xBTS
Content-Type: text/plain
Hello, MMS.
Sender and recipient are passed on the command line. The recipient must be a registered subscriber phone number:
docker compose --profile mms cp mms-test.txt mbuni:/tmp/
docker compose --profile mms exec mbuni \
/opt/mbuni/bin/mmssend \
-f 12025550100/TYPE=PLMN \
-t 5551234/TYPE=PLMN \
-m /tmp/mms-test.txt \
/opt/mbuni/etc/mbuni.conf
mmssend prints Queued: <message-id> on success. The MS receives a WAP Push notification, opens packet data, fetches the M-Retrieve.conf URL, and renders the message.
Known limitations
- Modern iPhones on a Verizon carrier bundle don’t reach the MMSC. iOS prefers IMS/RCS over the legacy WAP MMS path and the carrier bundle hides the MMS APN field. The DNS hijack resolves correctly but the iPhone never opens a TCP connection to our cell’s MMSC. Lumia, Windows Phone, older Android, and the LG/Kyocera/Samsung feature phones use the legacy path and work end-to-end.
- HTTPS MMSC URLs are out of scope. The DNS hijack is HTTP-only — TLS interception with a trusted cert chain isn’t shipped.
- Outbound (off-net) MMS isn’t implemented. Anything addressed to a number with no HLR subscriber stays in
acceptedand goes nowhere; there’s no SMPP/SMTP egress. - Sustained MO upload speed is limited by R-FCH (~7 kbps net IP). A 70 KB MMS takes a minute or longer to POST; some handsets time the upload out client-side before Mbuni answers M-Send.conf, then retry. The notification still gets queued because Mbuni accepts the upload server-side.
Inspecting the queue
docker exec 1xbts-mbuni ls -la /var/spool/mbuni/global/r/au/ # accepted MO MMS waiting to be relayed
docker exec 1xbts-mbuni ls -la /var/spool/mbuni/mm1/ # outbound MM1 notifications
# Inside a queue file:
# F 14805558888/TYPE=PLMN — sender (now populated from X-1xBTS-MSISDN)
# R 15558675309/TYPE=PLMN — recipient
# N 2 — attempts so far
# X 1779803541 — next retry epoch
# D 1779890000 — expiry epoch