1<?php date_default_timezone_set("Europe/Warsaw"); // change to match (not used for anything YET, but I keep forgetting to put it in, so... :D)
2define("EXIT_AFTERWARDS", true);
3
4if ( "cli" != php_sapi_name() )
5 die("This tool is intended to be used from CLI");
6
7// 2023-01-01
8// Boy, did THIS grow out of control! ...
9// But yeah, it's now more feature-complete
10
11# TODO: DTLS ;)
12
13register_shutdown_function(function(){
14 // this is just a function to auto-close the socket (which isn't necessary, strictly speaking...)
15 // and to show errors (if any) if the socket hasn't been destroyed yet
16 global $socket;
17 if ( is_object($socket) ){
18 $err = socket_last_error($socket);
19 error("{$err} - ".socket_strerror($err)."\n");
20 }
21});
22
23function error($msg){
24 // just something to print errors out, so I have better control over it
25 static $errh = null; if ( $errh == null ) $errh = fopen("php://stderr", "w");
26 fwrite($errh, $msg);
27}
28
29function msg($msg, $isJSON = false){
30 // just something to print normal messages out, so I have better control over it
31 global $replyJSON; if ( $replyJSON && !$isJSON ) return;
32 echo $msg;
33}
34
35function socket_reconnect(&$socket, string $addr, int $port){
36 // disconnect the existing socket (if any) and (re)connect to a new address/port
37 if ( is_object($socket) ){
38 socket_close($socket);
39 $socket = null;
40 }
41
42 $socket = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
43 if ( $socket === null )
44 die("ERR: failed to (re)create the socket; issue: ");
45
46 socket_connect($socket, $addr, $port);
47}
48
49function resortResultArray(array &$data){
50 // sorting function, so that the values I want on top of the list will be on top/in a consistent order
51 $newArray = [];
52
53 // these I want ordered
54 foreach(['Success', 'Hostname', 'Address', 'ServerVersion', 'ServerState', 'InternalRouter', 'QueryPort', 'BeaconPort', 'GamePort'] as $value)
55 if ( isset($data[$value]) )
56 $newArray[$value] = $data[$value];
57
58 // any other stuff that's in there - I don't care for the order of
59 foreach($data as $name => $value)
60 if ( !isset($newArray[$name]) )
61 $newArray[$name] = $value;
62
63 $data = $newArray;
64}
65
66function socket_test_port(socket &$socket, int $portNumber, string $portType){
67 // this is my BCON/GAME testing function... it's not pretty and combines a lot of stuff,
68 // because it's essentially cut & pasted here with a change or two; needs a lot of love,
69 // but it works as-is, and I have bigger fish to fry (hello DTLS)
70
71 // do we even recognise the test param?
72 $portType = strtoupper($portType);
73 if ( $portType != "BCON" && $portType != "GAME" ){
74 socket_close($socket); $socket = null;
75 die("ERR: socket_test_port(): unrecognised port type '{$portType}' passed (use only BCON or GAME).\n");
76 }
77
78 // okay, I can test! first, let's write the message out to the socket...
79 msg("Testing {$portType} ({$portNumber}) port reachability..");
80 $buffer = $portType . " ";
81 $result = socket_send($socket, $buffer, strlen($buffer), 0);
82 $timeout = TIMEOUT * 2;
83 do {
84 $reads = [$socket];
85 $timeout--;
86 if ( $timeout <= 0 ){
87 msg(" TIMEOUT\n");
88 error("FAILED to talk to the {$portType} ({$portNumber}) port in " . TIMEOUT . " seconds, aborting.\n");
89 return false;
90 }
91 msg(".");
92 } while ( socket_select($reads, $__ignore1, $__ignore2, 0, 500000) < 1 );
93
94 // ... and then wait for the reply
95 $timeout = TIMEOUT;
96 while ( socket_select($reads, $__ignore1, $__ignore2, 0, 500000) >= 1 ) {
97 $reads = [$socket];
98 @socket_recv($socket, $readData, 1024, null);
99 if ( $readData !== null && strlen($readData) >= 5 ){
100 // we have our reply
101 break;
102 }
103 $timeout--;
104 if ( $timeout <= 0 ){
105 msg(" TIMEOUT\n");
106 error("FAILED to read the reply in " . TIMEOUT . " seconds, aborting.\n");
107 return false;
108 }
109 msg(".");
110 }
111
112 // we only care for the first 4 bytes of the reply to check if they are the same
113 // as we sent out (that's the condition for doing BASIC validation of the port)
114 if ( substr($readData, 0, 4) != $portType){
115 msg("ERR: unknown reply from the {$portType} ({$portNumber}) port, aborting.\n");
116 return false;
117 }
118 msg(" OK\n");
119 return true;
120}
121
122function reply_JSON($data, $kill = false){
123 // simple JSON reply + self-termination on the condition
124
125 $data['TimeoutSeconds'] = TIMEOUT;
126 resortResultArray($data);
127 msg(json_encode($data), true);
128
129 if ( $kill == EXIT_AFTERWARDS ){
130 global $socket;
131 socket_close($socket);
132 $socket = null;
133 die(1);
134 }
135}
136
137// default values
138$serverAddress = "127.0.0.1";
139$serverName = "localhost";
140$serverPort = "15777";
141$replyJSON = false;
142
143define("TIMEOUT", 10); // in seconds
144#####################################################
145// Use:
146// php sf-query.php <PORT> <IP> <reply with JSON 1/0>
147#####################################################
148
149if ( isset($argv[1]) )
150 $serverPort = $argv[1];
151
152if ( isset($argv[2]) )
153 $serverAddress = $argv[2];
154
155if ( isset($argv[3]) && $argv[3] == 1 )
156 $replyJSON = true;
157
158$serverData = ['Success' => false,
159 'QueryPort' => (int) $serverPort, 'BeaconPort' => 0, 'GamePort' => 0,
160 'ServerVersion' => 0, 'ServerState' => "Not tested",
161 'Address' => 0];
162if ( filter_var($serverAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 | FILTER_FLAG_IPV4) ){
163 // IP address, we're good
164 $serverData['Hostname'] = "(unknown)";
165 $serverData['Address'] = $serverAddress;
166} else if ( preg_match("/^[^\.]+\.[^\.]{2,}.*$/", $serverAddress) || $serverAddress == "localhost" ) {
167 // potential hostname?
168 $serverData['Hostname'] = $serverAddress;
169 $serverAddress = gethostbyname($serverData['Hostname']);
170 if ( $serverAddress == $serverData['Hostname'] ){
171 $serverData['Address'] = "Hostname resolution failed";
172 if ( $replyJSON ) reply_JSON($serverData, EXIT_AFTERWARDS);
173 error("ERR: hostname '{$serverName}' failed to resolve\n");
174 die();
175 }
176 $serverData['Address'] = $serverAddress;
177} else {
178 // nope, not recognising any of this, abort
179 if ( $replyJSON ) reply_JSON($serverData, EXIT_AFTERWARDS);
180 error("ERR: provided address '{$serverAddress}' not understood.\n");
181 die();
182}
183
184if ( !$replyJSON ){
185 msg("Using server address: " . $serverData['Address'] . ($serverData['Hostname'] != "(unknown)" ? " ({$serverName})" : "") ."\n");
186 msg("Comm timeout is " . TIMEOUT ." seconds\n");
187}
188
189// NOTE: we're IMPLICITLY creating the $socket variable here!
190socket_reconnect($socket, $serverData['Address'], $serverData['QueryPort']);
191
192// send the query message (essentially - 10 bytes, all 0)
193$result = @socket_send($socket, str_pad("", 10, chr(0)), 10, 0);
194
195// did we succeed in sending?
196// (yeah, NOW it creates the connection state, warts and all)
197if ( false === $result ){
198 $serverData['QueryPort'] = 0;
199 if ( $replyJSON ) reply_JSON($serverData, EXIT_AFTERWARDS);
200 die("Failed to create socket: "); // abort
201}
202
203msg("Querying {$serverAddress}:{$serverPort}..");
204// wait patiently for our reply packet...
205$timeout = TIMEOUT * 2;
206do {
207 $reads = [$socket];
208 $timeout--;
209 if ( $timeout <= 0 ){
210 msg(" TIMEOUT\n");
211 msg("FAILED to reach server in " . TIMEOUT . " seconds, aborting.\n");
212 if ( $replyJSON ) reply_JSON($serverData, EXIT_AFTERWARDS);
213 socket_close($socket); $socket = null; die();
214 }
215 msg(".");
216} while ( socket_select($reads, $__ignore1, $__ignore2, 0, 500000) < 1 );
217
218// something's back! read
219$readLength = @socket_recv($socket, $readData, 1024, 0);
220
221// check if we read anything at all?
222if ( $readLength === false ){
223 if ( socket_last_error($socket) == 10054 ){
224 $serverData['ServerState'] = "No reply";
225 if ( $replyJSON ) reply_JSON($serverData, EXIT_AFTERWARDS);
226 socket_close($socket); $socket = null;
227 die(" ERR: connection failed; the server is likely not running.\n");
228 }
229 if ( $replyJSON ) reply_JSON($serverData, EXIT_AFTERWARDS);
230 die(" ERR: failed to read from socket: "); // abort
231}
232
233msg(" OK\n");
234
235// check if we read exactly 17 bytes (that's how long the response should be)?
236if ( $readLength != 17 ){
237 $serverData['ServerState'] = "Host up";
238 $serverData['ProtocolVersion'] = "(unknown)";
239 if ( $replyJSON ) reply_JSON($serverData, EXIT_AFTERWARDS);
240 die(" ERR: failed to read exactly 17 bytes, read {$readLength} bytes instead\n"); // abort
241}
242
243$readData = unpack("CID/CProtocolVersion/QIgnore/CServerState/VServerVersion/vBeaconPort", $readData);
244
245// check that the message's ID is now 1?
246if ( $readData['ID'] != 1 ){
247 $serverData['ServerState'] = "Host up";
248 $serverData['ProtocolVersion'] = "(unknown)";
249 if ( $replyJSON ) reply_JSON($serverData, EXIT_AFTERWARDS);
250 msg(" ERR: reply ID != 1\n");
251 socket_close($socket); $socket = null; die(); // graceful shutdown
252}
253
254// check if protocol version is 0?
255if ( $readData['ProtocolVersion'] != 0 ){
256 $serverData['ServerState'] = "Host up";
257 $serverData['ProtocolVersion'] = "(unknown)";
258 if ( $replyJSON ) reply_JSON($serverData, EXIT_AFTERWARDS);
259 msg(" ERR: unknown protocol version {$readData['ProtocolVersion']} != 0\n");
260 socket_close($socket); $socket = null; die(); // graceful shutdown
261}
262
263// copy the values we want from the reply
264foreach(['ServerState', 'ServerVersion', 'BeaconPort'] as $valueName)
265 $serverData[$valueName] = $readData[$valueName];
266
267// humanize server state
268switch($serverData['ServerState']){
269 case 1:
270 $serverData['ServerState'] = "Idle";
271 break;
272
273 case 2:
274 $serverData['ServerState'] = "Loading";
275 break;
276
277 case 3:
278 $serverData['ServerState'] = "Playing";
279 break;
280
281 default:
282 $serverData['ServerState'] = "Unrecognised ({$readData['ServerState']})";
283 if ( $replyJSON ) reply_JSON($serverData, EXIT_AFTERWARDS);
284 msg(" ERR: unrecognised server state\n");
285 socket_close($socket); $socket = null; die(); // graceful shutdown
286 break;
287}
288
289if ( !$replyJSON ){
290 msg("Server version: {$serverData['ServerVersion']}, state: {$serverData['ServerState']}\n");
291}
292
293$serverData['QueryPort'] = (int) $serverPort;
294$serverData['GamePort'] = 0;
295$serverData['InternalRouter'] = false;
296if ( $serverData['BeaconPort'] == $serverPort ){
297 msg("Built-in router in use: YES.\n");
298 $serverData['InternalRouter'] = true;
299 $serverData['GamePort'] = (int) $serverPort;
300} else {
301 msg("Built-in router in use: NO, separate port connections required.\n");
302}
303
304/* ******************************************************************************************* */
305// Check BCON
306socket_reconnect($socket, $serverAddress, $serverData['BeaconPort']);
307if ( !socket_test_port($socket, $serverData['BeaconPort'], "BCON") ){
308 if ( $replyJSON ) reply_JSON($serverData, EXIT_AFTERWARDS);
309 socket_close($socket); $socket = null; die();
310}
311
312/* ******************************************************************************************* */
313// Check GAME
314if ( $serverData['InternalRouter'] == false && $serverData['GamePort'] == 0 ){
315 // to find out what the GAME port is without the internal router running, we
316 // need to actually DTLS with BCON to receive the GAME port... and it seems
317 // there's no DTLS functionality available to us yet; abort testing the GAME port
318
319 // what I *can* do is assume, and test
320 if ( $serverData['QueryPort'] == 15777 && $serverData['BeaconPort'] == 15000 ){
321 // assume GamePort is 7777?
322 socket_reconnect($socket, $serverAddress, 7777);
323 if ( socket_test_port($socket, 7777, "GAME") ){
324 msg(" Be advised: port 7777 is a GUESS; it's possible that this is NOT the GAME port in use.\n");
325 } else {
326 msg(" It's possible that I guessed wrong?\n");
327 }
328 } else {
329 msg("\n Built-in router not in use, no DTLS implementation available,\n and the setup does not follow default ports. Cannot test the\n GAME port as we don't know it and can't ask for it.\n");
330 }
331
332 // since we can't test GAME, we can just go ahead and assume the server is OK from this alone
333 $serverData['Success'] = true;
334} else {
335 // internal router is running (so we can assume identical port) OR we have DTLS
336 // and have received the GAME port from BCON... either way, reconnect to GAME
337 // and test if it replies correctly
338 socket_reconnect($socket, $serverAddress, $serverData['GamePort']);
339 if ( !socket_test_port($socket, $serverData['GamePort'], "GAME") ){
340 if ( $replyJSON ) reply_JSON($serverData, EXIT_AFTERWARDS);
341 socket_close($socket); $socket = null; die();
342 }
343
344 // looks like the server is truly reachable
345 $serverData['Success'] = true;
346}
347
348/* ******************************************************************************************* */
349// Display summary
350
351// reply with JSON or just print the values back to the user
352if ( $replyJSON ){
353 reply_JSON($serverData);
354} else {
355 resortResultArray($serverData);
356 unset($serverData['Success']);
357 $serverData['InternalRouter'] = ($serverData['InternalRouter'] ? "YES, single-port communication" : "NO, separate-port connections required");
358 if ( $serverData['GamePort'] == 0 )
359 $serverData['GamePort'] = "(unknown; no DTLS support available)";
360 msg("\nTEST SUMMARY:\n");
361 foreach($serverData as $field => $value)
362 msg(str_pad("$field:", 18, " ")." {$value}\n");
363}
364
365// that's all he wrote ;)
366
367socket_close($socket); $socket = null; die(); // yes, still a graceful shutdown ;)
368
369