1xBTS 1xBTS

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_addr in config/msc.json set to 0.0.0.0:17017 so the mbuni container reaches the MSC over the docker bridge.
  • grpc_listen_addr in config/management.json set to 0.0.0.0:17016 so the fou-nat container 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

FieldValue
Name1xBTS MMS
APNinternet (any non-empty string)
MMSChttp://mmsc.local.1xbts.org/
MMS proxy(empty)
MMS port80
APN typedefault,mms
APN protocolIPv4
MMS protocolIPv4

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 text query parameter (rather than the Kannel-spec hex data field) and never sets coding=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:

SettingValueEffect
queue-run-interval10sGlobal relay queue scanner
mm1-queue-run-interval10sMM1 delivery queue scanner (WAP Push dispatch)
send-attempt-back-off10sMinimum gap between retries for the same item
maximum-send-attempts5Overall retry cap
mm1-maximum-notify-attempts3M-Notification.ind retry cap
default-message-expiry86400sBody expiry in the relay queue
content-adaptationfalseSkip 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 accepted and 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