Skip to content

Password-protected Consumer channel is refused by the Channels API, and on_channel_status drops the error field that says so #21

Description

@rjonesbsink

A password-protected Zello Consumer channel cannot be used by the Channels API at all — and the proxy discards the field where Zello says so. The result is a dispatch console that authenticates cleanly, reports the channel offline with no reason given, and silently drops every transmission.

Zello diagnosed it correctly on the very first connect, in the same frame the proxy was already parsing.

What Zello actually sends

on_channel_status, on a password-protected channel:

{"command":"on_channel_status","channel":"TicketsCad-CleveOps","status":"offline",
 "users_online":0,"images_supported":false,"texting_supported":false,
 "locations_supported":false,
 "error":"invalid password","error_type":"configuration"}

Same channel, same account, same key, after removing the channel password:

{"command":"on_channel_status","channel":"TicketsCad-CleveOps","status":"online",
 "users_online":3,"images_supported":true,"texting_supported":true,
 "locations_supported":true,"full_duplex":false}

Note it is not only audio: images_supported, texting_supported and locations_supported are all false while the channel is refused, because the session is never attached to the channel. They flip to true the moment the password is gone.

The bug

proxy/ZelloUpstream.php, the on_channel_status handler, reads three fields and drops the two that explain failure:

$ch = (string) ($data['channel'] ?? '?');
$st = (string) ($data['status'] ?? '?');
\plog("[Upstream] Channel status: {$ch} - {$st}");
($this->onStatus)('channel_status', "Channel '{$ch}' is {$st}");
// ...
if (isset($data['images_supported'])) { ... }

error and error_type are never looked at. So the operator gets Channel 'X' is offline — true, unactionable, and indistinguishable from an empty channel, a bad key, a missing membership, or a wrong channel name.

Downstream the failure is silent in the worst way: logon succeeds, the widget shows connected, PTT lights up and the timer runs, start_stream goes out, and Zello simply never answers it. Frames pile up as Queued N frames (pending, no stream_id yet) and are discarded at PTT release. Nothing anywhere says why. start_stream FAILED never fires either, because Zello does not reject the command — it ignores it.

Fix

Surface what Zello already told us:

$err     = trim((string) ($data['error'] ?? ''));
$errType = trim((string) ($data['error_type'] ?? ''));
$suffix  = $err !== '' ? '' . $err . ($errType !== '' ? " ({$errType})" : '') : '';

\plog("[Upstream] Channel status: {$ch} - {$st}{$suffix}");
($this->onStatus)('channel_status', "Channel '{$ch}' is {$st}{$suffix}");

Running locally. The widget now reads Channel 'TicketsCad-CleveOps' is offline — invalid password (configuration), which points straight at the cause. Happy to send it as a PR.

The documentation half, which is what actually misled me

docs/ZELLO-SETUP-GUIDE.md:125 is the only mention of channel passwords in the repo:

Password / private vs. public. A private or passworded channel only admits users who have been approved or have the password. Important: you do not type a channel password into TicketsCAD. Instead, the console's Zello account (dispatch-console) must itself have joined and been admitted to the channel. If the console account isn't an admitted member, the proxy will log in to Zello fine but see an empty channel.

It predicts the symptom exactly — "log in to Zello fine but see an empty channel" is precisely success: true followed by status: offline, users_online: 0.

But the remedy does not work. The console account (dispatch-cad) was the channel's owner. You cannot be more admitted than that, and Zello still returned invalid password. Being an admitted member does not satisfy the Channels API on a passworded channel — the API session itself is refused, and the guide correctly states there is nowhere in TicketsCAD to put a channel password.

So the guidance sends you to check membership, membership is already correct, and the actual answer — remove the password from the channel — appears nowhere. I read that paragraph during the investigation, confirmed ownership, concluded passwords were not my problem, and moved on. It is the specific sentence that steered me wrong.

It also interacts badly with the partial fix from #6

In #6 you said:

Fixed in 18b37a2. The steps now follow the real console flow (Keys → Add Key → New Key Details) instead of the older "channel admin → Channels API" wording…

That shipped in v4.2.0 and the steps are correct now. But the framing paragraph above them was not updated. ZELLO-SETUP-GUIDE.md:173 is byte-identical to the pre-v4.2.0 version and still asserts:

that key pair is generated inside one specific channel's admin settings, and it authorizes only that one channel … The Issuer + Private Key are per channel.

The shipped guide therefore contradicts itself: the prose says keys are per-channel and minted from channel admin, and the numbered steps twelve lines later send you to an account-level Keys page with no channel field anywhere — which matches the real console, and matches Zello's own support article for acquiring a token, which states the flow involves no channel selection.

The troubleshooting entry at line 499 is built on the same stale premise:

"Channel offline" right after a successful login

Cause: the Issuer / Private Key don't authorize that channel.

That is the exact symptom of a passworded channel, attributed to the wrong cause. Following it, I regenerated the key twice, deleted and re-minted it, and rebuilt the account — none of which could have helped, because the key was valid every time.

Suggested doc changes

  1. Correct :125 — on a passworded Consumer channel the Channels API is refused outright, even for the channel owner. The fix is to remove the channel password, not to check membership.
  2. Add "channel has a password" as the first cause under the "Channel offline" troubleshooting entry, with error: "invalid password" as the identifying signature.
  3. Finish 18b37a2 — retire the "per channel / channel admin settings" premise at :173 so it stops contradicting the corrected steps.
  4. settings.php:4700 tells the reader to look for Channel status: NAME - online and explains Authentication failed, but says nothing about what an offline channel means. That is the state a stuck install is actually in.

Should TicketsCAD have a channel-password field? No — and that is the problem

Worth settling, because it is the obvious first instinct and it is a dead end. The Channels API spec lists the complete logon field set:

command, seq, auth_token, refresh_token, username, password,
channels, listen_only, version, platform_type, platform_name,
language, features

There is no channel-password field. password is the account password ("Required if username provided"). So a Channel Password box in Settings would have nowhere to send its value. The guide is right that you do not type one into TicketsCAD — there is no such thing to type. Only its remedy ("be an admitted member") is wrong.

That makes this a hard constraint rather than a missing feature: on Consumer, a channel usable by TicketsCAD must have no channel password. Nothing in the app can work around it.

Why this matters more for this project than it might look

TicketsCAD is open source and aimed squarely at agencies without budget, and free Zello Consumer is the obvious radio bridge for exactly that group — a paid Work network is the thing they do not have. So the tier most likely to be used here is the tier with this constraint.

The operational consequence deserves stating in the guide, because it is a real trade-off rather than a formality: the only way to make a Consumer channel work with TicketsCAD is to remove its password, which removes the access control the operator likely put there on purpose. For a dispatch channel carrying incident traffic, "anyone who finds the channel name can join and listen" is a different security posture from "you need the password," and an agency should be choosing that knowingly rather than discovering it as a side effect of getting PTT to work.

One thing I have not tested and am flagging rather than asserting: Zello Consumer channels can be restricted by approval / private membership as an alternative to a password. If approval-based access is honoured by the Channels API — plausible, since the refusal here is specifically invalid password and approval is not password-based — then that would be the right recommendation for anyone who needs a closed channel: private-by-approval, no password, console account admitted. Worth someone verifying before it goes in the guide; I did not want to swap one untested instruction for another.

Reproduction — and a request

On a free Zello Consumer account, which is the tier #6 was about:

  1. Create a channel you own and set a channel password on it.
  2. Point zello_dispatch_channel at it with a valid Issuer + Private Key.
  3. Start the proxy. Logon succeeds; channel reports offline.
  4. Press PTT. start_stream is sent, no stream_id ever arrives, frames are discarded at release. No error surfaces anywhere.
  5. Remove the channel password, restart the proxy — channel goes online and PTT works immediately.

I would value someone else reproducing step 1–4 on a free Consumer account before you act on this. My conclusion rests on one account and one channel, and while the before/after frames are unambiguous on this install, "passworded Consumer channel is refused by the Channels API" is a claim about Zello's behaviour rather than about this codebase, and it deserves a second data point.

I cannot speak to Zello Work at all. I have no Work network to test against. Work uses a different auth path — getAuthToken() returns an empty string for service=work and relies on the logon username/password — and Work channels have roles and per-channel permissions that do not exist on Consumer. It is entirely possible Work behaves differently, or that a Work network token spans passworded channels without complaint. Please do not let me imply otherwise; the doc wording should probably scope the warning to Consumer unless someone can confirm Work.

The code fix stands on its own regardless of tier: whatever error Zello sends on on_channel_status, showing it to the operator is strictly better than discarding it.

Environment

v4.2.2, Windows 11 / IIS, PHP 8.4.22, MySQL 8.0. Zello Consumer (wss://zello.io/ws), free account, self-minted production token (Issuer + Private Key, RS256). Not environment-specific — it is a dropped field in a JSON handler plus documentation.

Footnote on how long this took

For calibration on the cost of the dropped field: authentication succeeded on every attempt throughout, so every signal pointed at the credentials. Over the session this produced two full key regenerations, one key deleted and re-minted, a rebuilt Zello account and channel, an audit of channel ownership and talk permissions, and a detour into whether developers.zello.com binds keys to channels at all. The answer was in the first on_channel_status frame of the very first connect, and the proxy had been receiving and discarding it every reconnect since.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workinghelp wantedExtra attention is needed

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions