diff --git a/.gitignore b/.gitignore
index e2e599f4..df0d930b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -168,3 +168,5 @@ instance/*
.webassets-cache
.env
+# Claude Code per-project settings (local overrides, transcripts, etc.)
+.claude/
diff --git a/docs/mumble-callsign-cleanup.md b/docs/mumble-callsign-cleanup.md
new file mode 100644
index 00000000..e38229ac
--- /dev/null
+++ b/docs/mumble-callsign-cleanup.md
@@ -0,0 +1,100 @@
+# Callsign whitespace handling in Mumble auth
+
+This doc captures the state of callsign-whitespace hygiene across the OTS
+write path and the Mumble authenticator's lookup path, and lists the work
+that remains to make new rows immune to the same class of bug.
+
+## TL;DR
+
+- **Lookup side (landed in this branch):** `MumbleAuthenticator.resolve_identity`
+ compares against `func.trim(EUD.callsign)` on every callsign-equality
+ branch, so a stale leading or trailing space on the EUD row no longer
+ prevents a Mumble client from being matched back to its OTS user.
+- **Write side (follow-up PR needed):** the data ingestion paths in
+ `EudHandler`, `cot_parser`, and `meshtastic_controller` assign
+ `eud.callsign = contact.attrs["callsign"]` (or the equivalent) without
+ stripping, so new EUD rows can still be inserted with padded callsigns.
+ The trim at lookup time tolerates this, but the right defensive layer
+ is also to strip on the way in.
+- **Data hygiene:** existing deployments with rows already tainted by
+ padded callsigns benefit from a one-time
+ `UPDATE euds SET callsign = TRIM(callsign) WHERE callsign != TRIM(callsign)`
+ (and the equivalent on `certificates`). Not strictly required after the
+ lookup-side fix, but recommended for log readability and to keep
+ display strings clean.
+
+## The failure mode
+
+1. ATAK publishes a self-CoT message whose ``
+ attribute carries a stray trailing (or leading) space.
+2. The OTS EUD handler stores the value verbatim into `euds.callsign`.
+ No `.strip()` along the way.
+3. The user later connects to Murmur via the XV or VX voice plugin. The
+ plugin trims the callsign client-side, replaces spaces with underscores,
+ and sends `Some_User---` as the Mumble username.
+4. Murmur's pre-Ice cert check uses `nameToId(name)` (via the Ice
+ authenticator) to ask OTS "does a user named `Some_User---`
+ exist?" `resolve_identity` walks its lookup chain, gets to the
+ underscore-to-space branch (`spaced = "Some User"`), and compares
+ against `EUD.callsign == "Some User"`. The DB row is `"Some User "`
+ (with a trailing space), so the comparison fails. `resolve_identity`
+ returns `None`. `authenticate()` returns `(-1, None, None)`.
+5. Murmur reports the rejection as
+ `(-1)> Rejected connection from : Wrong
+ certificate or password for existing user`. The error message looks
+ like a TLS / cert-hash mismatch but is actually a name-lookup miss.
+
+The lookup-side fix in this branch closes step 4 by trimming the stored
+column before comparison.
+
+## Why the lookup-side fix is the right place
+
+The Mumble plugins (XV, VX) already trim the callsign on the client side
+before constructing the Mumble username, so the search keys reaching
+`resolve_identity` (the original `username`, the suffix-stripped
+`base_callsign`, and the underscore-spaced `spaced`) are all clean. The
+asymmetry is entirely on the stored-column side, which means a single
+`func.trim(EUD.callsign) == X` swap on each comparison rescues every
+affected lookup.
+
+Trim-on-read does not modify the row, so display strings rendered from
+`eud.callsign` elsewhere in OTS (mission rosters, group membership
+panels, Mumble channel-creation messages) still show whatever is stored.
+That's why the data cleanup is still recommended: the auth works without
+it, but operators see padded names in admin UIs and logs until the
+underlying row is normalized.
+
+## Write-side follow-up (separate PR)
+
+The following sites all write a raw `contact.attrs["callsign"]` (or
+similar) straight to a callsign column. A future PR should `.strip()`
+each before assignment, so new rows cannot reintroduce the bug.
+
+| File | Line(s) | Notes |
+| --- | --- | --- |
+| `opentakserver/eud_handler/EudHandler.py` | 385, 687 | `eud.callsign = self.callsign` on EUD create + update; `self.callsign` is captured at line 511 from `contact.attrs["callsign"]` without trim |
+| `opentakserver/cot_parser/cot_parser.py` | 244, 728 | mission-UID + marker writes both source `contact.attrs["callsign"]` |
+| `opentakserver/controllers/meshtastic_controller.py` | 316, 359 | writes EUD callsign from Meshtastic device long-name |
+| `opentakserver/blueprints/marti_api/mission_marti_api.py` | 862, 1040, 1987 | mission invitation and uid writes |
+| `opentakserver/blueprints/ots_api/marker_api.py` | 119 | marker name from request JSON; bleach-cleaned but not stripped |
+
+A single helper (`def _clean_callsign(s): return s.strip() if s else s`)
+applied at each of these write sites is the minimal patch. The same
+helper can be reused in the existing CoT validation path so future
+attributes don't slip through.
+
+## One-time data cleanup (deployment-level, optional)
+
+```sql
+BEGIN;
+UPDATE euds SET callsign = TRIM(callsign) WHERE callsign IS NOT NULL AND callsign != TRIM(callsign);
+UPDATE certificates SET callsign = TRIM(callsign) WHERE callsign IS NOT NULL AND callsign != TRIM(callsign);
+COMMIT;
+```
+
+Idempotent and reversible. Not required for auth to work (the lookup-side
+trim handles padded rows transparently), but recommended for display
+hygiene and to keep `EUD.callsign != TRIM(EUD.callsign)` a useful
+invariant for future linting. The same cleanup can be packaged as an
+Alembic migration once the write-side fix lands so a single upgrade
+takes care of both code and data.
diff --git a/docs/mumble-vx-calling.md b/docs/mumble-vx-calling.md
new file mode 100644
index 00000000..bbdac26b
--- /dev/null
+++ b/docs/mumble-vx-calling.md
@@ -0,0 +1,289 @@
+# Server-side support for the ATAK VX voice plugin
+
+This doc covers how OpenTAKServer integrates with Mumble (Murmur) to support
+the [ATAK VX](https://www.civtak.org/wiki/index.php/ATAK_VX_Voice) voice
+plugin, including direct calls and conference calls between users in
+different OTS groups.
+
+## Architecture at a glance
+
+```
+ATAK device ──TLS──> Murmur (mumble-server)
+ │
+ │ Ice (TCP 6502, secret-gated)
+ ▼
+ OpenTAKServer
+ ├── MumbleAuthenticator (authenticates clients)
+ ├── MumbleIceApp (channel sync + ACL helpers)
+ └── DirectionEnforcement (suppress/unmute by OTS group)
+```
+
+Every Mumble client that connects to Murmur is authenticated by OTS via the
+Murmur Ice authenticator interface, not by Murmur's local user table. OTS
+returns the user's Mumble user id, display name, and the list of Mumble
+groups they belong to (one per OTS group, plus `admin` for OTS administrators).
+
+The Ice connection is initialized by `MumbleIceDaemon` (background thread)
+and handed off to `MumbleIceApp`, which:
+
+1. Registers the authenticator with every booted virtual server.
+2. Registers a per-server `DirectionEnforcementCallback` that watches user
+ state changes for IN/OUT direction enforcement *and* records channel
+ activity for the cleanup job.
+3. Auto-creates a Mumble channel for every OTS group on startup and on
+ demand from the group_api (see `sync_channels_from_groups`).
+
+## How VX uses the integration
+
+The official ATAK VX voice plugin opens **two parallel TLS connections** per
+device — each authenticates to Murmur with the user's callsign suffixed with
+a unique UUID (`---`). OTS resolves both UUIDs back to the
+same OTS user via:
+
+1. Exact callsign match.
+2. `---` suffix stripped → callsign match.
+3. Underscore → space conversion (`EUD_Charlie_MXVX` → `EUD Charlie MXVX`).
+4. Cert CN (the EUD UID) → EUD lookup. This survives mid-session callsign
+ renames where the callsign change hasn't propagated through CoT yet.
+
+Each socket gets its own Mumble user id (base id + deterministic
+hash-of-callsign offset, modulo the per-user 1000-id range), so both sockets
+co-exist without colliding even though they belong to the same OTS user.
+
+### Direct (1:1) calls
+
+When the user initiates a direct call from VX:
+
+1. VX creates a **temporary Mumble channel** under either Root or the
+ user's current channel. This requires the `MakeTempChannel` (`0x400`)
+ permission on the parent channel.
+2. The receiving side's VX joins the temp channel by name/id (self-join via
+ coordination over OTS CoT messages — VX clients do not drag each other
+ between channels).
+3. Both clients speak in the temp. When both leave, Murmur garbage-collects
+ the temp automatically.
+
+### Conference calls
+
+Conferences are a generalization of direct calls — the temp channel can hold
+N participants, including users from different OTS groups. The ACL set on
+the temp at creation time (see below) ensures any authenticated user can
+enter once invited, regardless of which OTS group they belong to.
+
+## ACL model
+
+OTS deliberately operates a **locked-down per-channel ACL model**: each
+auto-synced channel revokes baseline access from `@all` and grants it back
+only to the channel's named group, so group channels are private to their
+members.
+
+| Channel | Group | Grant mask | apply_here / apply_sub | Notes |
+|---|---|---|---|---|
+| Root (id=0) | `@all` | `Traverse` (`0x2`) | here=1, sub=1 | Visible to everyone, propagates to subchannels |
+| Root | `auth` | `0x70e` | here=1, sub=1 | Lets any auth user speak in Root AND inherits Enter+Speak to temps |
+| Root | `admin` | `0x707ff` | here=1, sub=1 | Full Murmur admin, inherits to all subchannels |
+| Group channels (one per OTS group) | `all` | revoke `0x30e` | here=1, sub=0 | Strip baseline access from non-members; channel itself only |
+| Group channels | `` | `0x70e` | here=1, sub=0 | Members get speak + MakeTempChannel for in-group temps |
+| Group channels | `admin` | `0x707ff` | here=1, sub=1 | Full admin on the channel AND inherits to any temp beneath |
+| Group channels | `auth` | `0x30e` | here=0, sub=1 | **Sub-only grant** — temps inherit Enter/Speak/Whisper/Text without making the group channel itself public |
+| Temp channels (created by VX) | (mostly inherited) | — | — | See "How temp ACLs work" below — we don't override |
+
+**Move (`0x20`) is intentionally not granted to non-admin groups.** Standard
+users cannot drag other users between channels — only admins can. VX direct
+calls work via self-join (and via the caller's own creator-admin power on
+their own temp), not by the initiator dragging the recipient.
+
+### How temp channel ACLs work (inheritance, not per-temp setACL)
+
+We do **not** call `setACL` on every newly created temp. Murmur's own
+behavior handles per-temp permissions correctly as long as the parent
+channels are set up right:
+
+1. **Murmur auto-creates a local `admin` group** on each new temp and
+ adds the **creator's userid** to it. This gives the creator full
+ Write/Move/etc. on their own temp via the inherited `admin/0x707ff`
+ grant from Root — even if the creator is a non-admin OTS user. That's
+ how VX's "Updated ACL in channel" call (which the plugin issues
+ ~160ms after channel creation) succeeds for everyone.
+
+2. **The `@auth` apply_sub=1 grant on parent channels** propagates
+ Enter+Speak+Whisper+TextMessage down to the temp. On Root this means
+ any auth user can enter a Root-level temp; on group channels it means
+ any auth user (including someone from a different OTS group) can
+ enter the temp, enabling cross-group conferences.
+
+3. **`@all` apply_sub=1 from Root** propagates `Traverse` so the temp is
+ visible to everyone.
+
+Net effect: zero Ice calls during the VX call lifecycle. The temp's
+permissions are correct by inheritance from its parent.
+
+### How the persistent ACLs are kept correct
+
+`MumbleIceApp.sync_channels_from_groups` runs on startup and on demand
+(from `group_api.request_sync()` after add/delete). It walks Root + every
+OTS-managed channel and calls `_ensure_make_temp_channel_acl`, which
+idempotently ensures each channel has:
+
+1. `MakeTempChannel` (`0x400`) on the `` / `auth` / `admin` grants
+ so users can create call channels.
+2. `admin` set to the full `0x707ff` grant with `apply_sub=True` so admins
+ keep server-administrator perms on every OTS channel and any subchannel.
+3. An `@auth` grant of `0x30e` with `apply_here=False, apply_sub=True` on
+ group channels (added if missing). This is the sub-only grant that
+ makes temps inherit Enter+Speak.
+
+The function reads existing ACLs, modifies in place, filters out inherited
+rows, and writes back. Idempotent — runs that find nothing to change are
+silent. Manual ACL edits through Mumble's GUI are preserved (we only
+ensure the specific bits/grants above; other entries are untouched).
+
+## Channel cleanup
+
+The auto-sync only *creates* channels; it never deletes them. To prevent
+stale event channels from accumulating, `cleanup_unmanaged_mumble_channels`
+runs every 2 days (configurable via the `JOBS` entry in `defaultconfig.py`)
+and deletes root-level Mumble channels that:
+
+- Are **not** the Root channel
+- Are **not** named after an existing OTS group
+- Are **not** Murmur temp channels (Murmur GCs those itself)
+- Have **no users** currently in them
+- Have not had any user activity for `OTS_MUMBLE_CHANNEL_CLEANUP_IDLE_DAYS`
+ days (default 5)
+
+Activity is recorded in-memory by `DirectionEnforcementCallback` on every
+`userConnected` and `userStateChanged`. After a service restart, channels
+with no recorded activity fall back to the service start time — so an
+event channel needs a full idle window of running service before it
+qualifies for deletion. This avoids accidentally deleting a long-empty
+channel right after a restart, at the cost of delaying cleanup if the
+service restarts often.
+
+To create an event channel that should survive: just create it in the
+Mumble GUI and use it. As long as someone joins it within the idle window
+(or someone is in it at the moment the cleanup job runs), it will not be
+deleted.
+
+## Configuration
+
+| Key | Default | What it does |
+|---|---|---|
+| `OTS_ENABLE_MUMBLE_AUTHENTICATION` | `False` | Master switch — enables the Ice authenticator and the daemon |
+| `OTS_ICE_SECRET` | `""` | Must match Murmur's `icesecretread`/`icesecretwrite` in `/etc/mumble-server.ini` |
+| `OTS_MUMBLE_ENABLE_CONFERENCE_CALLS` | `True` | Adds the ACL grants that enable non-admin VX calls: MakeTempChannel on Root + each OTS-managed channel, @auth `apply_sub=True` sub-grant on group channels (temps inherit Enter/Speak), and full admin grant on every OTS channel. Set `False` to revert to the legacy admin-only model |
+| `OTS_MUMBLE_CHANNEL_CLEANUP_ENABLED` | `True` | Master switch for the periodic cleanup job |
+| `OTS_MUMBLE_CHANNEL_CLEANUP_IDLE_DAYS` | `5` | How long a channel must be idle before deletion |
+
+The cleanup interval (every 2 days) lives in the `JOBS` list in
+`defaultconfig.py` rather than as a separate config key, since it follows
+the same pattern as every other scheduled OTS job.
+
+## Troubleshooting
+
+### Standard user gets "Permission denied" when creating a temp channel
+
+Confirm `OTS_MUMBLE_ENABLE_CONFERENCE_CALLS` is `True` in your config and
+OTS has been restarted at least once after upgrading. On startup you
+should see lines like:
+
+```
+Updating ACL on channel_id=0 name=Root group=auth: allow 0x70e -> 0x70e, apply_sub False -> True
+Adding @auth sub-grant on channel_id=6 name=REACT: allow=0x30e, apply_sub=True
+Committed ACL update on channel_id=6 name=REACT
+```
+
+If those lines never appear on startup, OTS's Ice connection to Murmur is
+failing — check `OTS_ICE_SECRET` matches what's in `/etc/mumble-server.ini`.
+
+Once the ACLs are in place they're idempotent — subsequent startups won't
+re-log them. To verify the live ACL on the Murmur side directly:
+
+```sh
+sudo sqlite3 -header -column /var/lib/mumble-server/mumble-server.sqlite \
+ "SELECT channel_id, priority, group_name, apply_here, apply_sub, \
+ printf('0x%x', grantpriv) AS grant_hex, \
+ printf('0x%x', revokepriv) AS revoke_hex \
+ FROM acl ORDER BY channel_id, priority;"
+```
+
+Expected on each group channel: an `auth` row with `apply_here=0, apply_sub=1,
+grant_hex=0x30e`, plus `admin` row with `apply_sub=1, grant_hex=0x707ff`, plus
+`` row with `grant_hex=0x70e`.
+
+### User from group A can't join a temp channel created by group B
+
+Inheritance might not be propagating. Check the live ACL (sqlite query
+above) on the parent of the temp channel. The parent (Root or whichever
+OTS group channel) must have a `@auth` row with `apply_sub=1` and
+`grant_hex` of at least `0x30e`. If that row is missing, run an OTS
+restart — `_ensure_make_temp_channel_acl` will add it on startup.
+
+VX clients also need to coordinate the call (the signaling layer is
+plugin-internal and not visible to the server). If the ACL is correct
+but the callee still can't reach the temp, check VX-side first
+(server-side ACL is no longer the bottleneck).
+
+### Standard user can't drag another user into a temp
+
+This is **expected behavior**. `Move` is admin-only by design at the OTS
+group level. The caller doesn't need it for VX — VX moves users via
+whisper (VoiceTarget) and via each client's own self-join, not by the
+initiator dragging the recipient. The caller also has `Move` *on their
+own temp* automatically via Murmur's creator-admin group, which is enough
+for any in-call moves the plugin needs to do.
+
+### Stale event channels accumulating
+
+The cleanup job runs every 2 days. If you've just deployed and want to
+clear stale channels immediately, you can either:
+
+1. Wait for the next scheduled run (visible in the OTS scheduler API).
+2. Trigger it manually via the scheduler API:
+ ```
+ POST /api/scheduler/jobs/cleanup_unmanaged_mumble_channels/run
+ ```
+ (auth as an OTS administrator)
+
+If a channel is *not* getting deleted that you think should be, check:
+- Is its name in the OTS Group table? Channels matching an OTS group are
+ always preserved.
+- Is anyone currently in the channel?
+- Was the channel just created? After a service restart it needs a full
+ `OTS_MUMBLE_CHANNEL_CLEANUP_IDLE_DAYS` window of running service before
+ it's eligible.
+
+## Implementation references
+
+| Concern | File / function |
+|---|---|
+| Authenticator (resolves callsign-with-UUID, cert CN, etc.) | `opentakserver/mumble/mumble_authenticator.py::MumbleAuthenticator.resolve_identity` |
+| Persistent-channel ACL setup (MakeTempChannel + admin elevation + @auth sub-grant) | `opentakserver/mumble/mumble_ice_app.py::MumbleIceApp._ensure_make_temp_channel_acl` |
+| `channelCreated` callback (now just cache invalidation — no per-temp setACL) | `opentakserver/mumble/mumble_ice_app.py::DirectionEnforcementCallback.channelCreated` |
+| Direction enforcement (in-memory suppress tracking) | `opentakserver/mumble/mumble_ice_app.py::DirectionEnforcementCallback._apply_direction` |
+| Channel auto-sync from OTS groups | `opentakserver/mumble/mumble_ice_app.py::MumbleIceApp.sync_channels_from_groups` |
+| Idle channel cleanup job | `opentakserver/blueprints/scheduled_jobs.py::cleanup_unmanaged_mumble_channels` |
+| Murmur permission constants | `opentakserver/mumble/Murmur.ice` (search `Permission*`) |
+| VX direct-call protocol (sister doc) | [`vx-direct-call-protocol.md`](./vx-direct-call-protocol.md) |
+
+## Operational caveats
+
+- **OTS auto-recovers after a Murmur restart.** A 10-second watchdog timer
+ in `MumbleIceApp.check_connection()` reattaches the meta callback and
+ authenticator after a Murmur restart. There's still a brief window
+ (up to 10s) where new connections are rejected with
+ `Wrong certificate or password for existing user` until the watchdog
+ reattaches. If you need a faster recovery, restart OTS too — but you
+ no longer *have* to.
+
+- **Plain VX clients (v2.1.0) reconnect every few minutes by design.** You'll see
+ bursts of simultaneous `Connection closed` + `New connection` lines for
+ every VX client at roughly 3-5 minute intervals, even when nothing is
+ happening. This is the plugin's TLS reset cycle. Not a bug.
+
+- **Caller appears stuck in Root after End Call for up to ~60 seconds.**
+ After ending a call, VX moves the caller to Root as an intermediate
+ state, then to their original channel. The delay between the two moves
+ is client-side and varies by VX version. Nothing OTS can do about it
+ without a server-side auto-move-on-auth feature (which would mask the
+ symptom but introduce its own preference logic).
diff --git a/docs/vx-direct-call-protocol.md b/docs/vx-direct-call-protocol.md
new file mode 100644
index 00000000..25fb34e2
--- /dev/null
+++ b/docs/vx-direct-call-protocol.md
@@ -0,0 +1,228 @@
+# ATAK VX direct-call protocol — what we know
+
+Reverse-engineered from live observation of the official VX voice plugin
+(plain VX **v2.1.0**) against an OpenTAKServer + Murmur 1.3.4 deployment.
+In murmur's `slog` and TLS logs these clients identify as
+`1.0.0 (ATAK: ATAK_Vx)` — that's the Mumble protocol compatibility version
+the client advertises, NOT the VX plugin's own release version.
+Not from VX source — treat as "best-current-understanding" not gospel.
+
+## Architecture: signaling + media in two separate layers
+
+VX direct calls look superficially like Mumble channels but are really
+two independent layers stacked together, similar to SIP signaling +
+RTP media:
+
+```
+Layer 1 — CHANNEL HANDSHAKE (Mumble protocol)
+ Caller creates a temporary channel "TAK PRIVATE - "
+ Caller calls setACL on it (to lock down the call)
+ Recipient moves into the temp as a visual handshake
+ Visible: server-side via Murmur slog / Ice events
+
+Layer 2 — AUDIO (Mumble VoiceTarget / whisper)
+ Speaker configures a numbered VoiceTarget pointing at the recipient
+ Voice packets transmit with that target id — server routes only to that user
+ NOT visible server-side (UDP, no slog entries for voice routing)
+```
+
+**Critical implication:** the audio doesn't flow through the temp channel.
+In actual call captures we observed the caller stay in their original group
+channel while the recipient moved into the temp, and the call still worked.
+The temp is a visual + handshake indicator; the actual voice is whisper.
+
+## Layer 1: Channel handshake (Mumble protocol)
+
+Observed sequence when a call initiates (from `mumble-server.log`):
+
+```
+T+0.000s CALLER Added channel "TAK PRIVATE - " under Root
+T+0.160s CALLER Updated ACL in channel TAK PRIVATE -
+T+2-7s CALLEE Moved CALLEE to TAK PRIVATE -
+... audio flows via whisper (see Layer 2) ...
+T+N s CALLER Moved CALLER to Root (end-call intermediate)
+T+N+50ms CALLER Moved CALLER to original_channel (REACT/Family/etc.)
+T+N s CALLEE Moved CALLEE to original_channel
+T+N s Murmur auto-GCs the empty temp channel
+```
+
+### Naming convention
+
+Two formats observed: `TAK PRIVATE - 9a265fc4` (8 hex chars) and
+`TAK PRIVATE - 452b73ccc87f` (12 hex chars). Likely a session UUID truncated
+to 8 vs 12 chars; possibly distinguishing 1:1 vs conference. **Other VX
+clients recognize this prefix as a call channel** and apply special UI
+behavior (don't show in normal channel list).
+
+### Murmur auto-creates a creator-admin group
+
+This is the most important detail for OTS-side ACL design. When **any** user
+creates a temp via the Mumble protocol, Murmur **automatically creates a
+local `admin` group on that temp** and adds the creator's userid to it:
+
+```
+groups=[..., admin/inh=0/inheritable=1/add=[]/members=[]]
+```
+
+Combined with Root's inherited `admin/allow=0x707ff` ACL, this gives the
+creator full admin powers ON THEIR OWN TEMP, even if they aren't a real
+server admin. **That's how non-admin standard users successfully `setACL`
+on their own call channel** at T+0.160s. The `Updated ACL` line in the
+murmur log is VX configuring its own call's permissions (likely limiting
+who can enter), which requires `Write` on the temp — granted via the
+creator-admin chain.
+
+**Do not destroy this group.** Specifically: don't call `setACL` on the
+temp with `groups=[], inherit=False` — that wipes the creator-admin group
+and breaks VX's call lifecycle. Instead, **don't intervene per-temp at
+all** and instead put an `@auth` grant on parent channels with
+`apply_sub=True` so it inherits down to temps (see "OTS server-side
+strategy" below).
+
+### What ACL does VX set on its own temp?
+
+Unknown — VX issues `setACL` ~160ms after creation but it's a client-side
+Mumble protocol message; the server log only shows "Updated ACL in channel"
+with no contents. We never captured the actual ACL VX configures because
+our diagnostic `getACL` calls were timing out under load (Ice 30s).
+
+**To recover this:** run `tcpdump` on port 64738 during a call and decode
+the Mumble protocol `ACL` message in the capture. Or hook a `ServerCallback`
+implementation that does `getACL` immediately after `channelStateChanged`
+on temp channels.
+
+## Layer 2: Audio path via VoiceTarget (whisper)
+
+Murmur supports per-client "VoiceTarget" configurations — numbered slots
+(1-30) that map a target id to a list of `session ids` and/or `channel ids`.
+When the client transmits voice with a target byte set, Murmur routes the
+audio only to the targets, **not** to the client's current channel.
+
+This is the actual audio path for VX direct calls. Evidence from observed
+calls: the caller often **never moves into their own temp channel** — they
+create it, the recipient joins the temp, and audio flows the entire time
+without the caller leaving their original group channel. The only way that
+works is whisper.
+
+### Server-side permissions needed for whisper to work
+
+| Permission | Where applied | Why |
+|---|---|---|
+| `Whisper` (0x100) | Caller's source channel | Server checks sender has whisper rights in their current channel before routing voice |
+| `Speak` (0x08) | Caller's source channel | Implied — `suppress`ed users can't transmit any voice including whisper |
+| User NOT `suppress`ed | Anywhere | Server-mute blocks ALL transmission including whisper |
+| `Enter` on the temp | For recipients | Needed so they can move into the temp for the visual handshake |
+
+**The suppress flag is the most likely server-side blocker for VX calls.**
+Mumble's `DirectionEnforcement` style "listen-only" mode that uses suppress
+will silently break VX whisper-based calls for the suppressed user — they
+can hear but cannot speak in any channel. If your direction-enforcement
+needs to coexist with VX, use channel-level ACL `Speak` revocation instead
+of the suppress flag, so the user is gagged in the OTS group channel but
+free to whisper from elsewhere.
+
+## End-call behavior (caller stuck-in-Root pattern)
+
+End-call typically transitions through these states:
+
+```
+1. Caller's UI hits End Call
+2. Caller's VX clears its VoiceTarget configuration (audio stops)
+3. Caller's VX moves the caller to Root (intermediate state)
+4. ~30 seconds later: plain VX (v2.1.0) drops its TLS socket and reconnects
+5. New session lands in Root (Murmur default for fresh connections)
+6. Eventually VX moves the user to their original channel
+ (observed delays: 35ms to 60+ seconds)
+```
+
+**Gotcha:** between steps 4 and 6, the user appears stuck in Root for up
+to a minute. This is VX client-side, not a server issue. If the server
+returns `PermissionDenied` for any of the VX-issued channel ops during
+this window (e.g., because a setACL races), the Mumble client surfaces
+an error and Android-localized error strings with unfilled `%1$d`
+placeholders ("Cannot rejoin %1$d") become visible — that's a client
+bug surfacing because the server didn't provide the parameter, not a
+real permission issue.
+
+**To mask the stuck-in-Root delay server-side:** auto-move OTS-authenticated
+users to their preferred OTS group channel right after `authenticate()`
+returns. Requires a "primary group" concept or persisting last-channel
+per user. Optional; trades complexity for UX polish.
+
+## OTS server-side strategy: inheritance, not per-temp setACL
+
+We tried setACL on every temp via a `channelCreated` callback. Bad idea:
+
+- Every temp triggered `getACL` + `setACL` on a background thread
+- Plus a 1.5s-delayed diagnostic `getACL`
+- Under VX's typical call lifecycle (multiple temps, several users moving
+ channels), this overlapped with Murmur's own state broadcasts
+- Murmur stalled on lock contention → 30s+ Ice timeouts cascaded
+- User-visible: 30-60 second End-Call hangs, `%1$d` client errors
+
+**Better strategy:** put the `@auth` grant on the PARENT channels with
+`applySubs=True` so it propagates to temps via inheritance:
+
+```python
+# On Root: existing @auth grant gets apply_sub=True
+# On each OTS group channel: ADD a new @auth ACL entry with
+# applyHere=False (channel itself stays members-only)
+# applySubs=True (temps under it inherit)
+# allow=0x30e (Traverse|Enter|Speak|Whisper|TextMessage)
+```
+
+Result: zero Ice calls during call lifecycle. Murmur's defaults (creator-admin
+group + Root admin inheritance) handle the per-temp permissions. Cross-group
+conferences work because any auth user inherits Enter+Speak on temps
+regardless of which OTS group they belong to.
+
+## Permission matrix users actually need
+
+For VX direct calls to work end-to-end:
+
+| Permission | Standard user (own OTS group) | Standard user as creator of temp | Standard user as callee on someone's temp | OTS admin |
+|---|---|---|---|---|
+| `Traverse` (see channel) | ✓ via `@all` | ✓ via `@all` (Root inheritance) | ✓ | ✓ |
+| `Enter` (join channel) | ✓ via `` | ✓ via auto creator-admin | ✓ via `@auth` apply_sub from parent | ✓ via admin |
+| `Speak`, `Whisper`, `TextMessage` | ✓ via `=0x70e` | ✓ via creator-admin | ✓ via `@auth` apply_sub | ✓ via admin |
+| `MakeTempChannel` (0x400) | ✓ via `` and `@auth` on Root | ✓ via creator-admin | n/a | ✓ via admin |
+| `Move` (drag other users) | ✗ — admin-only by design | ✓ on their own temp via creator-admin (needed for VX to manage participants) | ✗ | ✓ via admin |
+| `Write` (manage channel ACL) | ✗ | ✓ on their own temp via creator-admin (needed for VX's "Updated ACL" call) | ✗ | ✓ via admin |
+| `Kick`/`Ban`/`Register` | ✗ | ✗ | ✗ | ✓ via admin |
+
+**Key insight:** `Move` and `Write` for non-admins are NOT granted at the
+OTS group level. They come for free per-temp via Murmur's automatic
+creator-admin group. So creators are "admins of their own call" without
+being server admins.
+
+## Operational quirks to design around
+
+| Quirk | What you'll see | Mitigation |
+|---|---|---|
+| Plain VX (v2.1.0) reconnect cycle | All TLS sockets drop simultaneously every few minutes, immediate reconnect | None server-side |
+| Caller doesn't enter own temp | Murmur log shows creator stays in original channel; only callee moves | Don't worry about it; audio is whispered from origin |
+| `%1$d` errors in client | Localization fallback when server returns slow response with int param | Fix the slowness (Ice load, lock contention) not the string |
+| `not allowed to Write ACL` denials | VX's own setACL failing | Your code wiped the creator-admin group. Don't pass `groups=[]` |
+| Caller stuck in Root after call | New session lands in Root; VX is slow to move | Optional: server-side auto-move-on-auth |
+| Channel `[id:0*]` notation | `*` indicates `temporary=true` | Use `Channel.temporary` field via Ice, not the log notation |
+
+## Open questions still worth answering
+
+| Question | How to find out |
+|---|---|
+| Exact ACL VX configures via "Updated ACL" | tcpdump port 64738 during call, decode protocol; or hook Murmur callback for `getACL` immediately after temp creation |
+| 8-char vs 12-char hex suffix in `TAK PRIVATE - ` | Initiate a 1:1 vs a 3-way and compare |
+| How does VX signal call setup between clients? | Decode Mumble plugin-data messages during a call; not visible in slog |
+| Does VX use Mumble TextMessage as a fallback signaling channel? | Hook `userTextMessage` callback; observe |
+| How does VX handle calls when both parties are on different Mumble servers? | Likely doesn't |
+
+## References
+
+- Murmur Ice slice: `/usr/share/slice/Murmur.ice` or the project's
+ `opentakserver/mumble/Murmur.ice`
+- Mumble protocol reference:
+- Mumble permission bit values (search `Permission*` constants in Murmur.ice):
+ Write=0x01, Traverse=0x02, Enter=0x04, Speak=0x08, MuteDeafen=0x10,
+ Move=0x20, MakeChannel=0x40, LinkChannel=0x80, Whisper=0x100,
+ TextMessage=0x200, MakeTempChannel=0x400,
+ Kick=0x10000, Ban=0x20000, Register=0x40000
diff --git a/opentakserver/blueprints/ots_api/group_api.py b/opentakserver/blueprints/ots_api/group_api.py
index e3d896c7..35c86165 100644
--- a/opentakserver/blueprints/ots_api/group_api.py
+++ b/opentakserver/blueprints/ots_api/group_api.py
@@ -18,6 +18,21 @@
group_api = Blueprint("group_api", __name__)
+def _request_mumble_channel_sync():
+ """Best-effort: ask the Mumble Ice daemon to sync channels to OTS groups.
+
+ Why: channel name == group name is required for direction enforcement, so
+ a new OTS group needs a matching Mumble channel. Silent no-op if the Ice
+ daemon isn't running.
+ """
+ try:
+ ice_app = app.extensions.get("mumble_ice_app")
+ if ice_app is not None:
+ ice_app.request_sync()
+ except Exception as e:
+ logger.warning(f"Failed to request Mumble channel sync: {e}")
+
+
@group_api.route("/api/groups")
@roles_required("administrator")
def get_groups():
@@ -318,6 +333,7 @@ def add_group():
500,
)
+ _request_mumble_channel_sync()
return jsonify({"success": True})
@@ -469,4 +485,5 @@ def delete_group():
500,
)
+ _request_mumble_channel_sync()
return jsonify({"success": True})
diff --git a/opentakserver/blueprints/scheduled_jobs.py b/opentakserver/blueprints/scheduled_jobs.py
index e88509c7..9c23aafb 100644
--- a/opentakserver/blueprints/scheduled_jobs.py
+++ b/opentakserver/blueprints/scheduled_jobs.py
@@ -398,3 +398,88 @@ def delete_old_data():
# This function is to prevent errors caused by changing get_airplanes_live_data() to get_adsb_data
def get_airplanes_live_data():
return
+
+
+def cleanup_unmanaged_mumble_channels():
+ """Delete root-level Mumble channels that aren't backed by an OTS group
+ and have been idle long enough to be safely removed.
+
+ Preserves event channels admins create through Mumble's GUI until they're
+ actually unused, so a deliberately-kept channel for an event survives this
+ job until activity stops. Channels whose name matches an OTS group are
+ always preserved (those are managed by sync_channels_from_groups).
+
+ Idle = currently empty AND last activity older than the configured
+ threshold. Activity is recorded in MumbleIceApp by the direction
+ enforcement callback on every userConnected / userStateChanged. After a
+ service restart, channels with no recorded activity fall back to the
+ service start time -- so a long-idle channel needs another full idle
+ window of running service before it qualifies for deletion.
+ """
+ with apscheduler.app.app_context():
+ if not app.config.get("OTS_MUMBLE_CHANNEL_CLEANUP_ENABLED", True):
+ return
+
+ ice_app = app.extensions.get("mumble_ice_app")
+ if ice_app is None or not ice_app.connected:
+ logger.debug("Skipping Mumble channel cleanup: Ice not connected")
+ return
+
+ idle_days = app.config.get("OTS_MUMBLE_CHANNEL_CLEANUP_IDLE_DAYS", 5)
+ threshold = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(
+ days=idle_days
+ )
+
+ managed_names = {
+ g.name for g in db.session.query(Group).all() if g.name and g.name != "__ANON__"
+ }
+
+ try:
+ booted = ice_app.meta.getBootedServers()
+ except Exception as e:
+ logger.error(f"Mumble channel cleanup: getBootedServers failed: {e}")
+ return
+
+ for server in booted:
+ try:
+ channels = server.getChannels()
+ users = server.getUsers()
+ except Exception as e:
+ logger.error(f"Mumble channel cleanup: getChannels/getUsers failed: {e}")
+ continue
+
+ occupied = {u.channel for u in users.values()}
+ # Stamp every currently-occupied channel as freshly active so the
+ # idle clock genuinely measures "no one in here," even for channels
+ # whose users joined before this service started.
+ for channel_id in occupied:
+ ice_app.record_channel_activity(channel_id)
+
+ for channel_id, ch in channels.items():
+ if channel_id == 0:
+ continue
+ if ch.parent != 0:
+ continue
+ if ch.name in managed_names:
+ continue
+ if ch.temporary:
+ # Murmur GCs these on its own when empty; don't race it.
+ continue
+ if channel_id in occupied:
+ continue
+
+ last_active = ice_app.get_channel_last_active(channel_id)
+ if last_active > threshold:
+ continue
+
+ try:
+ server.removeChannel(channel_id)
+ ice_app.forget_channel_activity(channel_id)
+ logger.info(
+ f"Deleted idle unmanaged Mumble channel '{ch.name}' "
+ f"(id={channel_id}, idle_since={last_active.isoformat()})"
+ )
+ except Exception as e:
+ logger.error(
+ f"Failed to delete idle channel '{ch.name}' (id={channel_id}): {e}"
+ )
diff --git a/opentakserver/defaultconfig.py b/opentakserver/defaultconfig.py
index 65d7b932..6b27967d 100644
--- a/opentakserver/defaultconfig.py
+++ b/opentakserver/defaultconfig.py
@@ -140,6 +140,27 @@ class DefaultConfig:
OTS_PROFILE_MAP_SOURCES = True
OTS_ENABLE_MUMBLE_AUTHENTICATION = False
+ OTS_ICE_SECRET = "" # nosec B105 -- config default, not a hardcoded credential
+ # When True, OTS adds the ACL grants that enable ATAK VX direct calls
+ # for non-admin users:
+ # - MakeTempChannel on Root and each OTS-managed channel
+ # - An @auth sub-grant on group channels with apply_sub=True so VX
+ # temps inherit Enter/Speak via Mumble's normal ACL propagation
+ # - Full admin grant (0x707ff with apply_sub=True) on every OTS
+ # channel so OTS administrators retain Murmur server-admin powers
+ # Set False to keep the legacy admin-only model (calls and channel
+ # creation are restricted to OTS administrators).
+ OTS_MUMBLE_ENABLE_CONFERENCE_CALLS = True
+ # Periodic cleanup of root-level Mumble channels not backed by an OTS
+ # group. Channels whose name matches an OTS group are always preserved.
+ # Anything else (event channels admins create through the Mumble GUI) is
+ # removed once it's been empty for IDLE_DAYS, so deliberate event channels
+ # survive as long as they're in use.
+ OTS_MUMBLE_CHANNEL_CLEANUP_ENABLED = True
+ OTS_MUMBLE_CHANNEL_CLEANUP_IDLE_DAYS = 5
+ # Direction-based mute for Mumble (OUT membership = listen-only). Disabled
+ # by default: IN and OUT both grant speak. Set True to enforce OUT=muted.
+ OTS_MUMBLE_DIRECTION_MUTE_ENABLED = False
OTS_IP_WHITELIST = ["127.0.0.1"]
@@ -275,4 +296,11 @@ class DefaultConfig:
"minutes": 1,
"next_run_time": None,
},
+ {
+ "id": "cleanup_unmanaged_mumble_channels",
+ "func": "opentakserver.blueprints.scheduled_jobs:cleanup_unmanaged_mumble_channels",
+ "trigger": "interval",
+ "days": 2,
+ "next_run_time": None,
+ },
]
diff --git a/opentakserver/mumble/mumble_authenticator.py b/opentakserver/mumble/mumble_authenticator.py
index eff7c741..dd2e2930 100644
--- a/opentakserver/mumble/mumble_authenticator.py
+++ b/opentakserver/mumble/mumble_authenticator.py
@@ -1,9 +1,12 @@
import os
+import zlib
import Ice
+from cryptography import x509
from flask import Flask
from flask_ldap3_login import AuthenticationResponseStatus
from flask_security import verify_password
+from sqlalchemy import func
from ..extensions import ldap_manager
@@ -17,40 +20,177 @@
)
import Murmur
+# Each OTS user gets a 1000-id range in Mumble. PC username auth uses the base
+# id (user.id * 1000); ATAK callsign auth uses base + a deterministic offset
+# derived from the callsign (so a single OTS account can connect from multiple
+# devices simultaneously, as the official ATAK VX voice plugin does).
+MUMBLE_ID_RANGE = 1000
+MUMBLE_ID_CALLSIGN_OFFSET_RANGE = MUMBLE_ID_RANGE - 1
-class MumbleAuthenticator(Murmur.ServerUpdatingAuthenticator):
- texture_cache = {}
+class MumbleAuthenticator(Murmur.ServerUpdatingAuthenticator):
def __init__(self, app, logger, ice):
Murmur.ServerUpdatingAuthenticator.__init__(self)
self.app: Flask = app
self.logger = logger
self.ice = ice
- def authenticate(self, username, password, certlist, certhash, strong, current=None):
+ # ----- public lookup helpers (also used by mumble_ice_app) ---------------
+
+ @staticmethod
+ def resolve_identity(app, username, certlist=None):
+ """Look up an OTS user from a Mumble username, ATAK callsign, or cert.
+
+ Lookup chain (first match wins):
+ 1. OTS username (PC clients)
+ 2. EUD callsign exact match
+ 3. EUD callsign with `---` suffix stripped (ATAK adds this)
+ 4. Above with `_` -> ` ` (ATAK Mumble plugin replaces spaces in callsigns)
+ 5. Cert CN -> EUD.uid (immutable; survives callsign renames)
+
+ Each EUD callsign comparison is whitespace-tolerant: the stored
+ column is wrapped in `TRIM()` before equality, so a stale leading
+ or trailing space on the EUD row doesn't prevent a match. Real-
+ world cause: ATAK occasionally publishes a CoT contact with
+ `callsign="Some User "` (trailing space), the EUD handler stores
+ it verbatim, and the Mumble plugin (which trims its own callsign
+ before building the Mumble username) then can't be matched back.
+ Trimming the column at lookup time keeps Mumble auth working
+ even when the underlying row is dirty; a separate write-side fix
+ (strip on insert/update in EudHandler / cot_parser / meshtastic_
+ controller) prevents new rows from carrying junk in the first
+ place. See PR description for the write-side follow-up.
+
+ Does NOT verify password. Returns (user_or_None, is_callsign_auth_bool).
+ """
+ from opentakserver.models.EUD import EUD
+
+ user = app.security.datastore.find_user(username=username)
+ if user:
+ return user, False
+
+ eud = None
+ for candidate in MumbleAuthenticator._candidate_callsigns(username):
+ eud = EUD.query.filter(func.trim(EUD.callsign) == candidate).first()
+ if eud:
+ break
+
+ if not eud and certlist:
+ eud = MumbleAuthenticator._eud_from_cert(certlist)
+
+ if eud and eud.user_id:
+ user = app.security.datastore.find_user(id=eud.user_id)
+ if user:
+ return user, True
+ return None, False
+
+ @staticmethod
+ def _candidate_callsigns(username):
+ """Ordered, de-duplicated callsign spellings to try for an EUD lookup.
+
+ Derived from a presented Mumble username; first match wins:
+ 1. the presented name verbatim
+ 2. with the ATAK Vx ``---`` suffix stripped (and surrounding
+ whitespace trimmed, in case the callsign portion carries a stray
+ leading/trailing space)
+ 3. #2 with underscores turned back into spaces (the Vx plugin
+ replaces spaces in callsigns with underscores)
+
+ Stored ``EUD.callsign`` values are matched with SQL ``TRIM()`` at the
+ call site, so this only needs to normalize the *presented* side.
+ """
+ base = username
+ if "---" in username:
+ base = username.split("---")[0].strip()
+
+ ordered = [username, base, base.replace("_", " ")]
+
+ seen = set()
+ candidates = []
+ for name in ordered:
+ if name and name not in seen:
+ seen.add(name)
+ candidates.append(name)
+ return candidates
+
+ @staticmethod
+ def _eud_from_cert(certlist):
+ """Look up an EUD by parsing the client cert chain's CN.
+
+ ATAK device certs use the EUD UID (e.g. `ANDROID-xxxx`) as the cert CN,
+ so this lookup survives mid-session callsign renames -- the OTS EUDs
+ table only updates on the next CoT, but the Mumble plugin auths
+ immediately with the new name.
+ """
+ from opentakserver.models.EUD import EUD
+
+ for cert_bytes in certlist:
+ try:
+ cert = x509.load_der_x509_certificate(cert_bytes)
+ cns = cert.subject.get_attributes_for_oid(x509.NameOID.COMMON_NAME)
+ if not cns:
+ continue
+ eud = EUD.query.filter_by(uid=cns[0].value).first()
+ if eud:
+ return eud
+ except Exception: # nosec B112 -- skip an unparseable cert, try the next
+ continue
+ return None
+
+ @staticmethod
+ def mumble_identity(user, is_callsign_auth, presented_username):
+ """Return (mumble_user_id, display_name) for an authenticated user.
+
+ PC username auth -> (user.id * 1000, user.username)
+ ATAK callsign auth -> (user.id * 1000 + hash(callsign) % 999 + 1, callsign)
+
+ The hash offset lets one OTS account connect from multiple ATAK devices
+ simultaneously, each with a unique Mumble user id (the VX plugin even
+ opens two sockets per device, each with a different `---` suffix).
"""
- This function is called to authenticate a user
+ if is_callsign_auth:
+ # Non-cryptographic hash: we only need a stable per-callsign offset
+ # within the user's id block so one OTS account can host multiple
+ # simultaneous device connections. CRC32 is deterministic and is
+ # not a security hash (keeps it off the SAST weak-crypto radar).
+ offset = zlib.crc32(presented_username.encode()) % MUMBLE_ID_CALLSIGN_OFFSET_RANGE + 1
+ return user.id * MUMBLE_ID_RANGE + offset, presented_username
+ return user.id * MUMBLE_ID_RANGE, user.username
+
+ # ----- Murmur Ice callbacks ---------------------------------------------
+
+ def authenticate(self, username, password, certlist, certhash, strong, current=None):
+ """Authenticate a Mumble client.
+
+ Returns (mumble_id, display_name, group_list) on success or
+ (-1, None, None) on failure. Returning -2 tells Murmur to use its
+ own auth fallback (used only for SuperUser).
"""
if username == "SuperUser":
- return (-2, None, None)
+ return -2, None, None
self.logger.info("Mumble auth request for {}".format(username))
with self.app.app_context():
- user = self.app.security.datastore.find_user(username=username)
+ user, is_callsign_auth = self.resolve_identity(self.app, username, certlist)
+
if not user:
- self.logger.warning("Mumble auth: User {} not found".format(username))
+ self.logger.warning("Mumble auth: user {} not found".format(username))
return -1, None, None
- elif not user.active:
- self.logger.warning("Mumble auth: User {} is deactivated".format(username))
+ if not user.active:
+ self.logger.warning("Mumble auth: user {} is deactivated".format(username))
return -1, None, None
+ mumble_groups = [g.name for g in user.groups]
+ if any(r.name == "administrator" for r in user.roles):
+ mumble_groups.append("admin")
+
+ authenticated = False
+
if self.app.config.get("OTS_ENABLE_LDAP"):
auth_result = ldap_manager.authenticate(username, password)
if auth_result.status == AuthenticationResponseStatus.success:
- self.logger.info("Mumble auth: {} has been authenticated".format(username))
-
- # Keep this import here to avoid a circular import when OTS is started
+ # Keep this import inline to avoid a circular import at startup.
from opentakserver.blueprints.ots_api.ldap_api import save_user
save_user(
@@ -59,73 +199,103 @@ def authenticate(self, username, password, certlist, certhash, strong, current=N
auth_result.user_info,
auth_result.user_groups,
)
-
- return user.id, user.username, None
-
+ authenticated = True
+ elif is_callsign_auth:
+ # ATAK clients authenticate by client cert, not password.
+ # The cert was already validated by Murmur's TLS layer before
+ # this callback was invoked; we only need to verify that the
+ # presented identity (callsign or cert CN) maps to an EUD.
+ authenticated = True
elif verify_password(password, user.password):
- self.logger.info("Mumble auth: {} has been authenticated".format(username))
- return user.id, user.username, None
+ authenticated = True
- self.logger.warning("Mumble auth: Bad password for {}".format(username))
- return -1, None, None
+ if not authenticated:
+ self.logger.warning("Mumble auth: bad password for {}".format(username))
+ return -1, None, None
- def idToTexture(self, id, current=None):
- return
+ mumble_id, display_name = self.mumble_identity(user, is_callsign_auth, username)
+ self.logger.info(
+ "Mumble auth: id={} display={} groups={}".format(
+ mumble_id, display_name, mumble_groups
+ )
+ )
+ return mumble_id, display_name, mumble_groups
def getInfo(self, id, current=None):
+ """Return user info to Murmur so it stays authoritative for Ice-authed users.
+
+ Murmur 1.3 will otherwise look up cert/password against its local
+ ``user_info`` table, which is empty for Ice-authed users. That causes
+ a "Wrong certificate or password for existing user" rejection on every
+ reconnect -- before Ice authenticate() is even called.
"""
- Gets called to fetch user specific information
- """
+ if id is None or id <= 0:
+ return False, None
+ try:
+ with self.app.app_context():
+ from opentakserver.extensions import db
+ from opentakserver.models.user import User
- # We do not expose any additional information so always fall through
- return False, None
+ user = db.session.get(User, id // MUMBLE_ID_RANGE)
+ if not user:
+ return False, None
+ info = {Murmur.UserInfo.UserName: user.username}
+ if user.email:
+ info[Murmur.UserInfo.UserEmail] = user.email
+ return True, info
+ except Exception as e:
+ self.logger.error("Mumble getInfo({}) failed: {}".format(id, e))
+ return False, None
def nameToId(self, name, current=None):
+ """Tell Murmur the Mumble id that owns a given name.
+
+ Returning -2 (fall through) makes Murmur consult its local users table,
+ which causes the rename/reconnect rejection bug; returning the encoded
+ id keeps Ice authoritative.
"""
- Gets called to get the id for a given username
- """
- pass
+ if not name or name == "SuperUser":
+ return -2
+ try:
+ with self.app.app_context():
+ user, is_callsign_auth = self.resolve_identity(self.app, name)
+ if not user:
+ return -2
+ mumble_id, _ = self.mumble_identity(user, is_callsign_auth, name)
+ return mumble_id
+ except Exception as e:
+ self.logger.error("Mumble nameToId({}) failed: {}".format(name, e))
+ return -2
def idToName(self, id, current=None):
- """
- Gets called to get the username for a given id
- """
- pass
+ """Return display name for a Mumble id. Used for ACL/log lookups."""
+ if id is None or id <= 0:
+ return ""
+ try:
+ with self.app.app_context():
+ from opentakserver.extensions import db
+ from opentakserver.models.user import User
+
+ user = db.session.get(User, id // MUMBLE_ID_RANGE)
+ return user.username if user else ""
+ except Exception as e:
+ self.logger.error("Mumble idToName({}) failed: {}".format(id, e))
+ return ""
def idToTexture(self, id, current=None):
- """
- Gets called to get the corresponding texture for a user
- """
- # seems like it pulled a user's avatar from a phpbb DB
+ return b""
- def registerUser(self, name, current=None):
- """
- Gets called when the server is asked to register a user.
- """
- pass
+ def registerUser(self, info, current=None):
+ return -2
def unregisterUser(self, id, current=None):
- """
- Gets called when the server is asked to unregister a user.
- """
- pass
+ return -1
def getRegisteredUsers(self, filter, current=None):
- """
- Returns a list of usernames in the phpBB3 database which contain
- filter as a substring.
- """
- pass
+ return {}
def setInfo(self, id, info, current=None):
- """
- Gets called when the server is supposed to save additional information
- about a user to his database
- """
- pass
+ return 0
def setTexture(self, id, texture, current=None):
- """
- Gets called when the server is asked to update the user texture of a user
- """
- pass
+ return -1
diff --git a/opentakserver/mumble/mumble_ice_app.py b/opentakserver/mumble/mumble_ice_app.py
index 3bc48b8a..7cdbfb4b 100644
--- a/opentakserver/mumble/mumble_ice_app.py
+++ b/opentakserver/mumble/mumble_ice_app.py
@@ -1,5 +1,7 @@
import os
import threading
+import time
+from datetime import datetime, timezone
from threading import Timer
import Ice
@@ -16,6 +18,24 @@
)
import Murmur
+# Murmur permission bit masks — names mirror Murmur.ice for grep-ability.
+PERM_TRAVERSE = 0x002
+PERM_ENTER = 0x004
+PERM_SPEAK = 0x008
+PERM_WHISPER = 0x100
+PERM_TEXT_MESSAGE = 0x200
+PERM_MAKE_TEMP_CHANNEL = 0x400
+
+# Baseline speak/text grant used on temp channels so any authenticated user
+# (regardless of which OTS group they belong to) can join a VX-initiated
+# conference call once invited.
+PERM_BASELINE_SPEAK = PERM_TRAVERSE | PERM_ENTER | PERM_SPEAK | PERM_WHISPER | PERM_TEXT_MESSAGE
+
+# Same admin grant Murmur uses on Root by default — gives admins full control
+# of temp channels (kick stragglers, link, etc.) without touching their existing
+# per-channel admin rights.
+PERM_ADMIN_FULL = 0x707FF
+
class MumbleIceDaemon(threading.Thread):
def __init__(self, app, logger):
@@ -30,17 +50,35 @@ def run(self):
props = Ice.createProperties()
props.setProperty("Ice.ImplicitContext", "Shared")
props.setProperty("Ice.Default.EncodingVersion", "1.0")
- props.setProperty("Ice.Default.InvocationTimeout", str(30 * 1000))
+ # 5s is plenty for localhost Ice round-trips and lets transient
+ # Murmur stalls surface as fast errors instead of compounding into
+ # minute-long client-visible hangs.
+ props.setProperty("Ice.Default.InvocationTimeout", str(5 * 1000))
+ # Cap the client thread pool so bursts of concurrent Ice calls during
+ # call lifecycle can't queue beyond what Murmur can drain.
+ props.setProperty("Ice.ThreadPool.Client.SizeMax", "10")
+ # Disable Ice's default retry-once behavior. With the short timeout
+ # above we'd rather see a timeout immediately than wait for a retry
+ # that doubles user-visible latency.
+ props.setProperty("Ice.RetryIntervals", "-1")
props.setProperty("Ice.MessageSizeMax", str(1024))
idata = Ice.InitializationData()
idata.properties = props
- # Create Ice connection
+ # Create Ice connection. The secret must be in ImplicitContext
+ # before any proxy call (e.g. checkedCast below) or Murmur rejects
+ # with InvalidSecretException.
ice = Ice.initialize(idata)
- proxy = ice.stringToProxy("Meta:tcp -h 127.0.0.1 -p 6502")
- secret = ""
- if secret != "":
+ secret = self.app.config.get("OTS_ICE_SECRET", "")
+ if secret:
ice.getImplicitContext().put("secret", secret)
+ else:
+ self.logger.warning(
+ "OTS_ICE_SECRET is empty; Murmur will reject Ice calls if its "
+ "icesecretread/icesecretwrite is set."
+ )
+
+ proxy = ice.stringToProxy("Meta:tcp -h 127.0.0.1 -p 6502")
try:
meta = Murmur.MetaPrx.checkedCast(proxy)
except Ice.ConnectionRefusedException:
@@ -63,16 +101,56 @@ def __init__(self, app, logger, ice):
self.failed_watch = False
self.watchdog = None
self.auth = None
+ self.adapter = None
+ # server_id -> ServerCallbackPrx; guards against duplicate registration
+ self.server_callbacks = {}
+ # channel_id -> last activity datetime (UTC). Updated by the direction
+ # enforcement callback on every userConnected / userStateChanged so the
+ # scheduled cleanup job can identify idle event channels. After a
+ # service restart, channels with no entry fall back to service_start_time.
+ self._channel_last_active = {}
+ self._channel_activity_lock = threading.Lock()
+ self.service_start_time = datetime.now(timezone.utc)
+ # Serializes ACL get/modify/set passes on persistent channels so
+ # concurrent request_sync calls (e.g. multiple group_api add operations)
+ # can't TOCTOU-race each other or clobber manual ACL edits made between
+ # the read and the write.
+ self._acl_lock = threading.Lock()
+ # Expose this daemon to Flask blueprints so group_api can request channel
+ # syncs after add/delete. app.extensions is a plain dict; reads are thread-safe.
+ self.app.extensions["mumble_ice_app"] = self
+
+ def record_channel_activity(self, channel_id):
+ """Stamp `now` as the last-active time for a channel. Called by the
+ direction-enforcement callback on connect / channel-hop, and by the
+ cleanup job when it observes a channel that's currently occupied."""
+ with self._channel_activity_lock:
+ self._channel_last_active[channel_id] = datetime.now(timezone.utc)
+
+ def get_channel_last_active(self, channel_id):
+ """Return the last-activity timestamp for a channel, falling back to
+ service_start_time if no activity has been recorded since boot."""
+ with self._channel_activity_lock:
+ return self._channel_last_active.get(channel_id, self.service_start_time)
+
+ def forget_channel_activity(self, channel_id):
+ """Drop a channel's last-active entry (e.g. after the cleanup job
+ deletes the channel) so the dict doesn't grow unboundedly."""
+ with self._channel_activity_lock:
+ self._channel_last_active.pop(channel_id, None)
def run(self, *args):
if not self.initialize_ice_connection():
self.logger.error("Mumble server connection failed")
return 1
+ # check_connection() schedules a recursive 10s Timer that reattaches
+ # callbacks if Murmur restarts. Don't cancel it -- leaving it running
+ # is the whole point. Without it, OTS doesn't recover after a Murmur
+ # restart and clients get "Wrong certificate or password" rejections
+ # until OTS is manually restarted.
self.check_connection()
- self.watchdog.cancel()
-
if self.interrupted():
self.logger.warning("Caught interrupt, shutting down")
@@ -81,18 +159,17 @@ def run(self, *args):
def initialize_ice_connection(self):
"""
Establishes the two-way Ice connection and adds the authenticator to the
- configured servers
+ configured servers. The Ice secret was already pushed into the shared
+ ImplicitContext by MumbleIceDaemon.run().
"""
- # if False and 'ice_secret':
- # self.ice.getImplicitContext().put("secret", "some_secret")
-
self.logger.debug("Connecting to Ice server ({}:{})".format("127.0.0.1", 6502))
base = self.ice.stringToProxy("Meta:tcp -h {} -p {}".format("127.0.0.1", 6502))
self.meta = Murmur.MetaPrx.uncheckedCast(base)
adapter = self.ice.createObjectAdapterWithEndpoints("Callback.Client", "tcp -h 127.0.0.1")
adapter.activate()
+ self.adapter = adapter
metacbprx = adapter.addWithUUID(MetaCallback(self))
self.metacb = Murmur.MetaCallbackPrx.uncheckedCast(metacbprx)
@@ -117,6 +194,7 @@ def attach_callbacks(self):
"Setting mumble authenticator for virtual server {}".format(server.id())
)
server.setAuthenticator(self.auth)
+ self.attach_server_callback(server)
except (
Murmur.InvalidSecretException,
@@ -141,6 +219,256 @@ def attach_callbacks(self):
self.connected = True
return True
+ def attach_server_callback(self, server):
+ """Register DirectionEnforcementCallback for IN/OUT suppress enforcement.
+
+ Guarded against duplicate registration — check_connection() calls
+ attach_callbacks() every 10 seconds. The guard is cleared by
+ on_server_stopped() so a restarted virtual server gets a fresh
+ callback correctly.
+ """
+ server_id = server.id()
+ if server_id in self.server_callbacks:
+ return
+
+ cb = DirectionEnforcementCallback(self.app, self.logger, server)
+ cbprx = self.adapter.addWithUUID(cb)
+ server_cb = Murmur.ServerCallbackPrx.uncheckedCast(cbprx)
+
+ try:
+ server.addCallback(server_cb)
+ self.server_callbacks[server_id] = server_cb
+ self.logger.info(f"Direction enforcement callback attached to server {server_id}")
+ except Exception as e:
+ self.logger.error(f"Failed to attach server callback to {server_id}: {e}")
+
+ self.sync_channels_from_groups(server)
+
+ def sync_channels_from_groups(self, server):
+ """Create a root-level Mumble channel for each OTS group lacking one.
+
+ Channel name == group name so DirectionEnforcementCallback's lookup
+ (which keys by channel name) keeps working. Skips __ANON__. Never
+ deletes channels — too risky if users are mid-conversation; logs instead.
+ """
+ try:
+ with self.app.app_context():
+ from opentakserver.extensions import db
+ from opentakserver.models.Group import Group
+
+ rows = db.session.query(Group).all()
+ group_names = {g.name for g in rows if g.name and g.name != "__ANON__"}
+
+ if not group_names:
+ return
+
+ existing = server.getChannels()
+ root_names = {ch.name for ch in existing.values() if ch.parent == 0}
+
+ missing = group_names - root_names
+ stale = root_names - group_names - {"Root"}
+
+ for name in sorted(missing):
+ try:
+ cid = server.addChannel(name, 0)
+ self.logger.info(
+ f"Mumble channel created for OTS group '{name}' "
+ f"(server={server.id()}, channel_id={cid})"
+ )
+ except Exception as e:
+ self.logger.error(f"Failed to create channel '{name}': {e}")
+
+ for name in sorted(stale):
+ self.logger.warning(
+ f"Mumble channel '{name}' has no matching OTS group "
+ f"(server={server.id()}); leaving in place"
+ )
+
+ if self.app.config.get("OTS_MUMBLE_ENABLE_CONFERENCE_CALLS", True):
+ self._ensure_temp_channel_acls(server, group_names)
+ except Exception as e:
+ self.logger.error(f"sync_channels_from_groups failed: {e}", exc_info=True)
+
+ def _ensure_temp_channel_acls(self, server, managed_names):
+ """Grant MakeTempChannel on Root and each OTS-managed channel.
+
+ The OTS install's ACL model locks group channels to their members
+ (revoke @all, grant only to ) and grants MakeTempChannel
+ only to admins on Root. That blocks the ATAK VX plugin's direct-call
+ feature for non-admin users, since VX needs to create a temp channel
+ for the 1:1. This pass ORs MakeTempChannel into the auth//
+ admin grants on the parents that gate creation.
+
+ Channel-level ACLs on the temp itself are set separately by
+ DirectionEnforcementCallback.channelCreated when a temp is created.
+ """
+ try:
+ channels = server.getChannels()
+ except Exception as e:
+ self.logger.error(f"getChannels failed: {e}")
+ return
+
+ for channel_id, ch in channels.items():
+ if channel_id != 0 and ch.name not in managed_names:
+ continue
+ self._ensure_make_temp_channel_acl(server, channel_id, ch.name)
+
+ def _ensure_make_temp_channel_acl(self, server, channel_id, channel_name):
+ """Idempotently set the ACL needed for VX direct calls on a persistent
+ channel. Three guarantees:
+
+ 1. The channel's own group/auth grant has MakeTempChannel so users
+ can create temp channels under it (the call lifecycle).
+ 2. The `admin` grant has the full PERM_ADMIN_FULL mask with
+ apply_sub=True so OTS administrators can administer the server
+ (move, kick, ban, manage ACLs) on every OTS channel AND on any
+ subchannel/temp created beneath it.
+ 3. An `auth` grant exists with apply_sub=True so temp channels
+ created under this channel inherit Enter/Speak/Whisper/Text for
+ any authenticated user. On Root that grant also applies here
+ (users can speak in Root itself). On group channels apply_here
+ stays False so the channel itself remains members-only.
+
+ Guarantee #3 is what makes per-temp ACL setACL hooks unnecessary --
+ the @auth grant flows down via inheritance so we don't have to
+ intervene every time VX creates a temp.
+
+ Only modifies non-inherited entries; inherited rows are filtered out
+ before setACL (passing them back would shadow the parent's ACL).
+ Idempotent — safe to re-run.
+ """
+ # Serialize get/modify/set so concurrent syncs (request_sync racing
+ # with the startup attach path) can't TOCTOU-clobber each other or
+ # overwrite a manual ACL edit that lands between the read and write.
+ with self._acl_lock:
+ try:
+ acls, groups, inherit = server.getACL(channel_id)
+ except Exception as e:
+ self.logger.error(f"getACL({channel_id}, name={channel_name}) failed: {e}")
+ return
+
+ is_root = channel_id == 0
+ own_acls = [a for a in acls if not a.inherited]
+
+ targets = {"auth", "admin"}
+ if channel_name and channel_name not in ("Root", "__ANON__"):
+ targets.add(channel_name)
+
+ dirty = False
+ for acl in own_acls:
+ if acl.group not in targets:
+ continue
+
+ new_allow = acl.allow | PERM_MAKE_TEMP_CHANNEL
+ new_apply_subs = acl.applySubs
+ new_apply_here = acl.applyHere
+
+ if acl.group == "admin":
+ new_allow |= PERM_ADMIN_FULL
+ new_apply_subs = True
+
+ if acl.group == "auth":
+ # @auth must propagate to subchannels so VX temps inherit
+ # Enter+Speak+Whisper+TextMessage without per-temp intervention.
+ new_apply_subs = True
+ # On non-Root group channels, the channel itself MUST stay
+ # members-only (gated by the ACL). Force
+ # apply_here=False even if a prior buggy run or a manual
+ # edit left @auth applying to the channel itself, so the
+ # per-group privacy model can't drift open.
+ if not is_root:
+ new_apply_here = False
+
+ if (
+ new_allow != acl.allow
+ or new_apply_subs != acl.applySubs
+ or new_apply_here != acl.applyHere
+ ):
+ before_allow = acl.allow
+ before_sub = acl.applySubs
+ before_here = acl.applyHere
+ acl.allow = new_allow
+ acl.applySubs = new_apply_subs
+ acl.applyHere = new_apply_here
+ self.logger.info(
+ f"Updating ACL on channel_id={channel_id} name={channel_name} "
+ f"group={acl.group}: allow 0x{before_allow:x} -> 0x{acl.allow:x}, "
+ f"apply_here {before_here} -> {acl.applyHere}, "
+ f"apply_sub {before_sub} -> {acl.applySubs}"
+ )
+ dirty = True
+
+ # Group channels (REACT, Family, etc.) typically have no own @auth
+ # ACL -- access is gated entirely by the per-channel group grant.
+ # Add an apply_here=False, apply_sub=True @auth grant so temps
+ # under this channel inherit Enter+Speak for any authenticated
+ # user. The channel itself stays members-only.
+ if not is_root and not any(a.group == "auth" for a in own_acls):
+ own_acls.append(
+ Murmur.ACL(
+ applyHere=False,
+ applySubs=True,
+ inherited=False,
+ userid=-1,
+ group="auth",
+ allow=PERM_BASELINE_SPEAK,
+ deny=0,
+ )
+ )
+ self.logger.info(
+ f"Adding @auth sub-grant on channel_id={channel_id} "
+ f"name={channel_name}: allow=0x{PERM_BASELINE_SPEAK:x}, apply_sub=True"
+ )
+ dirty = True
+
+ if not dirty:
+ return
+
+ try:
+ server.setACL(channel_id, own_acls, groups, inherit)
+ self.logger.info(
+ f"Committed ACL update on channel_id={channel_id} name={channel_name}"
+ )
+ except Exception as e:
+ self.logger.error(
+ f"setACL({channel_id}, name={channel_name}) failed: {e}",
+ exc_info=True,
+ )
+
+ def request_sync(self):
+ """Trigger a channel sync on all booted servers off-thread.
+
+ Called by group_api after add/delete so newly-created groups get a
+ Mumble channel without waiting for the next service restart.
+ """
+ threading.Thread(target=self._sync_all_servers, daemon=True).start()
+
+ def _sync_all_servers(self):
+ try:
+ for server in self.meta.getBootedServers():
+ self.sync_channels_from_groups(server)
+ except Ice.ConnectionRefusedException as e:
+ # Murmur died between request_sync and now. Mirror what
+ # MetaCallback.stopped does -- flip connected=False so the
+ # cleanup job and any other consumers of that flag stop using
+ # the dead proxy. The watchdog will repair state on its next
+ # tick (~10s) when attach_callbacks reconnects.
+ self.connected = False
+ self.logger.warning(
+ f"_sync_all_servers: Ice connection refused, marking disconnected: {e}"
+ )
+ except Exception as e:
+ self.logger.error(f"_sync_all_servers: {e}", exc_info=True)
+
+ def on_server_stopped(self, server_id):
+ """Clear the callback guard and any cached session state for a stopped server.
+
+ Without this, the duplicate-registration guard would prevent re-registration
+ when the virtual server restarts.
+ """
+ self.server_callbacks.pop(server_id, None)
+ self.logger.info(f"Cleared callback guard for stopped server {server_id}")
+
def check_connection(self):
"""
Tries reapplies all callbacks to make sure the authenticator
@@ -148,15 +476,263 @@ def check_connection(self):
"""
try:
- self.attach_callbacks()
- except Ice.Exception as e:
- self.logger.warning(
- "{}: Failed connection check, will retry in next watchdog run ({}s)".format(e, 10)
+ try:
+ self.attach_callbacks()
+ except Ice.Exception as e:
+ self.logger.warning(
+ "{}: Failed connection check, will retry in next watchdog run ({}s)".format(
+ e, 10
+ )
+ )
+ except Exception as e:
+ # Anything other than an Ice exception (a SQLAlchemy error
+ # from sync_channels_from_groups, a runtime bug, etc.) would
+ # otherwise propagate out and break the watchdog Timer chain,
+ # leaving OTS unable to recover from future Murmur restarts.
+ self.logger.error(f"Unexpected error in watchdog reattach: {e}", exc_info=True)
+ finally:
+ # Always re-arm the timer, even if attach_callbacks raised
+ # something unexpected. daemon=True so the timer thread doesn't
+ # block process shutdown.
+ self.watchdog = Timer(10, self.check_connection)
+ self.watchdog.daemon = True
+ self.watchdog.start()
+
+
+class DirectionEnforcementCallback(Murmur.ServerCallback):
+ """Enforces OTS IN/OUT speak direction by setting Murmur's suppress flag.
+
+ Channel access (who can enter which channel) is controlled by Murmur's
+ own ACL configuration. This callback's only job is to mute users whose
+ OTS group membership has direction=OUT (listen-only) and unmute those
+ with direction=IN.
+
+ All Ice proxy calls (getState/setState) are dispatched to a background
+ daemon thread to avoid deadlocking the Ice thread pool.
+ """
+
+ def __init__(self, app, logger, server):
+ Murmur.ServerCallback.__init__(self)
+ self.app = app
+ self.logger = logger
+ self.server = server
+ self.server_id = server.id()
+ self._channel_cache = None
+ self._channel_cache_time = 0
+ self._session_lock = threading.Lock()
+ self._session_cache = {} # session_id -> {directions, is_admin, cached_at}
+ # Sessions we've actively set suppress=True for. Used so steady-state
+ # channel moves make zero Ice calls -- we only round-trip to Murmur
+ # when a user actually crosses an IN<->OUT boundary. For environments
+ # with no OUT memberships this set stays empty forever and direction
+ # enforcement is effectively free.
+ self._suppressed_sessions = set()
+ self._suppress_lock = threading.Lock()
+ # Direction-based mute is disabled by default (IN and OUT memberships
+ # both grant speak). Flip OTS_MUMBLE_DIRECTION_MUTE_ENABLED to re-enable
+ # OUT=listen-only suppression.
+ self._direction_mute_enabled = bool(
+ app.config.get("OTS_MUMBLE_DIRECTION_MUTE_ENABLED", False)
+ )
+
+ # ------------------------------------------------------------------ helpers
+
+ def _get_channel_map(self):
+ """Return {channel_id: channel_name}, cached for 60 seconds."""
+ if self._channel_cache is None or (time.time() - self._channel_cache_time) > 60:
+ try:
+ channels = self.server.getChannels()
+ self._channel_cache = {cid: ch.name for cid, ch in channels.items()}
+ self._channel_cache_time = time.time()
+ except Exception as e:
+ self.logger.error(f"Failed to refresh channel map: {e}")
+ return self._channel_cache or {}
+ return self._channel_cache
+
+ def _get_user_directions(self, session_id, username):
+ """Return (group_directions dict, is_admin) for a user, cached for 30 seconds.
+
+ group_directions maps group_name -> 'IN' or 'OUT'.
+ Prefers IN over OUT when a user has both rows for the same group.
+ """
+ cache_ttl = 30
+ now = time.time()
+
+ with self._session_lock:
+ cached = self._session_cache.get(session_id)
+ if cached and (now - cached["cached_at"]) < cache_ttl:
+ return cached["directions"], cached["is_admin"]
+
+ group_directions = {}
+ is_admin = False
+
+ try:
+ with self.app.app_context():
+ # Reuse the authenticator's lookup chain (username -> callsign ->
+ # base callsign -> underscore->space) so direction enforcement
+ # finds users by the same path Mumble auth used.
+ user, _ = MumbleAuthenticator.resolve_identity(self.app, username)
+
+ if not user:
+ self.logger.warning(f"Direction lookup: OTS user not found for '{username}'")
+ return {}, False
+
+ for membership in user.group_memberships:
+ if not membership.enabled:
+ continue
+ grp = membership.group.name
+ # Prefer IN over OUT if both rows exist for the same group
+ if group_directions.get(grp) != "IN":
+ group_directions[grp] = membership.direction
+
+ is_admin = any(r.name == "administrator" for r in user.roles)
+ except Exception as e:
+ self.logger.error(f"Direction lookup failed for '{username}': {e}", exc_info=True)
+ return {}, False
+
+ with self._session_lock:
+ self._session_cache[session_id] = {
+ "directions": group_directions,
+ "is_admin": is_admin,
+ "cached_at": now,
+ }
+
+ return group_directions, is_admin
+
+ def _dispatch_apply(self, session, username, channel_id, group_directions, is_admin):
+ """Dispatch only the Ice state calls to a background thread.
+
+ DB queries run in the Ice dispatch thread (same as authenticate() — works fine).
+ Only getState()/setState() must be off-thread to avoid deadlocking the Ice pool.
+ """
+ threading.Thread(
+ target=self._apply_direction,
+ args=(session, username, channel_id, group_directions, is_admin),
+ daemon=True,
+ ).start()
+
+ def _apply_direction(self, session, username, channel_id, group_directions, is_admin):
+ """Apply the suppress flag based on the user's OTS direction.
+
+ Uses in-memory tracking (_suppressed_sessions) so the common case --
+ a user moving between channels where direction is IN or undefined --
+ makes zero Ice calls. Only round-trips to Murmur when the desired
+ suppress bit actually has to flip.
+ """
+ try:
+ if is_admin:
+ return
+
+ channel_map = self._get_channel_map()
+ channel_name = channel_map.get(channel_id, f"unknown({channel_id})")
+
+ # Root and non-OTS channels (VX temps, event channels) are not
+ # subject to direction enforcement -- treat as if direction=IN.
+ direction = group_directions.get(channel_name)
+ should_suppress = self._direction_mute_enabled and direction == "OUT"
+
+ with self._suppress_lock:
+ currently_suppressed = session in self._suppressed_sessions
+
+ if should_suppress == currently_suppressed:
+ return # No state change needed; no Ice call.
+
+ try:
+ s = self.server.getState(session)
+ except Murmur.InvalidSessionException:
+ # Session disconnected between dispatch and execution -- normal.
+ self.logger.debug(f"Direction: session {session} ({username}) gone before getState")
+ with self._suppress_lock:
+ self._suppressed_sessions.discard(session)
+ return
+
+ s.suppress = should_suppress
+ try:
+ self.server.setState(s)
+ except Murmur.InvalidSessionException:
+ self.logger.debug(f"Direction: session {session} ({username}) gone before setState")
+ with self._suppress_lock:
+ self._suppressed_sessions.discard(session)
+ return
+
+ with self._suppress_lock:
+ if should_suppress:
+ self._suppressed_sessions.add(session)
+ else:
+ self._suppressed_sessions.discard(session)
+
+ if should_suppress:
+ self.logger.info(f"LISTEN ONLY: {username} in {channel_name} (direction=OUT)")
+ try:
+ self.server.sendMessage(
+ session,
+ f"Listen Only: You are receive-only in {channel_name}.",
+ )
+ except Exception: # nosec B110 -- best-effort notice; never fatal
+ pass
+ else:
+ self.logger.info(
+ f"SPEAK ENABLED: {username} in {channel_name} "
+ f"(direction={direction or 'non-OTS'})"
+ )
+
+ except Murmur.InvalidSessionException:
+ self.logger.debug(f"Direction: session {session} ({username}) gone during apply")
+ with self._suppress_lock:
+ self._suppressed_sessions.discard(session)
+ except Exception as e:
+ self.logger.error(
+ f"Unhandled error applying direction for '{username}' session={session}: {e}",
+ exc_info=True,
)
- # Renew the timer
- self.watchdog = Timer(10, self.check_connection)
- self.watchdog.start()
+ # ----------------------------------------------------------- Ice callbacks
+
+ def _record_activity(self, channel_id):
+ ice_app = self.app.extensions.get("mumble_ice_app")
+ if ice_app is not None:
+ ice_app.record_channel_activity(channel_id)
+
+ def userConnected(self, state, current=None):
+ self.logger.info(
+ f"User connected: {state.name} (session={state.session}, userid={state.userid}) "
+ f"channel={state.channel}"
+ )
+ self._record_activity(state.channel)
+ # DB lookup runs here in the Ice dispatch thread (safe — same as authenticate())
+ directions, is_admin = self._get_user_directions(state.session, state.name)
+ # Only the Ice getState/setState calls go to a background thread
+ self._dispatch_apply(state.session, state.name, state.channel, directions, is_admin)
+
+ def userDisconnected(self, state, current=None):
+ with self._session_lock:
+ self._session_cache.pop(state.session, None)
+ with self._suppress_lock:
+ self._suppressed_sessions.discard(state.session)
+ self.logger.info(f"User disconnected: {state.name} (session={state.session})")
+
+ def userStateChanged(self, state, current=None):
+ """Fire on any state change — channel moves trigger direction re-check."""
+ self._record_activity(state.channel)
+ directions, is_admin = self._get_user_directions(state.session, state.name)
+ self._dispatch_apply(state.session, state.name, state.channel, directions, is_admin)
+
+ def userTextMessage(self, state, message, current=None):
+ pass
+
+ def channelCreated(self, state, current=None):
+ # Per-temp ACL intervention is no longer needed: the @auth grant on
+ # parent channels (set by _ensure_make_temp_channel_acl) propagates
+ # to temps via inheritance, and Murmur auto-creates a creator-admin
+ # group on each temp for VX's call lifecycle management. We only
+ # need to invalidate the channel cache here.
+ self._channel_cache = None
+
+ def channelRemoved(self, state, current=None):
+ self._channel_cache = None
+
+ def channelStateChanged(self, state, current=None):
+ self._channel_cache = None
class MetaCallback(Murmur.MetaCallback):
@@ -169,11 +745,15 @@ def started(self, server, current=None):
This function is called when a virtual server is started
and makes sure an authenticator gets attached if needed.
"""
+ server_id = server.id()
self.authenticator.logger.info(
- "Setting authenticator for virtual server {}".format(server.id())
+ "Virtual server {} started — attaching authenticator and direction callback".format(
+ server_id
+ )
)
try:
server.setAuthenticator(self.authenticator.auth)
+ self.authenticator.attach_server_callback(server)
# Apparently this server was restarted without us noticing
except (Murmur.InvalidSecretException, Ice.UnknownUserException) as e:
if hasattr(e, "unknown") and e.unknown != "Murmur::InvalidSecretException":
@@ -190,9 +770,11 @@ def stopped(self, server, current=None):
# Only try to output the server id if we think we are still connected to prevent
# flooding of our thread pool
try:
+ server_id = server.id()
self.authenticator.logger.info(
- "Authenticated virtual server {} got stopped".format(server.id())
+ "Virtual server {} stopped — clearing callback guard".format(server_id)
)
+ self.authenticator.on_server_stopped(server_id)
return
except Ice.ConnectionRefusedException:
self.authenticator.connected = False
diff --git a/tests/test_mumble_authenticator.py b/tests/test_mumble_authenticator.py
new file mode 100644
index 00000000..9b3cb32d
--- /dev/null
+++ b/tests/test_mumble_authenticator.py
@@ -0,0 +1,154 @@
+"""Unit tests for the Mumble authenticator's identity resolution.
+
+These cover the presented-username normalization that keeps ATAK Vx plugin
+clients (which connect as ``callsign---``) matching their OTS account,
+plus the Mumble id/display-name mapping.
+
+The authenticator module loads Murmur's Ice slice at import time, so the whole
+module is unavailable without Ice installed -- skip cleanly in that case (the
+suite runs under CI and on the server, where Ice is present).
+"""
+
+import sys
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+pytest.importorskip("Ice")
+
+from opentakserver.mumble.mumble_authenticator import ( # noqa: E402
+ MUMBLE_ID_RANGE,
+ MumbleAuthenticator,
+)
+
+
+# --------------------------------------------------------------------------- #
+# _candidate_callsigns -- pure presented-name normalization
+# --------------------------------------------------------------------------- #
+@pytest.mark.parametrize(
+ "username,expected",
+ [
+ # plain PC username -- nothing to strip
+ ("plainuser", ["plainuser"]),
+ # ATAK Vx suffix stripped -> base callsign added
+ (
+ "ANVIL---47c4c853-4e52-4b97-9a0b-08a0f961b0fa",
+ ["ANVIL---47c4c853-4e52-4b97-9a0b-08a0f961b0fa", "ANVIL"],
+ ),
+ # underscores in a Vx callsign map back to spaces
+ ("Some_User---uid", ["Some_User---uid", "Some_User", "Some User"]),
+ # underscore->space also applies without a suffix
+ ("Some_User", ["Some_User", "Some User"]),
+ # stray whitespace around the callsign portion is trimmed (the hardening
+ # borrowed from upstream PR #338) so it still matches TRIM()'d rows
+ (" ANVIL ---uid", [" ANVIL ---uid", "ANVIL"]),
+ # empty suffix still yields the base callsign, no empty candidate
+ ("ANVIL---", ["ANVIL---", "ANVIL"]),
+ ],
+)
+def test_candidate_callsigns(username, expected):
+ assert MumbleAuthenticator._candidate_callsigns(username) == expected
+
+
+def test_candidate_callsigns_has_no_duplicates_or_empties():
+ for username in ("bob", "bob---uid", "a_b---uid", " x ---y", "---"):
+ candidates = MumbleAuthenticator._candidate_callsigns(username)
+ assert all(candidates), f"empty candidate for {username!r}: {candidates}"
+ assert len(candidates) == len(
+ set(candidates)
+ ), f"duplicate candidate for {username!r}: {candidates}"
+
+
+# --------------------------------------------------------------------------- #
+# mumble_identity -- id / display-name mapping
+# --------------------------------------------------------------------------- #
+def test_mumble_identity_pc_user_uses_base_id_and_username():
+ user = SimpleNamespace(id=5, username="bob")
+ mumble_id, display = MumbleAuthenticator.mumble_identity(user, False, "bob")
+ assert mumble_id == 5 * MUMBLE_ID_RANGE
+ assert display == "bob"
+
+
+def test_mumble_identity_callsign_is_deterministic_and_within_user_range():
+ user = SimpleNamespace(id=5, username="bob")
+ base = 5 * MUMBLE_ID_RANGE
+
+ first, display = MumbleAuthenticator.mumble_identity(user, True, "ANVIL")
+ again, _ = MumbleAuthenticator.mumble_identity(user, True, "ANVIL")
+
+ assert display == "ANVIL" # callsign is the display name, not the username
+ assert first == again # deterministic offset for a given callsign
+ assert base < first < base + MUMBLE_ID_RANGE # stays inside the user's block
+
+ # a different callsign lands on a different id (so one account can host
+ # multiple simultaneous device connections)
+ other, _ = MumbleAuthenticator.mumble_identity(user, True, "HAMMER")
+ assert other != first
+
+
+# --------------------------------------------------------------------------- #
+# resolve_identity -- lookup chain wiring (EUD queries mocked out)
+# --------------------------------------------------------------------------- #
+def _fake_eud_module(first_returns):
+ """Fake ``opentakserver.models.EUD`` whose EUD.query.filter().first()
+ yields the given values in order (one per candidate query)."""
+ fake_eud_cls = MagicMock()
+ fake_eud_cls.query.filter.return_value.first.side_effect = list(first_returns)
+ return SimpleNamespace(EUD=fake_eud_cls)
+
+
+def _app(username_lookup=None, id_lookup=None):
+ app = MagicMock()
+
+ def find_user(**kwargs):
+ if "username" in kwargs:
+ return username_lookup
+ if "id" in kwargs:
+ return id_lookup
+ return None
+
+ app.security.datastore.find_user.side_effect = find_user
+ return app
+
+
+def _patched(fake_module):
+ """Replace the inline EUD import and the sqlalchemy ``func`` so the lookup
+ chain runs without a database."""
+ return (
+ patch.dict(sys.modules, {"opentakserver.models.EUD": fake_module}),
+ patch("opentakserver.mumble.mumble_authenticator.func"),
+ )
+
+
+def test_resolve_identity_returns_ots_user_without_callsign_auth():
+ ots_user = SimpleNamespace(id=1, username="bob")
+ app = _app(username_lookup=ots_user)
+ mods, func = _patched(_fake_eud_module([]))
+ with mods, func:
+ user, is_callsign_auth = MumbleAuthenticator.resolve_identity(app, "bob")
+ assert user is ots_user
+ assert is_callsign_auth is False
+
+
+def test_resolve_identity_matches_eud_by_stripped_callsign():
+ eud = SimpleNamespace(user_id=42)
+ resolved = SimpleNamespace(id=42, username="anvil-owner")
+ app = _app(username_lookup=None, id_lookup=resolved)
+ # candidates for "ANVIL---" are [full, "ANVIL"]; miss the full, hit base
+ mods, func = _patched(_fake_eud_module([None, eud]))
+ with mods, func:
+ user, is_callsign_auth = MumbleAuthenticator.resolve_identity(
+ app, "ANVIL---47c4c853-4e52-4b97-9a0b-08a0f961b0fa"
+ )
+ assert user is resolved
+ assert is_callsign_auth is True
+
+
+def test_resolve_identity_returns_none_when_nothing_matches():
+ app = _app(username_lookup=None, id_lookup=None)
+ mods, func = _patched(_fake_eud_module([None, None, None]))
+ with mods, func:
+ user, is_callsign_auth = MumbleAuthenticator.resolve_identity(app, "ghost", certlist=None)
+ assert user is None
+ assert is_callsign_auth is False