Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
4.26% covered (danger)
4.26%
4 / 94
0.00% covered (danger)
0.00%
0 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
Mumble
4.26% covered (danger)
4.26%
4 / 94
0.00% covered (danger)
0.00%
0 / 5
1238.57
0.00% covered (danger)
0.00%
0 / 1
 __construct
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 query
0.00% covered (danger)
0.00%
0 / 79
0.00% covered (danger)
0.00%
0 / 1
992
 query_server
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 getProtocolName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getVersion
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php declare(strict_types=1);
2
3/**
4 * Clansuite Server Query
5 *
6 * SPDX-FileCopyrightText: 2003-2025 Jens A. Koch
7 * SPDX-License-Identifier: MIT
8 *
9 * For the full copyright and license information, please view
10 * the LICENSE file that was distributed with this source code.
11 */
12
13namespace Clansuite\ServerQuery\ServerProtocols;
14
15use function array_values;
16use function count;
17use function fclose;
18use function feof;
19use function fgets;
20use function fsockopen;
21use function fwrite;
22use function is_array;
23use function json_decode;
24use function sprintf;
25use function stream_set_timeout;
26use Clansuite\Capture\Protocol\ProtocolInterface;
27use Clansuite\Capture\ServerAddress;
28use Clansuite\Capture\ServerInfo;
29use Clansuite\ServerQuery\CSQuery;
30use Override;
31
32/**
33 * Implements the query protocol for Mumble voice communication servers.
34 * Retrieves server information, user lists, channel details, and connection statistics.
35 */
36class Mumble extends CSQuery implements ProtocolInterface
37{
38    private const PORT_DIFF      = -36938; // clientPort + PORT_DIFF = query port (64738 + -36938 = 27800)
39    public string $name          = 'Mumble';
40    public array $supportedGames = ['Mumble'];
41    public string $protocol      = 'mumble';
42
43    /**
44     * Default client (voice) port for Mumble is 64738.
45     */
46    public int $clientPort = 64738;
47
48    /**
49     * Channel list parsed from Murmur JSON.
50     *
51     * @var array<mixed>
52     */
53    public array $channels = [];
54
55    /**
56     * Constructor.
57     */
58    public function __construct(?string $address = null, ?int $queryport = null)
59    {
60        parent::__construct($address, $queryport);
61
62        // Ensure address is a string (CSQuery::$address is non-nullable)
63        if ($address !== null) {
64            $this->address = $address;
65        }
66
67        // Default query port for Murmur is 27800 when none provided
68        if ($queryport === null) {
69            $this->queryport = 27800;
70        }
71    }
72
73    /**
74     * query method.
75     */
76    #[Override]
77    public function query(ServerAddress $addr): ServerInfo
78    {
79        $info         = new ServerInfo;
80        $info->online = false;
81
82        // Determine the query port. If caller supplied a client port (e.g. 64738), map to query port
83        if ($addr->port !== 0) {
84            if ($addr->port >= 60000) {
85                // assume this is the client port; map to query port
86                $queryPort = $addr->port + self::PORT_DIFF;
87            } else {
88                $queryPort = $addr->port;
89            }
90        } else {
91            // fallback to configured queryport or default 27800
92            $queryPort = $this->queryport ?? 27800;
93        }
94
95        // try a TCP connect to the query port; Murmur responds with a JSON payload to the 'json' packet
96        $fp = @fsockopen($addr->ip, $queryPort, $errno, $errstr, 5);
97
98        if ($fp === false) {
99            $this->errstr = sprintf('connect failed: %s (%d)', $errstr !== '' && $errstr !== '0' ? $errstr : 'unknown', $errno !== 0 ? $errno : 0);
100
101            return $info;
102        }
103
104        stream_set_timeout($fp, 4);
105
106        // Send the 'json' packet (4 ASCII bytes) to request JSON status
107        @fwrite($fp, 'json');
108
109        // Read entire response
110        $buffer = '';
111
112        while (!feof($fp)) {
113            $chunk = @fgets($fp, 8192);
114
115            if ($chunk === false) {
116                break;
117            }
118            $buffer .= $chunk;
119        }
120
121        fclose($fp);
122
123        if ($buffer === '') {
124            $this->errstr = 'no response from murmur query port';
125
126            return $info;
127        }
128
129        $data = @json_decode($buffer, true);
130
131        if (!is_array($data)) {
132            $this->errstr = 'unable to decode murmur JSON response';
133
134            return $info;
135        }
136
137        // Determine server title from common keys
138        $this->servertitle = (string) ($data['name'] ?? $data['hostname'] ?? $data['x_connecturl'] ?? ($addr->ip . ':' . $queryPort));
139
140        // Extract players and channels
141        $channels = [];
142        $players  = [];
143
144        $extract = static function (array &$node, ?int $parentId = null) use (&$extract, &$channels, &$players): void
145        {
146            // If node contains 'id' and 'name' treat it as a channel
147            if (isset($node['id'], $node['name'])) {
148                $cid            = $node['id'];
149                $channels[$cid] = [
150                    'id'     => $cid,
151                    'name'   => $node['name'] ?? 'unknown',
152                    'parent' => $node['parent'] ?? $parentId,
153                ];
154
155                // collect any users in this channel
156                if (is_array($node['users'] ?? null) && $node['users'] !== []) {
157                    foreach ($node['users'] as $user) {
158                        // user might be keyed by session id
159                        if (is_array($user)) {
160                            $pname     = $user['name'] ?? $user['username'] ?? null;
161                            $pid       = $user['userid'] ?? $user['session'] ?? null;
162                            $players[] = [
163                                'name'    => $pname ?? 'unknown',
164                                'id'      => $pid,
165                                'channel' => $cid,
166                            ];
167                        }
168                    }
169                }
170
171                // recurse channels
172                if (is_array($node['channels'] ?? null) && $node['channels'] !== []) {
173                    foreach ($node['channels'] as $child) {
174                        if (is_array($child)) {
175                            $extract($child, $cid);
176                        }
177                    }
178                }
179            } else {
180                // if node is an associative list of channels
181                foreach ($node as $v) {
182                    if (is_array($v) && (isset($v['id']) || isset($v['name']) || isset($v['users']))) {
183                        $extract($v, $parentId);
184                    }
185                }
186            }
187        };
188
189        // Murmur usually encloses channels/users under 'root'
190        if (isset($data['root'])) {
191            $extract($data['root']);
192        } else {
193            $extract($data);
194        }
195
196        // Fallback: some providers include players at top-level 'users'
197        if ($players === [] && isset($data['users']) && is_array($data['users'])) {
198            foreach ($data['users'] as $u) {
199                $players[] = [
200                    'name'    => $u['name'] ?? 'unknown',
201                    'id'      => $u['userid'] ?? $u['session'] ?? null,
202                    'channel' => $u['channel'] ?? 0,
203                ];
204            }
205        }
206
207        // set info
208        $this->numplayers = count($players);
209        $this->maxplayers = isset($data['x_gtmurmur_max_users']) ? (int) $data['x_gtmurmur_max_users'] : ($this->maxplayers ?? 0);
210        $this->players    = $players;
211        $this->channels   = array_values($channels);
212        $this->online     = true;
213
214        $info->online      = true;
215        $info->servertitle = $this->servertitle;
216        $info->numplayers  = $this->numplayers;
217        $info->maxplayers  = $this->maxplayers;
218        $info->players     = $this->players;
219        $info->channels    = $this->channels;
220
221        return $info;
222    }
223
224    /**
225     * query_server method.
226     */
227    #[Override]
228    public function query_server(bool $getPlayers = true, bool $getRules = true): bool
229    {
230        $addr = new ServerAddress($this->address ?? '', $this->queryport ?? $this->clientPort);
231        $info = $this->query($addr);
232
233        $this->online      = $info->online;
234        $this->servertitle = $info->servertitle ?? '';
235        $this->numplayers  = $info->numplayers ?? 0;
236        $this->maxplayers  = $info->maxplayers ?? 0;
237        $this->players     = $info->players ?? [];
238
239        return $this->online;
240    }
241
242    /**
243     * getProtocolName method.
244     */
245    #[Override]
246    public function getProtocolName(): string
247    {
248        return $this->protocol;
249    }
250
251    /**
252     * getVersion method.
253     */
254    #[Override]
255    public function getVersion(ServerInfo $info): string
256    {
257        return $info->gameversion ?? 'unknown';
258    }
259}