Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions src/Civ13/GameServer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
}
Expand Down
49 changes: 48 additions & 1 deletion tests/local/heartbeat-integration.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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');
Expand All @@ -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';
Expand Down Expand Up @@ -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);
}
}
4 changes: 2 additions & 2 deletions tests/local/server-compatibility.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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');
Expand Down
Loading