diff --git a/src/Civ13/GameServer.php b/src/Civ13/GameServer.php index c28686a4..a83def36 100644 --- a/src/Civ13/GameServer.php +++ b/src/Civ13/GameServer.php @@ -1899,8 +1899,23 @@ private function heartbeatEmbed(array $status): Embed default => 'Unknown', }) ->addFieldValues('Observation', $status['reason'].($status['age'] === null ? '' : ' ('.(int) $status['age'].'s since last observed progress/baseline)'), false) - ->addFieldValues('Connectivity', 'Unverified', true) - ->addFieldValues('Host', $this->host, true); + ->addFieldValues('Host', $this->displayHost(), true); + } + + /** Read only this locally configured server's host file; do not infer a fallback. */ + private function displayHost(): string + { + $base = str_replace('\\', '/', $this->basedir); + if ($this->ip !== ($this->civ13->httpServiceManager->httpHandler->external_ip ?? null) + || $base === '' || str_starts_with($base, '//') || str_contains($base, '://')) { + return 'Unavailable'; + } + $path = rtrim($this->basedir, '/\\').'/config/host.txt'; + if (! @is_file($path) || ($value = @file_get_contents($path, false, null, 0, 1025)) === false + || strlen($value) > 1024 || preg_match('//u', $value) !== 1) { + return 'Unavailable'; + } + return trim($value) === '' ? 'Unavailable' : trim($value); } /** * Explodes the server data string into an array by removing specific substrings and splitting by semicolon. @@ -1968,7 +1983,7 @@ public function toEmbed(): Embed return $this->civ13->createEmbed() ->setTitle($this->name) ->addFieldValues($this->engine === 'opendream' ? 'OpenDream gameplay endpoint' : 'Server URL', $this->engine === 'opendream' ? "{$this->ip}:{$this->port}" : "byond://{$this->ip}:{$this->port}", false) - ->addFieldValues('Host', $this->host, true) + ->addFieldValues('Host', $this->displayHost(), true) ->addFieldValues('Health', 'Unknown (no local heartbeat evidence)', true) ->addFieldValues('Players (cached, unverified)', empty($this->players) ? 'N/A' : implode(', ', $this->players), true); } diff --git a/tests/local/heartbeat-integration.php b/tests/local/heartbeat-integration.php index f338df49..ca66557f 100644 --- a/tests/local/heartbeat-integration.php +++ b/tests/local/heartbeat-integration.php @@ -5,6 +5,15 @@ namespace Civ13 { function fsockopen(...$arguments): never { throw new \RuntimeException('Status attempted a network probe'); } function touch(...$arguments): never { throw new \RuntimeException('Status attempted to touch game data'); } + function file_get_contents($filename, ...$arguments): string|false { + if (($GLOBALS['host_read_forbidden'] ?? null) === $filename) { throw new \RuntimeException('Remote host file was read'); } + if (($GLOBALS['host_read_failure'] ?? null) === $filename) { return false; } + return \file_get_contents($filename, ...$arguments); + } + function is_file($filename): bool { + if (str_starts_with(str_replace('\\', '/', $filename), '//')) { throw new \RuntimeException('UNC path was probed'); } + return \is_file($filename); + } } namespace { @@ -79,9 +88,40 @@ function fields(Discord\Parts\Embed\Embed $embed): array { $clock = static function () use (&$now): float { return $now; }; try { $path = $root.'/serverdata.txt'; + mkdir($root.'/config'); + $hostPath = $root.'/config/host.txt'; + file_put_contents($hostPath, " AlphaHost \r\n"); + mkdir($root.'/other'); mkdir($root.'/other/config'); + $otherHostPath = $root.'/other/config/host.txt'; + file_put_contents($otherHostPath, "BetaHost\n"); file_put_contents($path, record('100', 'player', true)); touch($path, 1000000000); $server = server($path, $clock); + $other = server($root.'/other/serverdata.txt', $clock); + foreach ([[$server, 'AlphaHost'], [$other, 'BetaHost']] as [$fixtureServer, $expectedHost]) { + check(fields($fixtureServer->generateServerstatusEmbed())['Host'] === $expectedHost, 'Heartbeat used another server or configured host'); + check(fields($fixtureServer->toEmbed())['Host'] === $expectedHost, 'Status embed used another server or configured host'); + } + check(file_get_contents($hostPath) === " AlphaHost \r\n" && file_get_contents($otherHostPath) === "BetaHost\n", 'Status rendering modified a host file'); + file_put_contents($hostPath, " ChangedHost \n"); + check(fields($server->toEmbed())['Host'] === 'ChangedHost' && fields($other->toEmbed())['Host'] === 'BetaHost', 'Host file changes did not stay per-server'); + file_put_contents($hostPath, " \xC3\x89lodie\r\n"); + check(fields($server->toEmbed())['Host'] === "\xC3\x89lodie" && fields($server->generateServerstatusEmbed())['Host'] === "\xC3\x89lodie", 'Valid non-ASCII host was not preserved'); + $invalidHost = "Bad\xC3\x28"; + file_put_contents($hostPath, $invalidHost); + check(fields($server->toEmbed())['Host'] === 'Unavailable' && fields($server->generateServerstatusEmbed())['Host'] === 'Unavailable' && file_get_contents($hostPath) === $invalidHost, 'Invalid UTF-8 was not rejected unchanged'); + $GLOBALS['host_read_failure'] = $hostPath; + check(fields($server->toEmbed())['Host'] === 'Unavailable' && fields($server->generateServerstatusEmbed())['Host'] === 'Unavailable', 'Unreadable host file used a fallback'); + unset($GLOBALS['host_read_failure']); + file_put_contents($hostPath, " \r\n\t"); + check(fields($server->toEmbed())['Host'] === 'Unavailable' && fields($server->generateServerstatusEmbed())['Host'] === 'Unavailable', 'Empty host file used a fallback'); + file_put_contents($hostPath, str_repeat('x', 1025)); + check(fields($server->toEmbed())['Host'] === 'Unavailable', 'Oversized host file produced an invalid embed'); + unlink($hostPath); + check(fields($server->toEmbed())['Host'] === 'Unavailable' && fields($server->generateServerstatusEmbed())['Host'] === 'Unavailable' && fields($other->toEmbed())['Host'] === 'BetaHost', 'Missing host file used another server or placeholder'); + file_put_contents($hostPath, "AlphaHost\n"); + $unc = server('//server/share/serverdata.txt', $clock); + check(fields($unc->toEmbed())['Host'] === 'Unavailable', 'UNC host path should not be probed'); $server->players = ['cached']; $initial = fields($server->generateServerstatusEmbed()); check($initial['Nomads'] === 'Unknown' && ! isset($initial['Players (1)']), 'Old literal Online leaked into initial status'); @@ -91,7 +131,7 @@ function fields(Discord\Parts\Embed\Embed $embed): array { $now = 15; file_put_contents($path, record('200', 'player', true)); $active = fields($server->generateServerstatusEmbed()); - check($active['Nomads'] === 'Heartbeat advancing' && $active['Connectivity'] === 'Unverified', 'Heartbeat was represented as connection readiness'); + check($active['Nomads'] === 'Heartbeat advancing' && ! array_key_exists('Connectivity', $active), 'Heartbeat or removed connectivity field is incorrect'); check($active['Players (1)'] === 'player' && $server->localServerPlayerCount() === 1, 'Optional Approved field corrupted player metadata'); check($server->players === ['player'], 'Confirmed player cache not populated'); $server->current_round = 'fixture'; @@ -129,12 +169,19 @@ public function save($channel): React\Promise\PromiseInterface { $this->saves++; check($server->players === [], 'Missing data retained players'); $remote = server($root.'/never-created', $clock, '192.0.2.1'); (new ReflectionProperty($remote, 'heartbeat'))->setValue($remote, new Civ13\ServerHeartbeat('unused', $clock, static function (): never { throw new RuntimeException('Remote directory was read'); })); + $GLOBALS['host_read_forbidden'] = $hostPath; + check(fields($remote->toEmbed())['Host'] === 'Unavailable', 'Remote status used a local or configured host'); check(fields($remote->generateServerstatusEmbed())['Health'] === 'Unknown (no local heartbeat evidence)', 'Remote server inferred health'); + unset($GLOBALS['host_read_forbidden']); check($remote->localServerPlayerCount() === 0, 'Remote server tried local player read'); check(! array_key_exists('heartbeat', $server->__toArray()), 'Ephemeral observation leaked into serialization'); echo "Production heartbeat renderer/cache/timer contracts passed\n"; } finally { + unset($GLOBALS['host_read_forbidden'], $GLOBALS['host_read_failure']); if (is_file($root.'/serverdata.txt')) { unlink($root.'/serverdata.txt'); } + if (is_file($root.'/config/host.txt')) { unlink($root.'/config/host.txt'); } + if (is_file($root.'/other/config/host.txt')) { unlink($root.'/other/config/host.txt'); } + rmdir($root.'/config'); rmdir($root.'/other/config'); rmdir($root.'/other'); rmdir($root); } } diff --git a/tests/local/server-compatibility.php b/tests/local/server-compatibility.php index 63a5d34a..e4e9cb94 100644 --- a/tests/local/server-compatibility.php +++ b/tests/local/server-compatibility.php @@ -41,7 +41,7 @@ public function updateFilesFromMemberRoles(callable $callback,array $file_paths, try { $server=(new ReflectionClass(\Civ13\GameServer::class))->newInstanceWithoutConstructor(); $server->civ13=(new ReflectionClass(StatusCivFixture::class))->newInstanceWithoutConstructor(); - foreach (['name'=>'Fixture','ip'=>'127.0.0.1','port'=>'18452','host'=>'Local'] as $property=>$value) {(new ReflectionProperty($server,$property))->setValue($server,$value);} + foreach (['name'=>'Fixture','ip'=>'127.0.0.1','port'=>'18452','host'=>'Local','basedir'=>$root.'/'] as $property=>$value) {(new ReflectionProperty($server,$property))->setValue($server,$value);} $server->players=['player']; foreach (['byond','opendream'] as $engine) { $server->engine=$engine; $embed=json_decode(json_encode($server->toEmbed()),true); @@ -50,7 +50,7 @@ public function updateFilesFromMemberRoles(callable $callback,array $file_paths, check($field['name']===($engine==='byond'?'Server URL':'OpenDream gameplay endpoint'),'engine-specific endpoint label'); check(!str_contains($field['value'],'18462'),'Topic port exposed as gameplay'); } - $server->enabled=true; (new ReflectionProperty($server, 'basedir'))->setValue($server,$root.'/'); mkdir($root.'/SQL'); + $server->enabled=true; mkdir($root.'/SQL'); check($server->OOCMessage('hello','admin'),'production OOC'); check($server->AdminMessage('notice','admin') === true,'production admin'); check($server->DirectMessage('hello','admin','player'),'production DM');