Skip to content

Docker DMR bridge: HTTP control never starts; CAD sends token hash not plaintext #10

Description

@kmk1971

Summary

The Docker DMR bridge (services/dvswitch/docker) cannot work as shipped. Two
defects block it completely; three smaller ones affect the UI. With workarounds
applied I have the bridge fully working — HBP login, AMBE vocoder, live receive
and browser push-to-talk — so everything below is reproducible on a real setup.

Reported after building this from scratch against a private HBLink3 master.

Environment

  • TicketsCAD v4.1.2 (272513b), Docker Compose build pack under Coolify, Ubuntu 24.04
  • Bridge from services/dvswitch/docker, same host as the CAD, attached to the CAD's Docker network
  • Master: HBLink3 (HBLink-org/hblink3) on Debian 12, single MASTER-1, REPEAT: True, UDP 54000
  • Second peer for audio testing: DroidStar with ESSID 01

1. BLOCKER — the HTTP control server can never start on Docker

services/dvswitch/hbp_client.py:1468

if bearer and piper_bin and piper_voice:
    worker = threading.Thread(target=client.run, name="hbp-loop", daemon=True)
    worker.start()
    http_server = serve_http(...)

piper_bin / piper_voice come from DMR_PIPER_BIN / DMR_PIPER_VOICE, which
default to "". Those two variables appear only in
services/dvswitch/hbp-client.env.example (the bare-metal file). They are in
none of:

  • services/dvswitch/docker/.env.example
  • services/dvswitch/docker/docker-compose.yml
  • services/dvswitch/docker/Dockerfile
  • services/dvswitch/docker/entrypoint.sh

So on the Docker path the condition is always false, serve_http() never runs,
and port 18091 never opens. Execution falls through to client.run() inline, so
the DMR side works perfectly and nothing indicates a problem.

This fails silently. The entrypoint still prints starting hbp_client.py (HTTP control on 18091) and the HBP side logs a clean login, so the bridge looks
healthy. Symptoms are only visible from the CAD side:

  • Channel never connects
  • curl to 18091 from the host: curl: (56) Recv failure: Connection reset by peer
    (docker-proxy accepts the TCP connection, then finds nothing listening)

Workaround — compose override, no rebuild needed:

services:
  dmr-bridge:
    environment:
      DMR_PIPER_BIN: /usr/bin/true
      DMR_PIPER_VOICE: /dev/null

After this: INFO [hbp] HTTP control listening on :18091.

Suggested fix: decouple the HTTP surface from TTS. /health,
/audio-stream, /tx/audio and /tx/stream don't need Piper — only /tx/text
does. Gate on if bearer: and have /tx/text return a clear 503 ("TTS not
configured") when piper_bin/piper_voice are empty, rather than taking the
entire control surface down with it.

Related: Piper isn't installed in the Docker image at all, so /tx/text
can't work on Docker even with the gate fixed. Either ship it, or document Docker
as RX + raw-audio-TX only.


2. BLOCKER — the CAD sends the token hash to the bridge; every server-side call is 401

api/dvswitch.php:284-285 mints the bearer and stores it hashed:

$token = bin2hex(random_bytes(32));
$hash  = hash('sha256', $token);   // $hash is what goes into bridge_token

The plaintext is returned once and never persisted. But every server-side caller
reads that same column and uses it directly as the Authorization: Bearer value:

  • api/dmr-tx-audio.php:77
  • api/dmr-stream.php:87
  • proxy/dmr-proxy.php:131
  • inc/channel_registry.php:450-456
  • inc/weather_radio.php:49, 219

They send the SHA-256 hash; the bridge compares against the plaintext
DMR_BEARER_TOKEN and returns 401.

Symptoms. Push-to-talk:

Transmit failed (HTTP 401): {"error":"bridge tx-audio failed",
 "detail":"{\"error\": \"unauthorized\"}","http_code":401}

And live audio never plays, because /audio-stream 401s on a retry loop:

INFO [hbp] HTTP "GET /audio-stream HTTP/1.1" 401 -
INFO [hbp] HTTP "GET /audio-stream HTTP/1.1" 401 -
...every ~3 seconds

Only channel_test_health works, because dvs_bridge_call() uses
$ch['_token_plain'] — a value the operator pastes into the Test dialog by
hand
. Nothing that runs unattended can ever authenticate.

Workaround — store the plaintext. api/dmr-ingest.php:130 already accepts
either form:

$ok = hash_equals($stored, $incoming) || hash_equals($stored, $incomingHash);

so ingest still authenticates correctly:

UPDATE dmr_channels SET bridge_token = '<plaintext token>' WHERE label = '<label>';

Immediately after this, /audio-stream returns 200 and PTT works:

INFO [hbp] HTTP "GET /audio-stream HTTP/1.1" 200 -
INFO [hbp] audio-stream subscriber 1f45a76f connected (subs=1)

Suggested fix: a background daemon cannot derive a bearer from a hash, so
hashing at rest can't work here. Encrypting it with the existing app_keys field
encryption (already used for 2FA secrets and encrypted fields) and decrypting in
dvs_bridge_call() would keep it out of the DB in the clear while remaining
usable.

Note also that rotating the token or recreating the channel writes a fresh hash,
putting the deployment straight back to 401.


3. "TX 0.5s 1 kHz tone" posts to an endpoint that doesn't exist

api/dvswitch.php:439 (channel_test_tx):

$resp = dvs_bridge_call($ch, '/tx/test', 'POST', [...]);

hbp_client.py implements /tx/audio (1036), /tx/stream (1040),
/transcript (1045) and /tx/text (1353). There is no /tx/test; the request
falls through to the 404 at line 1386.

{ "ok": false, "status": 404, "body": { "error": "not found" } }

Presumably not Docker-specific. Worth having — it would be the fastest way to
verify the TX audio path.


4. "Recent transcripts" likewise

channel_recent_calls calls GET /calls/recent. The bridge's do_GET handles
only /health, /audio-stream and /recording* before its 404 at line 900.


5. Radio widget hardcodes TG 3127

inc/navbar.php:954 and :989 print TG 3127 and Listening for TG 3127…
regardless of the channel's configured talkgroup (mine is TG 10). Also referenced
in assets/js/radio-widget.js:5 and inc/radio_ai_client.php:170. Cosmetic.


What works, with workarounds applied

Vocoder self-test:

[entrypoint] md380-emu is answering on UDP 2470
INFO [hbp.ambe] round-trip PCM peak amplitude = 43 (should be >> 0)
OK — md380-emu codec round-trip succeeds

HBP login, bridge side:

INFO [hbp] bound local UDP 62032
INFO [hbp] sent RPTL for DMR ID 4300098
INFO [hbp] HTTP control listening on :18091
INFO [hbp] sent RPTK (auth hash)
INFO [hbp] sent RPTC config (302 bytes)
INFO [hbp] RUNNING — authenticated to <master>:54000 as DMR ID 4300098

Same login, HBLink3 side:

(MASTER-1) Repeater Logging in with Radio ID: 4300098
(MASTER-1) Peer 4300098 has completed the login exchange successfully
(MASTER-1) Peer b'A61KT   ' (4300098) has sent repeater configuration

Live receive — DroidStar as a second peer (ESSID 01 → 430009801), keyed on TG 10:

{"ok": true, "state": 4, "running": true, "rx_dmrd": 445, "rx_keepalive": 498}

445 DMRD frames, ~9 seconds of speech, decoded through md380-emu.

Transmit — browser push-to-talk:

DMRD seq=62 TS2 voice      dtype=1 src=4300098→tg=10 rpt=4300098 stream=4a94e82a
DMRD seq=67 TS2 voice_sync dtype=0 src=4300098→tg=10 rpt=4300098 stream=4a94e82a

voice_sync every sixth frame with dtype=1..5 between — a correct A–F
superframe.


Smaller notes

The voice compose profile isn't needed for either direction.
api/dmr-stream.php (SSE) and api/dmr-tx-audio.php both call the bridge
directly from the app container. dmr-proxy only backs the optional /dmr-ws
WebSocket PTT path; when it isn't running the widget correctly falls back:

[radio] WS TX setup failed; falling back to batched
[radio] tx-audio OK {accepted: true, pcm_bytes: 45120, duration_ms: 2820}

Worth documenting, since docs/RADIO-DMR-DOCKER.md implies the profile is needed
for browser PTT.

The DMR settings page still describes the bare-metal path. "Bring up a new
bridge host" in settings.php documents a Debian 13 VM at 10.0.0.10,
analog-bridge/mmdvm-bridge packages, /etc/ticketscad/dvswitch-<instance>.env
and systemctl enable --now ticketscad-dvswitch@<instance>. The bearer-token
panel shown after channel creation says the same. For a Dockerised CAD this is a
dead end, and it's the first thing a new user reads. The Bridge host field
also pre-fills 10.0.0.10.

HBLink3 startup timing, for anyone else testing: it parses the radioid.net
alias files before reactor.listenUDP, so the master takes 30–45 seconds to
start listening. Checking too early looks like a failure.


I have a working HBLink3 master and a clean Coolify CAD
I've also written up the full
deployment as a guide for other Docker users if that would be useful.

— Khalaf Almazrouei

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions