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 explode;
16: use function fclose;
17: use function fgets;
18: use function fsockopen;
19: use function fwrite;
20: use function is_array;
21: use function preg_split;
22: use function sprintf;
23: use function str_contains;
24: use function str_replace;
25: use function str_starts_with;
26: use function stream_set_timeout;
27: use function trim;
28: use Clansuite\Capture\Protocol\ProtocolInterface;
29: use Clansuite\Capture\ServerAddress;
30: use Clansuite\Capture\ServerInfo;
31: use Clansuite\ServerQuery\CSQuery;
32: use 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: */
41: class 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: }
343: