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:
13: namespace Clansuite\ServerQuery\ServerProtocols;
14:
15: use function array_values;
16: use function count;
17: use function fclose;
18: use function feof;
19: use function fgets;
20: use function fsockopen;
21: use function fwrite;
22: use function is_array;
23: use function json_decode;
24: use function sprintf;
25: use function stream_set_timeout;
26: use Clansuite\Capture\Protocol\ProtocolInterface;
27: use Clansuite\Capture\ServerAddress;
28: use Clansuite\Capture\ServerInfo;
29: use Clansuite\ServerQuery\CSQuery;
30: use 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: */
36: class 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: }
260: