Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
29.55% covered (danger)
29.55%
39 / 132
14.29% covered (danger)
14.29%
1 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Teamspeak3
29.55% covered (danger)
29.55%
39 / 132
14.29% covered (danger)
14.29%
1 / 7
628.89
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 query
0.00% covered (danger)
0.00%
0 / 74
0.00% covered (danger)
0.00%
0 / 1
420
 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
 query_server
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
6
 parseProperties
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
6.01
 parseClientList
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
10
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 explode;
16use function fclose;
17use function fgets;
18use function fsockopen;
19use function fwrite;
20use function is_array;
21use function preg_split;
22use function sprintf;
23use function str_contains;
24use function str_replace;
25use function str_starts_with;
26use function stream_set_timeout;
27use function trim;
28use Clansuite\Capture\Protocol\ProtocolInterface;
29use Clansuite\Capture\ServerAddress;
30use Clansuite\Capture\ServerInfo;
31use Clansuite\ServerQuery\CSQuery;
32use Override;
33
34/**
35 * Queries TeamSpeak 3 voice communication servers.
36 *
37 * Retrieves server information including channels, clients, server settings, and permissions
38 * by connecting to the query port and parsing the text-based response protocol.
39 * Enables monitoring of TeamSpeak 3 server status and user activity.
40 */
41class Teamspeak3 extends CSQuery implements ProtocolInterface
42{
43    public string $name = 'Teamspeak 3';
44
45    /** @var array<string> */
46    public array $supportedGames = ['Teamspeak 3'];
47    public string $protocol      = 'teamspeak3';
48
49    /**
50     * Client (voice) port to select virtual server (default 9987).
51     */
52    public int $clientPort = 9987;
53
54    /**
55     * Constructor.
56     *
57     * Initializes the TeamSpeak 3 query instance.
58     *
59     * @param null|string $address   The server address to query
60     * @param null|int    $queryport The query port for the TeamSpeak 3 server (default 10011)
61     */
62    public function __construct(?string $address = null, ?int $queryport = null)
63    {
64        parent::__construct();
65        $this->address   = $address;
66        $this->queryport = $queryport ?? 10011;
67        // default client (voice) port to select the virtual server
68        $this->clientPort = 9987;
69    }
70
71    /**
72     * Performs a query on the specified TeamSpeak 3 server address.
73     *
74     * Connects to the query port, retrieves server information, and returns a ServerInfo object
75     * containing the query results including channels and clients.
76     *
77     * @param ServerAddress $addr The server address and query port to connect to
78     *
79     * @return ServerInfo Server information including status, channels, and clients
80     */
81    #[Override]
82    public function query(ServerAddress $addr): ServerInfo
83    {
84        $info         = new ServerInfo;
85        $info->online = false;
86
87        $port = $addr->port !== 0 ? $addr->port : 10011;
88
89        $this->debug[] = sprintf('-> connect %s:%d', $addr->ip, $port);
90
91        $host   = $addr->ip;
92        $errno  = 0;
93        $errstr = '';
94        // Call fsockopen with reference params
95        $fp = @fsockopen($host, $port, $errno, $errstr);
96
97        if ($fp === false) {
98            // normalize error display values
99            $errstr_display = $errstr !== '' && $errstr !== '0' ? $errstr : 'unknown';
100            $errno_display  = $errno !== 0 ? $errno : 0;
101            $this->errstr   = sprintf('connect failed: %s (%d)', $errstr_display, $errno_display);
102            $this->debug[]  = sprintf('<- connect failed: %s (%d)', $errstr_display, $errno_display);
103
104            return $info;
105        }
106
107        // ensure we don't block forever
108        stream_set_timeout($fp, 5);
109        $banner = fgets($fp, 4096);
110
111        if ($banner === false) {
112            $banner = '';
113        }
114        $this->debug[] = '<- banner: ' . trim($banner);
115
116        // select virtual server by client port first
117        $this->debug[] = sprintf('-> use port=%d', $this->clientPort);
118        fwrite($fp, sprintf("use port=%d\n", $this->clientPort));
119        $useOk = false;
120
121        while (($line = fgets($fp, 4096)) !== false) {
122            $line          = trim($line);
123            $this->debug[] = '<- ' . $line;
124
125            if ($line === 'error id=0 msg=ok') {
126                $useOk = true;
127
128                break;
129            }
130
131            if (str_starts_with($line, 'error id=')) {
132                // permission or server ID errors
133                if (str_contains($line, 'insufficient')) {
134                    $this->errstr = 'insufficient client permissions for query';
135                } elseif (str_contains($line, 'invalid')) {
136                    $this->errstr = 'invalid server id / no selected virtual server';
137                } else {
138                    $this->errstr = $line;
139                }
140                // stop early
141                fclose($fp);
142                $this->debug[] = '-> close';
143
144                return $info;
145            }
146        }
147
148        // send serverinfo
149        $this->debug[] = '-> serverinfo';
150        fwrite($fp, "serverinfo\n");
151        $details = '';
152
153        while (($line = fgets($fp, 4096)) !== false) {
154            $line          = trim($line);
155            $this->debug[] = '<- ' . $line;
156
157            if ($line === 'error id=0 msg=ok') {
158                break;
159            }
160            $details .= $line . "\n";
161        }
162
163        // send clientlist
164        $this->debug[] = '-> clientlist';
165        fwrite($fp, "clientlist\n");
166        $clientsRaw = '';
167
168        while (($line = fgets($fp, 4096)) !== false) {
169            $line          = trim($line);
170            $this->debug[] = '<- ' . $line;
171
172            if ($line === 'error id=0 msg=ok') {
173                break;
174            }
175            $clientsRaw .= $line . "\n";
176        }
177
178        fclose($fp);
179        $this->debug[] = '-> close';
180
181        // Parse details (space separated key=value)
182        $props = $this->parseProperties($details);
183
184        $info->online      = true;
185        $info->servertitle = (string) ($props['virtualserver_name'] ?? '');
186        $info->gameversion = (string) ($props['virtualserver_version'] ?? '');
187        $info->numplayers  = isset($props['virtualserver_clientsonline']) ? (int) $props['virtualserver_clientsonline'] - (int) ($props['virtualserver_queryclientsonline'] ?? 0) : 0;
188        $info->maxplayers  = isset($props['virtualserver_maxclients']) ? (int) $props['virtualserver_maxclients'] : 0;
189        $info->mapname     = '';
190
191        // Parse clients
192        $info->players = [];
193        $clients       = $this->parseClientList($clientsRaw);
194
195        foreach ($clients as $client) {
196            if (is_array($client)) {
197                $info->players[] = [
198                    'name' => (string) ($client['client_nickname'] ?? ''),
199                    'id'   => (string) ($client['clid'] ?? ''),
200                    'team' => (string) ($client['cid'] ?? ''),
201                ];
202            }
203        }
204
205        return $info;
206    }
207
208    /**
209     * Returns the protocol name for TeamSpeak 3.
210     *
211     * @return string The protocol identifier 'teamspeak3'
212     */
213    #[Override]
214    public function getProtocolName(): string
215    {
216        return $this->protocol;
217    }
218
219    /**
220     * Extracts the TeamSpeak 3 server version from server information.
221     *
222     * @param ServerInfo $info The server information object
223     *
224     * @return string The server version string, or 'unknown' if not available
225     */
226    #[Override]
227    public function getVersion(ServerInfo $info): string
228    {
229        return $info->gameversion ?? 'unknown';
230    }
231
232    /**
233     * Queries the TeamSpeak 3 server and populates server information.
234     *
235     * Uses the query method to retrieve server data and updates internal state
236     * with server details, channels, and client information.
237     *
238     * @param bool $getPlayers Whether to retrieve client information
239     * @param bool $getRules   Whether to retrieve server rules/settings
240     *
241     * @return bool True on successful query, false on failure
242     */
243    #[Override]
244    public function query_server(bool $getPlayers = true, bool $getRules = true): bool
245    {
246        $addr = new ServerAddress($this->address ?? '', $this->queryport ?? 10011);
247        $info = $this->query($addr);
248
249        $this->online      = $info->online;
250        $this->servertitle = $info->servertitle ?? '';
251        $this->mapname     = $info->mapname ?? '';
252        $this->numplayers  = $info->numplayers;
253        $this->maxplayers  = $info->maxplayers;
254        $this->players     = [];
255
256        foreach ($info->players as $p) {
257            $this->players[] = [
258                'name' => $p['name'] ?? '',
259                'id'   => $p['id'] ?? '',
260                'team' => $p['team'] ?? '',
261            ];
262        }
263
264        return $this->online;
265    }
266
267    /**
268     * Parses a TeamSpeak 3 server info line into key-value pairs.
269     *
270     * @return array<mixed>
271     */
272    private function parseProperties(string $data): array
273    {
274        $props = [];
275
276        $split = preg_split('/\n/', trim($data));
277        $lines = $split !== false ? $split : [];
278
279        foreach ($lines as $line) {
280            $items = preg_split('/\s+/', trim($line));
281            $items = $items !== false ? $items : [];
282
283            foreach ($items as $item) {
284                if ($item === '') {
285                    continue;
286                }
287                $kv = explode('=', $item, 2);
288                $k  = $kv[0] ?? '';
289                $v  = $kv[1] ?? '';
290                // unescape TeamSpeak ServerQuery escaped sequences
291                // \s -> space, \p -> pipe, \/ -> /, \\ -> \\, \n -> newline
292                $v         = str_replace(['\\s', '\\p', '\\/', '\\\\', '\\n'], [' ', '|', '/', '\\', "\n"], $v);
293                $props[$k] = $v;
294            }
295        }
296
297        return $props;
298    }
299
300    /**
301     * @return array<mixed>
302     */
303    private function parseClientList(string $data): array
304    {
305        $out = [];
306
307        $split = preg_split('/\n/', trim($data));
308        $lines = $split !== false ? $split : [];
309
310        foreach ($lines as $line) {
311            $split2 = preg_split('/\|/', trim($line));
312            $items  = $split2 !== false ? $split2 : [];
313
314            foreach ($items as $item) {
315                if ($item === '') {
316                    continue;
317                }
318                $pairs = preg_split('/\s+/', trim($item));
319                $pairs = $pairs !== false ? $pairs : [];
320                $props = [];
321
322                foreach ($pairs as $pair) {
323                    $kv = explode('=', $pair, 2);
324                    $k  = $kv[0] ?? '';
325                    $v  = $kv[1] ?? '';
326                    // same unescaping for client values
327                    $v = str_replace(['\\s', '\\p', '\\/', '\\\\', '\\n'], [' ', '|', '/', '\\', "\n"], $v);
328
329                    if ($k !== '') {
330                        $props[$k] = $v;
331                    }
332                }
333
334                if ($props !== []) {
335                    $out[] = $props;
336                }
337            }
338        }
339
340        return $out;
341    }
342}