Part 11 of Trunking Engine, a 12-part deep dive into the “brain” of GopherTrunk. This is a current-work post — issue #711 landed the machinery it describes. The question is deceptively simple: when a call you’ve tuned turns out to be encrypted, and you can’t decrypt it, should you keep a scarce radio parked on silence?
TL;DR: Encrypted calls waste voice SDRs. GopherTrunk gives each system a policy:
EncryptedFollow(default, legacy — hold the tuner for the whole encrypted call),EncryptedMetadata(follow briefly to grab talker alias, source RID, and encryption sync, then release the voice SDRmetadata_follow_msafter the call is known encrypted — default 1.5 s), orEncryptedIgnore(never tie up a tuner on an encrypted call). A call whoseKeyIDthe operator has a configured key for is exempt and always followed. “Encrypted” means a P25 ALGID other than clear —algorithmClear = 0x80.
Key takeaways
- The motivation is tuner starvation: voice SDRs are scarce, and a radio parked on an undecodable encrypted call is a radio not following a clear one.
- Three modes, chosen per system, so you can run
metadataon one system andfolloworignoreon another. The zero value isfollow, so old configs are unchanged. - Metadata mode trades a brief hold for identity: it captures the alias / RID / enc-sync, then releases — and releases early the moment the alias completes.
- The configured-key exemption always wins: if you hold a key for the call’s
KeyID, it’s followed no matter the mode, because you intend to decode it.
Cheat sheet
| Mode / constant | Where it lives | Behaviour |
|---|---|---|
EncryptedFollow (0) |
encryptedmode.go |
hold the voice SDR for the full call — legacy default |
EncryptedMetadata |
encryptedmode.go |
follow briefly, release after metadata_follow_ms |
EncryptedIgnore |
encryptedmode.go |
never allocate / drop the tuner the moment enc is known |
algorithmClear = 0x80 |
engine.go |
the P25 ALGID a clear call advertises; anything else is encrypted |
keyConfigured(system, keyID) |
engine.go |
true when the operator holds a key → always follow |
applyEncryptedPolicy(...) |
engine.go |
enforces the policy once a call is known encrypted |
In this post
- Why encrypted calls are a resource problem, not just a listening one.
- The three modes and how a per-system policy is configured.
- When the engine learns a call is encrypted — up front vs mid-call.
- The metadata window and the key exemption — and how they shaped the Go.
The problem: a radio parked on silence
Voice SDRs are the scarcest resource in the whole system. Most rigs have one or two, and Part 5 was entirely about rationing them when more talkgroups are active than radios to follow. An encrypted call you can’t decrypt is the worst possible use of one: the tuner is bound, the recorder is running, and the output is noise. On a busy encrypted system — a lot of public-safety traffic is encrypted now — the legacy behaviour of following every call could leave your only voice SDR camped on undecodable audio while clear calls you can hear go unrecorded. That is tuner starvation, and issue #711 exists to fix it.
The catch is that an encrypted call isn’t worthless. Even when the audio is opaque, the traffic channel still carries metadata in the clear — the talker alias (the radio’s display name), the source RID, and the encryption sync (ALGID / KID / message indicator). That’s real intelligence for the affiliation roster and key-discovery tooling. So the design isn’t binary; it’s a spectrum from “keep everything” to “waste nothing,” with a middle mode that grabs the metadata and then lets the tuner go.
Three modes, per system
The policy is a small enum with a wire form for config, REST, and the TUI:
// internal/trunking/encryptedmode.go (shape)
type EncryptedMode uint8
const (
EncryptedFollow EncryptedMode = iota // hold the tuner, full call (legacy default)
EncryptedMetadata // follow briefly, then release
EncryptedIgnore // never tie up a tuner
)
// Empty or unknown config maps to follow, so a typo never silently
// stops following calls.
func ParseEncryptedMode(s string) EncryptedMode { /* "metadata"|"ignore"|else follow */ }
Two things about the defaults are deliberate. EncryptedFollow is the zero
value, so a system absent from the config map behaves exactly as GopherTrunk
always did — pre-existing configs see no change. And ParseEncryptedMode maps
unknown strings to follow too, so a typo in encrypted_calls can never
silently stop following calls — the failure mode is “kept recording,” never “went
dark.” Modes are per system (trunking.systems[].encrypted_calls), read-only after
NewEngine, so an operator can run metadata on one system and follow or
ignore on another with no locking on the hot path.
When the engine learns a call is encrypted
Encryption is discovered at two different moments, and the engine handles both.
Up front, a P25 Phase 2 grant carries the encryption flag, so HandleGrant
can drop an already-encrypted grant before allocating a tuner — but only in
ignore mode, and only when the operator has no keys for the system:
// internal/trunking/engine.go (shape) — inside HandleGrant
if g.Encrypted && !g.Emergency && e.encModeFor(g.System) == EncryptedIgnore &&
!e.keyConfigured(g.System, g.KeyID) && !e.systemHasKeys(g.System) {
return // never tie up a voice SDR on this grant
}
Emergency grants bypass the policy entirely, following the existing lockout/scan
precedent. And a system the operator has keys for is left to the in-call
handlers, which know the actual KeyID and can exempt a decryptable call — dropping
it here would discard a call the operator wants to capture.
Mid-call is the P25 Phase 1 case, where the grant looks clear and encryption
only surfaces once the traffic channel is up. The composer publishes a
KindCallEncryption (from an in-call Encryption Sync) or a KindCallSourceUpdate
carrying the discovered ALGID/KID, and both handlers funnel into one enforcement
point after enriching and republishing the event for the live view:
// internal/trunking/engine.go (shape)
e.applyEncryptedPolicy(c.DeviceSerial, g, c.AlgorithmID != algorithmClear)
That c.AlgorithmID != algorithmClear is the whole definition of “encrypted”:
algorithmClear is 0x80, the ALGID a clear P25 call advertises. Anything else is
an encryption algorithm. The constant is kept local to the package to avoid a
radio-package import, mirroring p25.AlgorithmClear.
The metadata window and the key exemption
applyEncryptedPolicy is the single place the decision is made once a call is
known encrypted, and it reads top to bottom as the priority order:
// internal/trunking/engine.go (shape)
func (e *Engine) applyEncryptedPolicy(serial string, g Grant, encrypted bool) {
if !encrypted {
return
}
if e.keyConfigured(g.System, g.KeyID) {
e.pool.DisarmEncryptedRelease(serial) // decryptable → always follow
return
}
switch e.encModeFor(g.System) {
case EncryptedIgnore:
e.endCall(ac, EndReasonEncrypted) // release the tuner now
case EncryptedMetadata:
e.pool.ArmEncryptedRelease(serial, e.now().Add(e.encMetadataFollowFor(g.System)))
}
// EncryptedFollow: do nothing — keep holding the tuner.
}
The configured-key exemption is checked first and unconditionally: if the
operator supplied a key whose ID matches the call’s KeyID, the call is
decryptable — the operator intends to capture and decode it — so any pending
metadata release is cancelled and the call is followed to the end, whatever the
mode says. That’s keyConfigured consulting the per-system configuredKeys set
built at startup.
Metadata mode doesn’t end the call; it arms a release. ArmEncryptedRelease
stamps a deadline of now + metadata_follow_ms (default 1.5 s — long enough for a
Phase 2 talker-alias reassembly plus a couple of MAC PDU repeats, short enough to
free the tuner fast). The watchdog
tick reaps any call whose window has elapsed, ending it with EndReasonEncrypted.
There’s an early-out too: handleTalkerAlias releases an armed call the instant
its alias fully reassembles — the reason we held the tuner is already satisfied, so
there’s no point waiting out the rest of the window.
How that principle shaped the Go code
The design principle is separate discovery from policy. Encryption can be
learned from three different places — a grant flag, an in-call encryption sync, a
source update — but every one of them reduces to a single boolean handed to one
applyEncryptedPolicy. The handlers don’t each re-implement “should I drop this?”;
they enrich, republish for the live view, and call the one enforcement point. And
enforcement itself is lazy: metadata mode doesn’t spin a timer per call, it
stamps a deadline the watchdog already sweeps once a tick, so the encrypted-release
path costs nothing on the hot loop. The single-writer rule holds throughout —
arming, disarming, and reaping all happen on the engine’s one goroutine, so there’s
no race between “arm a release” and “the alias just completed.”
Where this goes next
Part 12 closes the series on the machinery this post leaned on: control-channel hunting and backoff, the 500 ms watchdog that reaps both the encrypted-release deadlines here and ordinary silent calls, and how the whole engine — encrypted-mode policy included — is tested by publishing synthetic grants to a fake bus with no radio in sight. For the crypto background, see the encryption reference.
FAQ
What does “encrypted” mean to the engine?
A P25 call advertises an Algorithm ID; the clear (unencrypted) value is
algorithmClear = 0x80. Any other ALGID means the call is encrypted. DMR and other
protocols surface an equivalent encrypted flag on the grant.
What’s the difference between metadata mode and ignore mode?
ignore never ties up a voice SDR on an encrypted call — it drops the grant up
front when encryption is known, or releases the tuner the instant it’s discovered
mid-call. metadata follows briefly first, long enough to capture the talker
alias, source RID, and encryption sync, then releases after the follow window
(default 1.5 s).
I have the key for a system — will it still get dropped?
No. The configured-key exemption is checked before the mode: a call whose KeyID
matches a key you supplied is always followed to the end, regardless of whether the
system’s mode is metadata or ignore, because you intend to decode it.
Will enabling this change my existing setup?
No. EncryptedFollow is the default and the zero value, and an unknown config
string also maps to follow. A system you don’t configure for encrypted-call
handling behaves exactly as before, holding the tuner for the full call.
Series navigation
Part 11 of 12 · ← Part 10: Sites, Topology & Roaming · Next → Part 12: CC Hunting, the Watchdog & Testing