VectorCore CBC receives CAP 1.2 alerts from its Cell Broadcast Entity (CBE) via XEP-0127/XMPP, validates and deduplicates them, and exposes the current alert state through a small operator API.
The radio-facing encoder emits LTE SBcAP APER (TS 29.168) using a hand-written,
pure-Go codec (internal/sbcap) - no CGO, no external ASN.1/PER library -
including PLMN-, TA-, and cell-wide Warning-Area-Lists.
CBE -- CAP 1.2 / XEP-0127 over XMPP --> CBC ingest
|
validation + deduplication
|
Publisher interface
|
SBcAP (MME)
The CBE is configured as cbe, not merely as an arbitrary XMPP broker. The
client implements the documented receive-only flow: stream open, optional
STARTTLS, SASL PLAIN, stream restart, resource binding, and CAP <message>
reception.
CAP identifiers are the idempotency key. Alert and Update replace the
stored state for their identifier; references on updates are marked
superseded. The CBE may not forward Cancel messages, but the domain service
handles one defensively if a different CBE supplies it.
Alert state and lifecycle audit records are stored in SQLite. On startup the
CBC expires alerts whose CAP expires value has elapsed, then restores the
remaining state before reconnecting to the CBE. Operator endpoints require a
bearer token; health and readiness probes remain unauthenticated.
CAP messages are prepared into durable CBS plans before any RAN delivery is
attempted. A plan contains a TS 23.041 message identifier, serial number (GS,
message code, update number), DCS, page parameter, and fixed 82-octet pages.
The mapper uses GSM 7-bit when possible and falls back to UCS-2. CAP geocodes
named cell, cgi, ecgi, nci, or nr-cgi produce cell-wide targeting;
tac, tai, or tracking_area produce tracking-area-wide targeting.
Untargeted CAP alerts are rejected unless cbs.allow_plmn_wide is explicitly
enabled. CAP <area> blocks also support multiple <polygon> elements and
CAP 1.2's <circle> ("lat,lon radius", km) — see "Cell Inventory" below —
all unioned with any <geocode>-derived cells when targeting a CBS message.
Mixing cell-scoped and tracking-area-scoped targets in one alert is rejected:
TS 29.168's Write-Replace Warning Request is scoped to either a Cell List or
a Tracking-Area List, never both, at the protocol level.
TS 23.041 determines the CBS message mapping, serial-number/message-identifier
rules, and geographic scope. TS 29.168 defines the CBC/MME SBcAP transport.
Implementing its ASN.1 APER wire format is intentionally deferred: the
radio-facing publisher must have conformance vectors and MME peer integration
tests before it is enabled for live broadcast delivery. (The underlying spec
documents themselves are archived under docs/specs/ for anyone reading the
Go code alongside them: TS 23.041, TS 29.168, and TS 23.003 for cell-identity
encoding.)
cp config/cbc.yaml.example config/cbc.yaml
make clean
make ./bin/cbc -c config/cbc.yamlThe operator API (GET /v1/alerts, GET /v1/alerts/{identifier}, GET /v1/audit, GET /healthz, GET /readyz, GET /metrics) has no
authentication for now. Alerts and their lifecycle audit events persist in
SQLite. GET /v1/alerts/{identifier}/cbs returns the prepared TS 23.041 CBS
plan. When sbcap.enabled is set, the daemon maintains SCTP associations to
configured MME peers.
A web UI is served at /ui/ (built React app, embedded into the binary via
go:embed - see "Web UI" below), showing the dashboard, alert list/detail,
and cell inventory.
When cell_inventory.enabled is set, the CBC stores an LTE cell/TAC/eNB/MME
inventory in its existing SQLite database (cell_inventory.max_import_size_bytes,
default 10 MiB, and cell_inventory.default_import_mode are also
configurable). It exists to map a CAP alert polygon/circle to the E-UTRAN
cells, TAIs, and MME peers a warning must reach.
Cells can be bulk imported/exported as CSV, or created/deleted one at a time
via the API/UI (POST/DELETE /v1/cell-inventory/cells; delete is blocked
with 409 rather than cascading if the cell still has Geo Code mappings —
remove those first). The canonical CSV column order (also what the export
endpoint emits) is:
mcc,mnc,mnc_length,eci,enb_id,local_cell_id,tac,cell_name,mme_name,latitude,longitude,nominal_radius_m,azimuth_deg,beamwidth_deg,geometry_quality,source,source_record_id,source_version,active,coverage_geojsonHeaders are matched case-insensitively, all 20 must be present, and unknown
headers are rejected outright. eci must satisfy the macro eNB identity model
from TS 23.003: eci == (enb_id << 8) | local_cell_id. geometry_quality is
one of engineered_polygon, propagation_model, sector_estimate,
point_radius, site_point, unknown — the first two require a
coverage_geojson value. coverage_geojson accepts GeoJSON Polygon/
MultiPolygon only, with coordinates in [longitude, latitude] order (the
opposite of the latitude/longitude CSV columns).
Import modes are validate-only (parse/validate only, nothing applied),
merge (upsert; rows absent from the file are left untouched), and replace
(rows absent from the file are marked active=false, never physically
deleted). Both merge and replace are all-or-nothing: if any row is
invalid, nothing is applied.
POST /v1/cell-inventory/selection-preview takes a PLMN and a GeoJSON
Polygon/MultiPolygon area and returns the cells that intersect it, grouped
by mmeName into TAIs and ECIs. The only policy today is
conservative-intersection: a cell is selected when its stored coverage
polygon intersects the area, or — only when it has no coverage geometry at
all — when its center point lies inside the area. This endpoint (and the
whole cell-inventory feature) is preview/planning only: nothing here
transmits SBcAP or touches live alert delivery.
A Geo Codes registry (/v1/geocode-registry) lets an operator define
arbitrary CAP <geocode valueName="..."> types — any scheme string is
accepted, not just a fixed set. SAME and UGC (the codes NWS/WEA alerts
commonly carry) are the usual starting point; a registry entry is just
(type, code, description), e.g.:
| Type | Code | Description |
|---|---|---|
| SAME | 001101 | Autauga County, AL |
| UGC | ALZ057 | Autauga, AL |
Each registry entry is then mapped to one or more of the operator's own cells
(/v1/geocodes, the "Cell Mappings" table) — a hand-curated membership
mapping, not a boundary polygon, so an inbound alert carrying only a
<geocode> (no polygon) can still be targeted to real cells. The mapping CSV
format is:
mcc,mnc,mnc_length,eci,code_type,code311,435,3,1048577,SAME,001101311,435,3,1048577,UGC,ALZ057POST /v1/geocodes/resolve ({"codeType": "SAME", "code": "001101"}) tests
what a given (type, code) resolves to — the same lookup live alert
targeting uses via namedGeocodeCells. An unmatched geocode type/code
contributes zero cells rather than erroring, so one unrecognized <geocode>
in an alert never blocks the rest of that alert's targeting. The web UI's
Geo Codes page covers both the registry and the cell-mapping table.
The Cell Inventory page uses a shared Leaflet + leaflet-draw map popup
in two places:
- Add Cell → "Draw Coverage Area" — draw a polygon, rectangle, or circle
for that cell's
coverage_geojson. Omnidirectional antennas should use a circle rather than leaving coverage empty, since the spatial matcher only falls back to radius-aware behavior when real coverage geometry is present (a center-point-only cell just matches on its point). Clicking anywhere on the map outside the draw tools also fills in that cell's Latitude/Longitude. - Selection Preview → "Draw Area" — draw the CAP-alert-style area (polygon/rectangle/circle) to preview which cells/TAIs/MMEs it would select, without sending anything.
Drawn circles are approximated as polygons client-side, the same way CAP
<circle> alerts are approximated server-side — both are plain
degree-per-km math, not geodesic-accurate, consistent with the rest of the
inventory's precision level.