From ff0b942fe25a143685d7c819e91bad6bb47bba42 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 19 Oct 2026 13:25:31 -0400 Subject: [PATCH 1/8] Add opt-in live launcher server directory --- .github/workflows/verifier-integration.yml | 5 + README.MD | 1 + bot.php | 1 + docs/launcher-directory.md | 146 ++++++++++++ src/Civ13/Civ13.php | 3 + src/Civ13/HttpServiceManager.php | 41 ++++ .../ServerDirectory/ByondStatusProbe.php | 111 ++++++++++ src/Civ13/ServerDirectory/Directory.php | 173 +++++++++++++++ src/Civ13/ServerDirectory/Endpoint.php | 33 +++ tests/fixtures/server-directory.json | 1 + tests/server-directory.php | 207 ++++++++++++++++++ 11 files changed, 722 insertions(+) create mode 100644 docs/launcher-directory.md create mode 100644 src/Civ13/ServerDirectory/ByondStatusProbe.php create mode 100644 src/Civ13/ServerDirectory/Directory.php create mode 100644 src/Civ13/ServerDirectory/Endpoint.php create mode 100644 tests/fixtures/server-directory.json create mode 100644 tests/server-directory.php diff --git a/.github/workflows/verifier-integration.yml b/.github/workflows/verifier-integration.yml index 4572d0fd..804cbc71 100644 --- a/.github/workflows/verifier-integration.yml +++ b/.github/workflows/verifier-integration.yml @@ -7,6 +7,8 @@ on: - 'src/**' - 'tests/verifier-integration.php' - 'tests/http-dependency.php' + - 'tests/server-directory.php' + - 'tests/fixtures/server-directory.json' - 'tests/economy.php' - 'tests/economy-service.php' - '.github/workflows/verifier-integration.yml' @@ -16,6 +18,8 @@ on: - 'src/**' - 'tests/verifier-integration.php' - 'tests/http-dependency.php' + - 'tests/server-directory.php' + - 'tests/fixtures/server-directory.json' - 'tests/economy.php' - 'tests/economy-service.php' - '.github/workflows/verifier-integration.yml' @@ -45,6 +49,7 @@ jobs: - run: composer audit --locked - run: find src -name '*.php' -print0 | xargs -0 -n1 php -l - run: php tests/http-dependency.php + - run: php tests/server-directory.php - run: php tests/verifier-integration.php - run: php tests/economy.php - run: php tests/economy-service.php diff --git a/README.MD b/README.MD index 83f6cfe1..27ffbb4d 100644 --- a/README.MD +++ b/README.MD @@ -9,6 +9,7 @@ Civilizationbot is [Civ13](https://civ13.com)'s official Discord bot designed to - Chat moderation and relay - Interaction commands and slash commands - Automated status updates +- Opt-in, fresh online-only [launcher server directory](docs/launcher-directory.md) ## Installation diff --git a/bot.php b/bot.php index 80a87c88..8496b68f 100644 --- a/bot.php +++ b/bot.php @@ -207,6 +207,7 @@ function loadEnv(string $filePath): void //'verify_url' => 'http://valzargaming.com:8080/verified/', // Leave this blank if you do not want to use the webserver, ckeys will be stored locally as provisional 'verify_url' => getenv('VERIFIER_HOST_ADDR').':'.getenv('VERIFIER_HOST_PORT').'/verified/', // Local/Integrated Verifier Server // 'serverinfo_url' => '', // URL of the serverinfo.json file, defaults to the webserver if left blank + // 'launcher_directory' => ['civ13.com' => ['tdm', 'nomads']], // Opt-in public status; see docs/launcher-directory.md 'ooc_badwords' => $ooc_badwords, 'ic_badwords' => $ic_badwords, 'folders' => [ diff --git a/docs/launcher-directory.md b/docs/launcher-directory.md new file mode 100644 index 00000000..0ffc227f --- /dev/null +++ b/docs/launcher-directory.md @@ -0,0 +1,146 @@ +# Live launcher server directory + +The opt-in read-only endpoint is `GET /.well-known/civ13/servers.json`. It uses the +bot's existing ReactPHP HTTP listener. It does not create another listener, +scan ports, start servers, modify server configuration, or expose bot controls. +It is disabled unless `launcher_directory` is explicitly configured. + +## Deployment requirements + +Deployment and public reverse-proxy changes are separate operator actions. This +repository has no game/bot production-deployment workflow; its GitHub Actions +workflows test contracts and publish documentation only. + +1. Install the updated bot code and existing Composer dependencies with PHP 8.3+ + using the installation's usual process. No additional dependency is needed. +2. Add a mapping to the bot's `$options`. Each value is a list of keys from that + installation's **enabled** Civ13 `GameServer` registry, for example: + + ```php + 'launcher_directory' => [ + 'civ13.com' => ['tdm', 'nomads'], + // Add valzargaming.com only with its verified, enabled production keys. + ], + ``` + + `tdm` and `nomads` are enabled in the checked-in `bot.php`; the checked-in + VZG `pers` entry is disabled. That is not evidence of the current production + roster. An operator must inspect the actual installation before enabling the + mapping. `['valzargaming.com' => []]` intentionally reports an empty roster; + do not use that to claim VZG discovery is deployed or tested. A disabled, + duplicate, unknown or invalid key disables the directory and logs a warning. + Up to 32 total targets are allowed. A key belongs to only one provider. +3. Verify that each configured server's existing `ip` and `port` are the desired + BYOND Topic target. The public join address is the mapped provider hostname + plus that same configured port, so the provider DNS and any existing port + forwarding must already route those ports correctly. A server is probed at + its configured address, which can be loopback/private for an on-host bot; + its internal address is never published. No port is specified in the launcher + or repeated in the directory mapping. +4. The game must answer BYOND Topic `?status=1&format=json` on its configured + gameplay/Topic port with `players`, `map_name`, `map_gamemode`, and + `round_duration_in_ticks`. Zero players and a zero-duration lobby are valid. + A JSON `game_id` is recommended; if absent, the public round ID is empty. + A successful TCP handshake alone is not online evidence. +5. On each mapped provider, route the **exact** HTTPS path below to the existing + bot listener, preserving `Host: civ13.com` or `Host: valzargaming.com`: + + - `https://civ13.com/.well-known/civ13/servers.json` + - `https://valzargaming.com/.well-known/civ13/servers.json` + + Use the site's existing TLS certificate and reverse proxy. Serve GET/HEAD + without redirects, login pages, bot challenges, CDN caching, or stale-on-error + fallback. A normal website redirect to another host must not catch this path. + Do not expose the bot listener or proxy other bot API paths as part of this + change. The endpoint ignores forwarded-host headers and selects providers + only from its actual request URI/Host. An Nginx-style location could be: + + ```nginx + # Inside the existing civ13.com HTTPS virtual host. Adapt only after review. + location = /.well-known/civ13/servers.json { + proxy_pass http://127.0.0.1:55555; + proxy_set_header Host civ13.com; + proxy_cache off; + proxy_cache_use_stale off; + } + ``` + + `55555` is the bot's checked-in default HTTP port, not a game-port discovery + list. Substitute the verified listener for the installation. Use + `Host valzargaming.com` in that provider's virtual host. Do not change + listeners, firewalls, TLS/security settings, or existing proxy trust rules + without the operator's separate approval. Apply any per-client rate limiting + at the trusted public proxy. The existing bot handler also rate-limits this + endpoint to 600 requests/minute per observed remote address; behind a proxy, + clients may share that budget. Do not trust new forwarded-IP headers merely + to bypass that limit. +6. Restart the bot through its existing approved deployment process. Verify each + HTTPS endpoint returns status 200 and `application/json`, contains only the + corresponding provider, and has fresh timestamps. Then verify a known live + server, a zero-player server, and an offline configured server. Compare counts, + map/mode and round duration with the game or `/players`; do not use old bot + file-cache data as proof of liveness. Neither code merge nor offline CI proves + this live deployment acceptance. + +## JSON contract (schema 1) + +```json +{ + "schema": 1, + "generated_at": "2026-10-19T17:00:00Z", + "expires_at": "2026-10-19T17:01:00Z", + "servers": [ + { + "id": "nomads", + "name": "Nomads", + "host": "civ13.com", + "port": 1715, + "engine": "BYOND", + "online": true, + "checked_at": "2026-10-19T17:00:00Z", + "players": 0, + "map": "Gaia", + "mode": "Nomads", + "round": {"id": "example-round", "duration_seconds": 1234}, + "epoch": "1200" + } + ] +} +``` + +The port above is an illustrative response, read from the server registry at +runtime. All timestamps use UTC `yyyy-MM-ddTHH:mm:ssZ`. The envelope is valid +for at most 60 seconds and never past the oldest included observation's expiry. +HTTP reads cannot extend observations' lifetimes. An empty successful sweep +returns `servers: []`; warmup/stale data returns 503 with Retry-After. Unknown +provider/path, query parameters, and non-GET/HEAD methods are rejected. Every +response is `Cache-Control: no-store`; no raw status payload is returned. + +At bot readiness and every 15 seconds, one non-overlapping sweep asynchronously +probes the configured targets (at most 32 concurrent connections). Each probe +has a three-second total DNS/connect/read deadline and 64 KiB framing bound. +Only a complete valid BYOND string response containing a JSON object can produce +an online entry. Partial, malformed, timeout and offline results are omitted; +a completed sweep replaces the previous snapshot, including failed entries. +One provider/server failure does not remove another healthy server. Request +traffic never triggers probes or chooses addresses, ports, queries or URLs. + +`players` is the live numeric count, not a cached player list. Map, mode, round +duration, and optional `epoch` are allowlisted public metadata. Player names, +IPs, CIDs, admin lists, account links, tokens, and other raw status fields are +never copied. The game's existing `round_duration_in_second` has a units error, +so this adapter uses `round_duration_in_ticks / 10` (floored). It does not invent +round state or take state from the bot's disk cache. If a game version's ticks +exclude restored elapsed time, that game status producer must be corrected +separately before claiming persistence-aware round duration. + +## Offline verification + +Run `php tests/server-directory.php` after Composer installation. An optional +first argument selects an existing dependency autoloader. The suite loads no +`.env`, boots no bot, and opens no network connections. Injected streams exercise +the production BYOND parser and timeout cleanup; fixture registries exercise +online/offline/zero-player/partial/malformed data, privacy projection, provider +isolation, HTTP methods, freshness, time rollback, and coalescing. The existing +dependency workflow runs it on PHP 8.3, 8.4, and 8.5 alongside source lint and +the other bot regression suites. diff --git a/src/Civ13/Civ13.php b/src/Civ13/Civ13.php index 1beea869..3702e8cc 100644 --- a/src/Civ13/Civ13.php +++ b/src/Civ13/Civ13.php @@ -486,6 +486,7 @@ private function resolveOptions(array &$options = []): array 'http_key', 'http_port', 'http_whitelist', + 'launcher_directory', 'ic_badwords', 'legacy', 'moderate', @@ -526,6 +527,7 @@ private function resolveOptions(array &$options = []): array 'http_key' => '', 'http_port' => 0, 'http_whitelist' => [], + 'launcher_directory' => [], 'ic_badwords' => [], 'legacy' => true, 'moderate' => true, @@ -567,6 +569,7 @@ private function resolveOptions(array &$options = []): array ->setAllowedTypes('http_key', 'string') ->setAllowedTypes('http_port', 'int') ->setAllowedTypes('http_whitelist', 'array') + ->setAllowedTypes('launcher_directory', 'array') ->setAllowedTypes('ic_badwords', 'array') ->setAllowedTypes('legacy', 'bool') ->setAllowedTypes('moderate', 'bool') diff --git a/src/Civ13/HttpServiceManager.php b/src/Civ13/HttpServiceManager.php index 9e3a3287..baae662a 100644 --- a/src/Civ13/HttpServiceManager.php +++ b/src/Civ13/HttpServiceManager.php @@ -14,6 +14,9 @@ namespace Civ13; use Byond\Byond; +use Civ13\ServerDirectory\ByondStatusProbe; +use Civ13\ServerDirectory\Directory; +use Civ13\ServerDirectory\Endpoint as DirectoryEndpoint; use Discord\Discord; use Discord\DiscordWebAuth; use Discord\Parts\Channel\Message; @@ -25,6 +28,7 @@ use React\Http\Message\Response as HttpResponse; use React\Promise\PromiseInterface; use React\Socket\SocketServer; +use React\Socket\Connector; class HttpServiceManager { @@ -42,6 +46,8 @@ class HttpServiceManager protected array $dwa_sessions = []; protected array $dwa_timers = []; protected array $dwa_discord_ids = []; + private ?Directory $launcherDirectory = null; + private ?TimerInterface $launcherDirectoryTimer = null; /** @param Civ13 $civ13 The bot instance; builds the {@see HttpHandler} and binds `discord`/`logger` by reference. */ public function __construct(public Civ13 &$civ13) @@ -56,6 +62,9 @@ public function __construct(public Civ13 &$civ13) /** Closes the listening socket, if one is open. */ public function __destruct() { + if ($this->launcherDirectoryTimer !== null) { + $this->civ13->loop->cancelTimer($this->launcherDirectoryTimer); + } if (isset($this->socket)) { $this->socket->close(); } @@ -83,6 +92,11 @@ protected function __afterConstruct() //$this->logger->info('Populating HttpServer API whitelist...'); //$this->__populateWhitelist(); // This is disabled for now because it takes >20 seconds. $this->webapi->listen($this->socket); + if ($this->launcherDirectory !== null) { + $refresh = fn () => $this->launcherDirectory->refresh()->then(null, fn (\Throwable $error) => $this->logger->warning('Launcher directory refresh failed')); + $refresh(); + $this->launcherDirectoryTimer = $this->civ13->loop->addPeriodicTimer(Directory::REFRESH_SECONDS, $refresh); + } $this->logger->info("HttpServer API is now listening on port {$this->http_port}"); $this->logger->debug('[HTTP COMMAND LIST] '.PHP_EOL.$this->httpHandler->generateHelp()); }; @@ -221,6 +235,7 @@ protected function __populateWhitelist(): void /** Registers the core web-API routes (channel browser, message/embed senders, admin actions), then delegates to the per-server and website route generators. */ protected function __generateEndpoints() { + $this->__generateLauncherDirectoryEndpoint(); $this->httpHandler ->offsetSet( '/get-channels', @@ -1670,6 +1685,32 @@ function (ServerRequestInterface $request, string $endpoint, bool $whitelisted) } } + /** Opt-in public status only. No endpoint input can select a probe address or port. */ + private function __generateLauncherDirectoryEndpoint(): void + { + if (!$providers = $this->civ13->options['launcher_directory'] ?? []) { + return; + } + try { + $this->launcherDirectory = new Directory( + $providers, + $this->civ13->enabled_gameservers, + new ByondStatusProbe($this->civ13->loop, new Connector(['timeout' => ByondStatusProbe::TIMEOUT_SECONDS], $this->civ13->loop)) + ); + } catch (\InvalidArgumentException $error) { + $this->logger->warning('Launcher directory disabled: '.$error->getMessage()); + return; + } + $endpoint = new DirectoryEndpoint($this->launcherDirectory); + $this->httpHandler->offsetSet( + Directory::PATH, + static fn (ServerRequestInterface $request, string $path, bool $whitelisted): HttpResponse => $endpoint->handle($request), + false, + 'exact', + 'Read-only, online-only launcher server directory' + )->setRateLimit(Directory::PATH, 600, 60); + } + /** Serves every `*.html` file under the `/html` directory as its own web-API route, creating the directory if missing. */ protected function __generateWebsiteEndpoints() { diff --git a/src/Civ13/ServerDirectory/ByondStatusProbe.php b/src/Civ13/ServerDirectory/ByondStatusProbe.php new file mode 100644 index 00000000..32853969 --- /dev/null +++ b/src/Civ13/ServerDirectory/ByondStatusProbe.php @@ -0,0 +1,111 @@ +loop->cancelTimer($timer); + } + if ($connection !== null) { + $connection->close(); + } elseif ($connecting !== null) { + $connecting->cancel(); + } + $error === null ? $deferred->resolve($status) : $deferred->reject($error); + }; + $timer = $this->loop->addTimer(self::TIMEOUT_SECONDS, static fn () => $finish(null, new \RuntimeException('Status probe timed out'))); + // Targets come exclusively from the bot's configured server registry. + $host = str_contains($address, ':') ? '['.$address.']' : $address; + try { + $connecting = $this->connector->connect('tcp://'.$host.':'.$port); + $connecting->then(function (ConnectionInterface $stream) use (&$connection, &$settled, $finish): void { + if ($settled) { + $stream->close(); + return; + } + $connection = $stream; + $buffer = ''; + $stream->on('data', static function ($chunk) use (&$buffer, $finish): void { + if (strlen($buffer) + strlen($chunk) > self::MAX_PACKET_BYTES) { + $finish(null, new \UnexpectedValueException('Oversized status response')); + return; + } + $buffer .= $chunk; + try { + if (($status = self::decodePacket($buffer)) !== null) { + $finish($status); + } + } catch (\Throwable $error) { + $finish(null, $error); + } + }); + $stream->on('error', static fn (\Throwable $error) => $finish(null, $error)); + $stream->on('close', static fn () => $finish(null, new \UnexpectedValueException('Incomplete status response'))); + $query = "?status=1&format=json\0"; + try { + $stream->write("\0\x83".pack('n', strlen($query) + 5).str_repeat("\0", 5).$query); + } catch (\Throwable $error) { + $finish(null, $error); + } + }, static fn (\Throwable $error) => $finish(null, $error)); + } catch (\Throwable $error) { + $finish(null, $error); + } + return $deferred->promise(); + } + + /** Returns null only for an incomplete frame; rejects all non-string/non-JSON replies. */ + public static function decodePacket(string $packet): ?array + { + if (strlen($packet) > self::MAX_PACKET_BYTES) { + throw new \UnexpectedValueException('Oversized status response'); + } + if (strlen($packet) < 4) { + return null; + } + $length = unpack('nlength', substr($packet, 2, 2))['length']; + if (substr($packet, 0, 2) !== "\0\x83" || $length < 4 || $length + 4 > self::MAX_PACKET_BYTES) { + throw new \UnexpectedValueException('Invalid status frame'); + } + if (strlen($packet) < $length + 4) { + return null; + } + if (strlen($packet) !== $length + 4 || $packet[4] !== "\x06" || substr($packet, -1) !== "\0") { + throw new \UnexpectedValueException('Invalid status payload'); + } + $status = json_decode(substr($packet, 5, -1), true, 16, JSON_THROW_ON_ERROR); + if (!is_array($status) || array_is_list($status)) { + throw new \UnexpectedValueException('Status must be an object'); + } + return $status; + } +} diff --git a/src/Civ13/ServerDirectory/Directory.php b/src/Civ13/ServerDirectory/Directory.php new file mode 100644 index 00000000..23ef533d --- /dev/null +++ b/src/Civ13/ServerDirectory/Directory.php @@ -0,0 +1,173 @@ +> $providers Explicit provider => configured server keys. + * @param array $registry The bot's enabled_gameservers, not request data. + * @param callable(string, int): PromiseInterface $probe + * @param callable(): int|null $clock Test seam; production uses UTC wall-clock seconds. + */ + public function __construct(array $providers, array $registry, private $probe, private $clock = null) + { + $this->clock ??= time(...); + $count = 0; + $seen = []; + foreach ($providers as $host => $keys) { + if (!in_array($host, self::PROVIDERS, true) || !is_array($keys) || !array_is_list($keys)) { + throw new \InvalidArgumentException('Invalid launcher directory provider configuration'); + } + $this->targets[$host] = []; + $ports = []; + foreach ($keys as $key) { + if (!is_string($key) || !preg_match('/\A[a-zA-Z0-9_-]{1,64}\z/D', $key) || isset($seen[$key]) || !isset($registry[$key])) { + throw new \InvalidArgumentException('Directory keys must uniquely name configured enabled servers'); + } + $seen[$key] = true; + $server = $registry[$key]; + if (($server->enabled ?? false) !== true) { + throw new \InvalidArgumentException('Directory server is disabled'); + } + $port = filter_var($server->port ?? null, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 65535]]); + $address = $server->ip ?? null; + if (!is_string($address) || strlen($address) > 253 || (!filter_var($address, FILTER_VALIDATE_IP) && !preg_match('/\A(?=.{1,253}\z)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\z/D', $address))) { + throw new \InvalidArgumentException('Invalid configured probe address'); + } + if ($port === false || isset($ports[$port]) || self::text($server->name ?? null, 120) === null || ++$count > self::MAX_SERVERS) { + throw new \InvalidArgumentException('Invalid directory server or too many servers'); + } + $ports[$port] = true; + $this->targets[$host][] = ['id' => $key, 'name' => $server->name, 'host' => $host, 'port' => $port, 'address' => $address]; + } + } + } + + /** One bounded sweep; overlapping timers share it and HTTP requests never trigger probes. */ + public function refresh(): PromiseInterface + { + if ($this->pending !== null) { + return $this->pending; + } + $deferred = new Deferred(); + $this->pending = $deferred->promise(); + $pending = $this->pending; + $requests = []; + foreach ($this->targets as $targets) { + foreach ($targets as $target) { + try { + $request = ($this->probe)($target['address'], $target['port']); + $requests[] = $request->then(function ($status) use ($target): ?array { + return is_array($status) ? self::entry($target, $status, ($this->clock)()) : null; + }, static fn () => null); + } catch (\Throwable) { + $requests[] = resolve(null); + } + } + } + all($requests)->then(function (array $entries) use ($deferred): void { + $snapshots = array_fill_keys(array_keys($this->targets), []); + foreach ($entries as $entry) { + if ($entry !== null) { + $snapshots[$entry['host']][] = $entry; + } + } + // Atomic replacement drops formerly online servers after any failed probe. + $this->snapshots = $snapshots; + $this->completedAt = ($this->clock)(); + $this->pending = null; + $deferred->resolve(null); + }, function (\Throwable $error) use ($deferred): void { + $this->pending = null; + $deferred->reject($error); + }); + return $pending; + } + + /** Null means unconfigured, warming up or stale, never an invented online result. */ + public function snapshot(string $host): ?array + { + $now = ($this->clock)(); + if (!isset($this->targets[$host]) || $this->completedAt === null || $now < $this->completedAt || $now - $this->completedAt >= self::MAX_AGE_SECONDS) { + return null; + } + $servers = []; + $expires = $now + self::MAX_AGE_SECONDS; + foreach ($this->snapshots[$host] as $entry) { + $checked = $entry['_checked']; + if ($checked > $now || $now - $checked >= self::MAX_AGE_SECONDS) { + continue; + } + $expires = min($expires, $checked + self::MAX_AGE_SECONDS); + unset($entry['_checked']); + $servers[] = $entry; + } + // An empty result still expires relative to its last real sweep. + $expires = min($expires, $this->completedAt + self::MAX_AGE_SECONDS); + return ['schema' => 1, 'generated_at' => self::timestamp($now), 'expires_at' => self::timestamp($expires), 'servers' => $servers]; + } + + public function hasProvider(string $host): bool + { + return isset($this->targets[$host]); + } + + private static function entry(array $target, array $status, int $now): ?array + { + $players = $status['players'] ?? null; + $ticks = $status['round_duration_in_ticks'] ?? null; + $id = $status['game_id'] ?? ''; + $id = is_int($id) ? (string) $id : $id; + $map = self::text($status['map_name'] ?? null, 128); + $mode = self::text($status['map_gamemode'] ?? null, 80); + if (!is_int($players) || $players < 0 || $players > 10000 || (!is_int($ticks) && !is_float($ticks)) || !is_finite((float) $ticks) || $ticks < 0 || $ticks > 2147483647 || $map === null || $mode === null || self::text($id, 80, true) === null) { + return null; + } + $entry = [ + 'id' => $target['id'], 'name' => $target['name'], 'host' => $target['host'], 'port' => $target['port'], + 'engine' => 'BYOND', 'online' => true, 'checked_at' => self::timestamp($now), 'players' => $players, + 'map' => $map, 'mode' => $mode, 'round' => ['id' => $id, 'duration_seconds' => (int) floor($ticks / 10)], '_checked' => $now, + ]; + // Explicit allowlist only: never pass through ckeys, IPs, CIDs, admins, or account links. + $epoch = $status['map_age'] ?? null; + $epoch = is_int($epoch) ? (string) $epoch : $epoch; + if (($epoch = self::text($epoch, 32)) !== null) { + $entry['epoch'] = $epoch; + } + return $entry; + } + + private static function text(mixed $value, int $maxBytes, bool $emptyAllowed = false): ?string + { + if (!is_string($value) || (!$emptyAllowed && trim($value) === '') || strlen($value) > $maxBytes || !preg_match('//u', $value) || preg_match('/[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}]/u', $value)) { + return null; + } + return $value; + } + + private static function timestamp(int $time): string + { + return gmdate('Y-m-d\TH:i:s\Z', $time); + } +} diff --git a/src/Civ13/ServerDirectory/Endpoint.php b/src/Civ13/ServerDirectory/Endpoint.php new file mode 100644 index 00000000..a6b5f019 --- /dev/null +++ b/src/Civ13/ServerDirectory/Endpoint.php @@ -0,0 +1,33 @@ + 'application/json; charset=utf-8', 'Cache-Control' => 'no-store', 'X-Content-Type-Options' => 'nosniff']; + if (!in_array($request->getMethod(), ['GET', 'HEAD'], true)) { + return new Response(405, $headers + ['Allow' => 'GET, HEAD'], '{"error":"method_not_allowed"}'); + } + if ($request->getUri()->getPath() !== Directory::PATH || !$this->directory->hasProvider($request->getUri()->getHost())) { + return new Response(404, $headers, '{"error":"not_found"}'); + } + if ($request->getUri()->getQuery() !== '') { + return new Response(400, $headers, '{"error":"query_parameters_not_supported"}'); + } + if (($snapshot = $this->directory->snapshot($request->getUri()->getHost())) === null) { + return new Response(503, $headers + ['Retry-After' => (string) Directory::REFRESH_SECONDS], $request->getMethod() === 'HEAD' ? '' : '{"error":"directory_unavailable"}'); + } + return new Response(200, $headers, $request->getMethod() === 'HEAD' ? '' : json_encode($snapshot, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)); + } +} diff --git a/tests/fixtures/server-directory.json b/tests/fixtures/server-directory.json new file mode 100644 index 00000000..d642c201 --- /dev/null +++ b/tests/fixtures/server-directory.json @@ -0,0 +1 @@ +{"schema":1,"generated_at":"2026-10-19T17:00:00Z","expires_at":"2026-10-19T17:01:00Z","servers":[{"id":"nomads","name":"Nomads","host":"civ13.com","port":1715,"engine":"BYOND","online":true,"checked_at":"2026-10-19T17:00:00Z","players":0,"map":"Gaia","mode":"Nomads","round":{"id":"round-42","duration_seconds":1234},"epoch":"1200"}]} diff --git a/tests/server-directory.php b/tests/server-directory.php new file mode 100644 index 00000000..d838b826 --- /dev/null +++ b/tests/server-directory.php @@ -0,0 +1,207 @@ +closed; } + public function isWritable() { return !$this->closed; } + public function pause() {} + public function resume() {} + public function pipe(WritableStreamInterface $dest, array $options = []) { return Util::pipe($this, $dest, $options); } + public function write($data) { $this->request .= $data; return !$this->closed; } + public function getRemoteAddress() { return 'tcp://127.0.0.1:1'; } + public function getLocalAddress() { return 'tcp://127.0.0.1:2'; } + public function end($data = null) { if ($data !== null) { $this->write($data); } $this->close(); } + public function close() { if (!$this->closed) { $this->closed = true; $this->emit('close'); $this->removeAllListeners(); } } +} + +$status = ['game_id' => 'round-42', 'players' => 0, 'map_name' => 'Gaia', 'map_gamemode' => 'Nomads', 'map_age' => 1200, 'round_duration_in_ticks' => 12345, + 'round_duration_in_second' => 123450, 'admins' => ['private-admin'], 'ckeys' => ['private-player'], 'ip' => 'private-address', 'cid' => 'private-cid', 'secret' => 'private-secret']; +$packet = statusPacket(json_encode($status, JSON_THROW_ON_ERROR)); +directoryCheck(ByondStatusProbe::decodePacket($packet) === $status, 'Valid status packet changed'); +for ($i = 0; $i < strlen($packet); $i++) { + directoryCheck(ByondStatusProbe::decodePacket(substr($packet, 0, $i)) === null, 'Fragmented frame did not wait'); +} +foreach (["\0\x00\0\x04xxxx", "\0\x83\xff\xff", "\0\x83\0\x01x", $packet.'trailing', substr($packet, 0, -1).'x', str_replace("\x06", "\x2a", $packet), statusPacket('false'), statusPacket('[]'), statusPacket('{broken'), statusPacket('{"x":"'.str_repeat('x', 65536).'"}') ] as $bad) { + directoryThrows(static fn () => ByondStatusProbe::decodePacket($bad)); +} + +// Real production probe with injected connections: bytes, fragmentation, cleanup, IPv6. +$loop = new StreamSelectLoop(); +$connection = new DirectoryConnection(); +$connector = new class($connection) implements ConnectorInterface { + public array $uris = []; + public function __construct(private ConnectionInterface $connection) {} + public function connect($uri) { $this->uris[] = $uri; return resolve($this->connection); } +}; +$probe = new ByondStatusProbe($loop, $connector); +$value = null; $error = null; +$probe('::1', 12345)->then(static function ($result) use (&$value) { $value = $result; }, static function ($failure) use (&$error) { $error = $failure; }); +$query = "?status=1&format=json\0"; +directoryCheck($connection->request === "\0\x83".pack('n', strlen($query) + 5).str_repeat("\0", 5).$query, 'Read-only Topic wire request differs'); +directoryCheck($connector->uris === ['tcp://[::1]:12345'], 'Configured IPv6 target not preserved'); +foreach (str_split($packet, 3) as $chunk) { $connection->emit('data', [$chunk]); } +directoryCheck($value === $status && $error === null && $connection->closed, 'Probe did not parse/close valid response'); +$loop->run(); // No leftover deadline should keep this loop alive. + +foreach (['close', 'error', 'oversized', 'bad_json'] as $failure) { + $loop = new StreamSelectLoop(); $connection = new DirectoryConnection(); + $connector = new class($connection) implements ConnectorInterface { + public function __construct(private ConnectionInterface $connection) {} + public function connect($uri) { return resolve($this->connection); } + }; + $failed = false; + (new ByondStatusProbe($loop, $connector))('127.0.0.1', 1)->then(null, static function () use (&$failed) { $failed = true; }); + match ($failure) { + 'close' => $connection->close(), + 'error' => $connection->emit('error', [new RuntimeException('Offline fixture')]), + 'oversized' => $connection->emit('data', [str_repeat('x', 65537)]), + 'bad_json' => $connection->emit('data', [statusPacket('{')]), + }; + directoryCheck($failed && $connection->closed, 'Invalid/closed transport did not fail closed'); + $loop->run(); +} + +// A single real deadline bounds DNS/connect and established-stream stalls alike. +$loop = new StreamSelectLoop(); $cancelled = false; $connectFailed = false; $streamFailed = false; +$hanging = new Deferred(static function () use (&$cancelled) { $cancelled = true; }); +$connector = new class($hanging) implements ConnectorInterface { + public function __construct(private Deferred $pending) {} + public function connect($uri) { return $this->pending->promise(); } +}; +(new ByondStatusProbe($loop, $connector))('offline.invalid', 1)->then(null, static function () use (&$connectFailed) { $connectFailed = true; }); +$connection = new DirectoryConnection(); +$connector = new class($connection) implements ConnectorInterface { + public function __construct(private ConnectionInterface $connection) {} + public function connect($uri) { return resolve($this->connection); } +}; +(new ByondStatusProbe($loop, $connector))('127.0.0.1', 1)->then(null, static function () use (&$streamFailed) { $streamFailed = true; }); +$started = microtime(true); $loop->run(); +directoryCheck($cancelled && $connectFailed && $streamFailed && $connection->closed && microtime(true) - $started < 5, 'Deadline did not cancel connect and close stalled stream'); + +$now = 1800000000; +$registry = []; +foreach (['zero', 'offline', 'bad', 'other'] as $i => $key) { + $registry[$key] = (object) ['enabled' => true, 'ip' => '127.0.0.1', 'port' => (string) (12000 + $i), 'name' => 'Fixture '.$key]; +} +$results = [12000 => $status, 12001 => null, 12002 => ['players' => 5], 12003 => array_replace($status, ['players' => 7])]; +$calls = []; +$probe = static function ($address, $port) use (&$results, &$calls) { + $calls[] = [$address, $port]; + return $results[$port] === null ? reject(new RuntimeException('Offline')) : resolve($results[$port]); +}; +$clock = static function () use (&$now): int { return $now; }; +$directory = new Directory(['civ13.com' => ['zero', 'offline', 'bad'], 'valzargaming.com' => ['other']], $registry, $probe, $clock); +$endpoint = new Endpoint($directory); +$request = static fn ($method = 'GET', $host = 'civ13.com', $suffix = '') => new ServerRequest($method, 'https://'.$host.Directory::PATH.$suffix); +directoryCheck($directory->snapshot('civ13.com') === null && $endpoint->handle($request())->getStatusCode() === 503 && $calls === [], 'Warmup triggered a probe or reported online'); +$directory->refresh(); +$snapshot = $directory->snapshot('civ13.com'); +directoryCheck(count($snapshot['servers']) === 1 && $snapshot['servers'][0]['players'] === 0, 'Zero-player server lost, or failed/partial probe included'); +directoryCheck($snapshot['servers'][0]['round'] === ['id' => 'round-42', 'duration_seconds' => 1234], 'Round ticks conversion is incorrect'); +directoryCheck($snapshot['servers'][0]['epoch'] === '1200', 'Public epoch was not projected'); +directoryCheck($directory->snapshot('valzargaming.com')['servers'][0]['players'] === 7, 'Provider isolation failed'); +directoryCheck(count($calls) === 4 && array_column($calls, 1) === [12000, 12001, 12002, 12003], 'Probe scanned or used unconfigured ports'); +$response = $endpoint->handle($request()); $json = (string) $response->getBody(); +directoryCheck($response->getStatusCode() === 200 && $response->getHeaderLine('Cache-Control') === 'no-store' && $response->getHeaderLine('X-Content-Type-Options') === 'nosniff', 'Endpoint cache/content contract differs'); +directoryCheck(!str_contains($json, 'private-') && !str_contains($json, '127.0.0.1') && !str_contains($json, '_checked'), 'Private status or internal fields leaked'); +directoryCheck(json_decode($json, true, 16, JSON_THROW_ON_ERROR) === $snapshot && strlen($json) < 65536, 'Invalid/oversized public JSON'); +directoryCheck($snapshot['generated_at'] === gmdate('Y-m-d\TH:i:s\Z', $now) && strtotime($snapshot['expires_at']) === $now + 60, 'Timestamp contract differs'); +foreach ([['POST', 'civ13.com', '', 405], ['GET', 'evil.invalid', '', 404], ['GET', 'civ13.com', '?host=evil.invalid&port=1', 400], ['GET', 'civ13.com', '/extra', 404]] as [$method, $host, $suffix, $expected]) { + directoryCheck($endpoint->handle($request($method, $host, $suffix))->getStatusCode() === $expected, 'Request allowlist failed'); +} +directoryCheck((string) $endpoint->handle($request('HEAD'))->getBody() === '' && count($calls) === 4, 'HEAD/request handlers performed work or returned a body'); +$now += 59; +directoryCheck(strtotime($directory->snapshot('civ13.com')['expires_at']) === $now + 1, 'Reading cache extended observation lifetime'); +$now++; +directoryCheck($endpoint->handle($request())->getStatusCode() === 503, 'Expired snapshot remained available'); +$results[12000] = null; $directory->refresh(); +directoryCheck($directory->snapshot('civ13.com')['servers'] === [], 'Last known good server survived a failed probe'); +directoryCheck($directory->snapshot('valzargaming.com')['servers'][0]['players'] === 7, 'Partial failure lost healthy provider'); + +// Every malformed mandatory value drops only its server, never invents counts or rounds. +foreach (['players' => [-1, 1.5, '2', true, 10001, []], 'map_name' => ['', "bad\nmap", "bad\u{202e}map", "bad\u{2028}map", str_repeat('x', 129), "\xff"], 'map_gamemode' => [null, [], str_repeat('x', 81)], 'round_duration_in_ticks' => [-1, INF, NAN, '100', null, 2147483648], 'game_id' => [[], false, str_repeat('x', 81)]] as $field => $values) { + foreach ($values as $value) { + $results[12000] = array_replace($status, [$field => $value]); + $directory->refresh(); + directoryCheck($directory->snapshot('civ13.com')['servers'] === [], 'Malformed '.$field.' was published'); + } +} +$results[12000] = $status; unset($results[12000]['game_id']); $directory->refresh(); +directoryCheck($directory->snapshot('civ13.com')['servers'][0]['round']['id'] === '', 'Missing round ID should remain explicitly unavailable'); +$results[12000]['game_id'] = 42; $directory->refresh(); +directoryCheck($directory->snapshot('civ13.com')['servers'][0]['round']['id'] === '42', 'Numeric round ID not normalized'); +$results[12000]['round_duration_in_ticks'] = 12345.5; $directory->refresh(); +directoryCheck($directory->snapshot('civ13.com')['servers'][0]['round']['duration_seconds'] === 1234, 'Fractional deciseconds were not converted to whole seconds'); + +// A timer cannot launch overlapping sweeps, even while clients hammer the endpoint. +$waiting = new Deferred(); $startedProbes = 0; +$slow = new Directory(['civ13.com' => ['zero']], $registry, static function () use ($waiting, &$startedProbes) { $startedProbes++; return $waiting->promise(); }, $clock); +$pending = $slow->refresh(); +directoryCheck($slow->refresh() === $pending && $startedProbes === 1 && $slow->snapshot('civ13.com') === null, 'Overlapping sweep was launched'); +$now += 2; $waiting->resolve($status); +directoryCheck($slow->snapshot('civ13.com')['servers'][0]['checked_at'] === gmdate('Y-m-d\TH:i:s\Z', $now), 'Probe completion time was not used'); +$now -= 3; +directoryCheck($slow->snapshot('civ13.com') === null, 'Backwards wall clock made future status look fresh'); + +foreach ([['evil.invalid' => ['zero']], ['civ13.com' => ['missing']], ['civ13.com' => ['zero', 'zero']], ['civ13.com' => ['zero'], 'valzargaming.com' => ['zero']], ['civ13.com' => 'zero']] as $config) { + directoryThrows(static fn () => new Directory($config, $registry, $probe, $clock)); +} +foreach (['enabled' => false, 'port' => '65536', 'ip' => 'https://evil.invalid/path', 'name' => "name\0secret"] as $field => $value) { + $invalid = clone $registry['zero']; $invalid->$field = $value; + directoryThrows(static fn () => new Directory(['civ13.com' => ['zero']], ['zero' => $invalid], $probe, $clock)); +} +$empty = new Directory(['valzargaming.com' => []], [], $probe, $clock); $empty->refresh(); +directoryCheck($empty->snapshot('valzargaming.com')['servers'] === [], 'Explicitly empty provider did not report zero online servers'); +$large = []; +for ($i = 0; $i < 33; $i++) { $large['server'.$i] = clone $registry['zero']; $large['server'.$i]->port = (string) (13000 + $i); } +directoryThrows(static fn () => new Directory(['civ13.com' => array_keys($large)], $large, $probe, $clock)); +$samePort = clone $registry['zero']; +directoryThrows(static fn () => new Directory(['civ13.com' => ['one', 'two']], ['one' => $samePort, 'two' => $samePort], $probe, $clock)); + +// Shared launcher/backend compatibility artifact, produced by this exact projection. +$fixture = json_decode(file_get_contents(__DIR__.'/fixtures/server-directory.json'), true, 16, JSON_THROW_ON_ERROR); +$fixtureServer = (object) ['enabled' => true, 'ip' => '127.0.0.1', 'port' => '1715', 'name' => 'Nomads']; +$fixtureDirectory = new Directory(['civ13.com' => ['nomads']], ['nomads' => $fixtureServer], static fn () => resolve($status), static fn () => strtotime('2026-10-19T17:00:00Z')); +$fixtureDirectory->refresh(); +directoryCheck($fixtureDirectory->snapshot('civ13.com') === $fixture, 'Cross-language compatibility fixture differs from backend output'); + +echo "PASS directory: configured targets, online/zero-player/offline, partial failure, malformed JSON/status, privacy allowlist, freshness/clock, atomic coalesced sweeps, provider/method/query isolation, fixed BYOND wire protocol, frame bounds and asynchronous deadlines.\n"; From 5725c65b7fc21dc5e80f7eb9dd62bd169fdbd038 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 19 Oct 2026 13:30:28 -0400 Subject: [PATCH 2/8] Verify launcher directory startup registry integration --- .github/workflows/verifier-integration.yml | 3 + docs/launcher-directory.md | 9 +++ tests/server-directory-startup.php | 90 ++++++++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 tests/server-directory-startup.php diff --git a/.github/workflows/verifier-integration.yml b/.github/workflows/verifier-integration.yml index 804cbc71..d8bd5120 100644 --- a/.github/workflows/verifier-integration.yml +++ b/.github/workflows/verifier-integration.yml @@ -8,6 +8,7 @@ on: - 'tests/verifier-integration.php' - 'tests/http-dependency.php' - 'tests/server-directory.php' + - 'tests/server-directory-startup.php' - 'tests/fixtures/server-directory.json' - 'tests/economy.php' - 'tests/economy-service.php' @@ -19,6 +20,7 @@ on: - 'tests/verifier-integration.php' - 'tests/http-dependency.php' - 'tests/server-directory.php' + - 'tests/server-directory-startup.php' - 'tests/fixtures/server-directory.json' - 'tests/economy.php' - 'tests/economy-service.php' @@ -50,6 +52,7 @@ jobs: - run: find src -name '*.php' -print0 | xargs -0 -n1 php -l - run: php tests/http-dependency.php - run: php tests/server-directory.php + - run: php tests/server-directory-startup.php - run: php tests/verifier-integration.php - run: php tests/economy.php - run: php tests/economy-service.php diff --git a/docs/launcher-directory.md b/docs/launcher-directory.md index 0ffc227f..924e4399 100644 --- a/docs/launcher-directory.md +++ b/docs/launcher-directory.md @@ -144,3 +144,12 @@ online/offline/zero-player/partial/malformed data, privacy projection, provider isolation, HTTP methods, freshness, time rollback, and coalescing. The existing dependency workflow runs it on PHP 8.3, 8.4, and 8.5 alongside source lint and the other bot regression suites. + +`php tests/server-directory-startup.php` additionally constructs real +`GameServer` objects against an injected in-memory bot boundary and invokes the +real directory registration method. It verifies synchronous population of the +enabled registry, exclusion of disabled/invalid-directory servers, string-port +normalization, and pre-init route registration without probes. A source-order +guard covers `Civ13::afterConstruct`: the game-server constructor loop runs +before `HttpServiceManager` construction. This test bypasses bot startup, the +HTTP handler's external-IP lookup, and live listening. diff --git a/tests/server-directory-startup.php b/tests/server-directory-startup.php new file mode 100644 index 00000000..9317b13c --- /dev/null +++ b/tests/server-directory-startup.php @@ -0,0 +1,90 @@ +loop = new StreamSelectLoop(); + $this->logger = new Logger('offline-directory', [new NullHandler()]); + $this->discord = (new ReflectionClass(Discord::class))->newInstanceWithoutConstructor(); + } + public function VarLoad(string $filename = ''): ?array { return []; } + public function VarSave(string $filename, array $assoc_array = []): bool { return true; } + public function deferUntilReady(callable $callback, ?string $function = null): void { $this->deferred[] = $callback; } +} + +$bot = new DirectoryStartupBot(); +$base = ['basedir' => __DIR__, 'name' => 'Fixture', 'ip' => '127.0.0.1', 'port' => '12345', 'host' => 'Fixture host', 'enabled' => true]; +foreach (['discussion', 'playercount', 'ooc', 'lobby', 'asay', 'ic', 'transit', 'adminlog', 'debug', 'garbage', 'runtime', 'attack'] as $channel) { $base[$channel] = ''; } +foreach (['one', 'two', 'disabled', 'missing_directory'] as $index => $key) { + $options = array_replace($base, ['key' => $key, 'port' => (string) (12345 + $index), 'enabled' => $key !== 'disabled']); + if ($key === 'missing_directory') { $options['basedir'] = __DIR__.'/missing-'.bin2hex(random_bytes(8)); } + new GameServer($bot, $options); +} +startupCheck(!$bot->ready && !isset($bot->httpServiceManager), 'Fixture started the bot or HTTP manager'); +startupCheck(array_keys($bot->enabled_gameservers) === ['one', 'two'], 'Enabled registry was not populated synchronously, or disabled server leaked'); +startupCheck(count($bot->gameservers) === 4 && count($bot->deferred) === 2, 'Registration was deferred with server timers'); +foreach ($bot->enabled_gameservers as $server) { + startupCheck($server instanceof GameServer && $server->enabled === true && $server->ready, 'Actual GameServer was not registered ready'); + startupCheck(is_string($server->ip) && is_string($server->port), 'GameServer target types differ'); +} + +// Guard the actual production orchestrator's ordering without starting its +// moderator/verifier/Discord side effects. Dynamic tests above use its real +// GameServer constructor; the following route call is the real manager method. +$method = new ReflectionMethod(Civ13::class, 'afterConstruct'); +$lines = file($method->getFileName()); +$source = implode('', array_slice($lines, $method->getStartLine() - 1, $method->getEndLine() - $method->getStartLine() + 1)); +$serversAt = strpos($source, 'new GameServer($this, $gameserver_options)'); +$httpAt = strpos($source, 'new HttpServiceManager($this)'); +startupCheck($serversAt !== false && $httpAt !== false && $serversAt < $httpAt, 'Production HTTP construction moved before server registration'); + +$bot->options['launcher_directory'] = ['civ13.com' => ['one', 'two']]; +$manager = (new ReflectionClass(HttpServiceManager::class))->newInstanceWithoutConstructor(); +$manager->civ13 = $bot; +$manager->logger = $bot->logger; +// Skip HttpHandler's external-IP HTTP lookup but retain its production callback +// validation, route storage and rate-limit registration. +$manager->httpHandler = (new ReflectionClass(HttpHandler::class))->newInstanceWithoutConstructor(); +(new ReflectionMethod(HttpServiceManager::class, '__generateLauncherDirectoryEndpoint'))->invoke($manager); +$directory = (new ReflectionProperty(HttpServiceManager::class, 'launcherDirectory'))->getValue($manager); +startupCheck($directory instanceof Directory && $directory->hasProvider('civ13.com'), 'Valid pre-init registry disabled directory registration'); +$handlers = $manager->httpHandler->get('handlers'); +startupCheck(isset($handlers[Directory::PATH]), 'Directory route was not registered'); +$response = $handlers[Directory::PATH](new ServerRequest('GET', 'https://civ13.com'.Directory::PATH), Directory::PATH, false); +startupCheck($response->getStatusCode() === 503, 'Registered directory should warm up without probing during construction'); +startupCheck((new ReflectionProperty(HttpServiceManager::class, 'launcherDirectoryTimer'))->getValue($manager) === null, 'Directory timer started before HTTP readiness'); + +// The directory must consume real readonly-string GameServer ports and publish +// JSON integers. Only the status transport/clock are injected for this check. +$probe = static fn () => resolve(['players' => 0, 'map_name' => 'Gaia', 'map_gamemode' => 'Nomads', 'round_duration_in_ticks' => 0]); +$directory = new Directory($bot->options['launcher_directory'], $bot->enabled_gameservers, $probe, static fn () => 1800000000); +$directory->refresh(); +startupCheck(array_column($directory->snapshot('civ13.com')['servers'], 'port') === [12345, 12346], 'Real GameServer string ports were not published as integers'); + +echo "PASS directory startup: synchronous real GameServer registration, disabled/bad-directory exclusion, production constructor order, real pre-init HTTP registration, deferred probing and string-port normalization.\n"; From 3ebbfd07380eb4bbef35bd347892eec712d83e64 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 19 Oct 2026 13:36:04 -0400 Subject: [PATCH 3/8] Verify shared Unicode directory contract --- .github/workflows/verifier-integration.yml | 4 ++-- docs/launcher-directory.md | 8 ++++++++ tests/fixtures/server-directory-unicode.json | 1 + tests/server-directory.php | 15 +++++++++++++-- 4 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 tests/fixtures/server-directory-unicode.json diff --git a/.github/workflows/verifier-integration.yml b/.github/workflows/verifier-integration.yml index d8bd5120..1ff09931 100644 --- a/.github/workflows/verifier-integration.yml +++ b/.github/workflows/verifier-integration.yml @@ -9,7 +9,7 @@ on: - 'tests/http-dependency.php' - 'tests/server-directory.php' - 'tests/server-directory-startup.php' - - 'tests/fixtures/server-directory.json' + - 'tests/fixtures/server-directory*.json' - 'tests/economy.php' - 'tests/economy-service.php' - '.github/workflows/verifier-integration.yml' @@ -21,7 +21,7 @@ on: - 'tests/http-dependency.php' - 'tests/server-directory.php' - 'tests/server-directory-startup.php' - - 'tests/fixtures/server-directory.json' + - 'tests/fixtures/server-directory*.json' - 'tests/economy.php' - 'tests/economy-service.php' - '.github/workflows/verifier-integration.yml' diff --git a/docs/launcher-directory.md b/docs/launcher-directory.md index 924e4399..2ef49bd7 100644 --- a/docs/launcher-directory.md +++ b/docs/launcher-directory.md @@ -116,6 +116,14 @@ returns `servers: []`; warmup/stale data returns 503 with Retry-After. Unknown provider/path, query parameters, and non-GET/HEAD methods are rejected. Every response is `Cache-Control: no-store`; no raw status payload is returned. +Text limits are UTF-8 **bytes**: name 120, map 128, mode 80, round ID 80, +and optional epoch 32. Well-formed supplementary-plane characters such as +`🌍` are supported and occupy four bytes. Malformed UTF-8/surrogates, Unicode +control/format characters, and line/paragraph separators are rejected. IDs use +1–64 ASCII letters, digits, underscores or hyphens. Players must be an integer +from 0 to 10,000. The two shared fixtures under `tests/fixtures/` cover ordinary +and supplementary Unicode output against the launcher's production parser. + At bot readiness and every 15 seconds, one non-overlapping sweep asynchronously probes the configured targets (at most 32 concurrent connections). Each probe has a three-second total DNS/connect/read deadline and 64 KiB framing bound. diff --git a/tests/fixtures/server-directory-unicode.json b/tests/fixtures/server-directory-unicode.json new file mode 100644 index 00000000..85213570 --- /dev/null +++ b/tests/fixtures/server-directory-unicode.json @@ -0,0 +1 @@ +{"schema":1,"generated_at":"2026-10-19T17:00:00Z","expires_at":"2026-10-19T17:01:00Z","servers":[{"id":"nomads","name":"Nomads 🌍","host":"civ13.com","port":1715,"engine":"BYOND","online":true,"checked_at":"2026-10-19T17:00:00Z","players":0,"map":"Gaia","mode":"Nomads","round":{"id":"round-42","duration_seconds":1234},"epoch":"1200"}]} diff --git a/tests/server-directory.php b/tests/server-directory.php index d838b826..1313d8fc 100644 --- a/tests/server-directory.php +++ b/tests/server-directory.php @@ -60,7 +60,7 @@ public function close() { if (!$this->closed) { $this->closed = true; $this->emi for ($i = 0; $i < strlen($packet); $i++) { directoryCheck(ByondStatusProbe::decodePacket(substr($packet, 0, $i)) === null, 'Fragmented frame did not wait'); } -foreach (["\0\x00\0\x04xxxx", "\0\x83\xff\xff", "\0\x83\0\x01x", $packet.'trailing', substr($packet, 0, -1).'x', str_replace("\x06", "\x2a", $packet), statusPacket('false'), statusPacket('[]'), statusPacket('{broken'), statusPacket('{"x":"'.str_repeat('x', 65536).'"}') ] as $bad) { +foreach (["\0\x00\0\x04xxxx", "\0\x83\xff\xff", "\0\x83\0\x01x", $packet.'trailing', substr($packet, 0, -1).'x', str_replace("\x06", "\x2a", $packet), statusPacket('false'), statusPacket('[]'), statusPacket('{broken'), statusPacket('{"x":"\ud800"}'), statusPacket('{"x":"\udc00"}'), statusPacket('{"x":"'."\xed\xa0\x80".'"}'), statusPacket('{"x":"'.str_repeat('x', 65536).'"}') ] as $bad) { directoryThrows(static fn () => ByondStatusProbe::decodePacket($bad)); } @@ -158,7 +158,7 @@ public function connect($uri) { return resolve($this->connection); } directoryCheck($directory->snapshot('valzargaming.com')['servers'][0]['players'] === 7, 'Partial failure lost healthy provider'); // Every malformed mandatory value drops only its server, never invents counts or rounds. -foreach (['players' => [-1, 1.5, '2', true, 10001, []], 'map_name' => ['', "bad\nmap", "bad\u{202e}map", "bad\u{2028}map", str_repeat('x', 129), "\xff"], 'map_gamemode' => [null, [], str_repeat('x', 81)], 'round_duration_in_ticks' => [-1, INF, NAN, '100', null, 2147483648], 'game_id' => [[], false, str_repeat('x', 81)]] as $field => $values) { +foreach (['players' => [-1, 1.5, '2', true, 10001, []], 'map_name' => ['', "bad\nmap", "bad\u{202e}map", "bad\u{2028}map", str_repeat('x', 129), "\xff", "\xed\xa0\x80", str_repeat('x', 125).'🌍'], 'map_gamemode' => [null, [], str_repeat('x', 81)], 'round_duration_in_ticks' => [-1, INF, NAN, '100', null, 2147483648], 'game_id' => [[], false, str_repeat('x', 81)]] as $field => $values) { foreach ($values as $value) { $results[12000] = array_replace($status, [$field => $value]); $directory->refresh(); @@ -171,6 +171,8 @@ public function connect($uri) { return resolve($this->connection); } directoryCheck($directory->snapshot('civ13.com')['servers'][0]['round']['id'] === '42', 'Numeric round ID not normalized'); $results[12000]['round_duration_in_ticks'] = 12345.5; $directory->refresh(); directoryCheck($directory->snapshot('civ13.com')['servers'][0]['round']['duration_seconds'] === 1234, 'Fractional deciseconds were not converted to whole seconds'); +$results[12000]['map_name'] = str_repeat('x', 124).'🌍'; $directory->refresh(); +directoryCheck($directory->snapshot('civ13.com')['servers'][0]['map'] === str_repeat('x', 124).'🌍', 'Valid supplementary Unicode at UTF-8 byte limit was rejected'); // A timer cannot launch overlapping sweeps, even while clients hammer the endpoint. $waiting = new Deferred(); $startedProbes = 0; @@ -203,5 +205,14 @@ public function connect($uri) { return resolve($this->connection); } $fixtureDirectory = new Directory(['civ13.com' => ['nomads']], ['nomads' => $fixtureServer], static fn () => resolve($status), static fn () => strtotime('2026-10-19T17:00:00Z')); $fixtureDirectory->refresh(); directoryCheck($fixtureDirectory->snapshot('civ13.com') === $fixture, 'Cross-language compatibility fixture differs from backend output'); +$unicodeFixture = json_decode(file_get_contents(__DIR__.'/fixtures/server-directory-unicode.json'), true, 16, JSON_THROW_ON_ERROR); +$fixtureServer->name = 'Nomads 🌍'; +$unicodeDirectory = new Directory(['civ13.com' => ['nomads']], ['nomads' => $fixtureServer], static fn () => resolve($status), static fn () => strtotime('2026-10-19T17:00:00Z')); +$unicodeDirectory->refresh(); +directoryCheck($unicodeDirectory->snapshot('civ13.com') === $unicodeFixture, 'Supplementary Unicode fixture differs from backend output'); +$fixtureServer->name = str_repeat('x', 116).'🌍'; +new Directory(['civ13.com' => ['nomads']], ['nomads' => $fixtureServer], $probe, $clock); +$fixtureServer->name = str_repeat('x', 117).'🌍'; +directoryThrows(static fn () => new Directory(['civ13.com' => ['nomads']], ['nomads' => $fixtureServer], $probe, $clock)); echo "PASS directory: configured targets, online/zero-player/offline, partial failure, malformed JSON/status, privacy allowlist, freshness/clock, atomic coalesced sweeps, provider/method/query isolation, fixed BYOND wire protocol, frame bounds and asynchronous deadlines.\n"; From 71e0e3fc2365555590ef182836ea93559087ad67 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 19 Oct 2026 15:41:40 -0400 Subject: [PATCH 4/8] Serve both launcher providers through central status-only discovery --- .github/workflows/verifier-integration.yml | 3 + bot.php | 1 + docs/launcher-directory.md | 159 +++++++++++------- src/Civ13/Civ13.php | 3 + src/Civ13/HttpServiceManager.php | 12 +- .../ServerDirectory/ByondStatusProbe.php | 37 +++- src/Civ13/ServerDirectory/Directory.php | 35 +++- src/Civ13/ServerDirectory/Endpoint.php | 25 ++- src/Civ13/ServerDirectory/PublicResolver.php | 61 +++++++ tests/fixtures/server-directory-vzg.json | 1 + tests/server-directory-remote.php | 151 +++++++++++++++++ tests/server-directory-startup.php | 21 ++- tests/server-directory.php | 17 +- 13 files changed, 443 insertions(+), 83 deletions(-) create mode 100644 src/Civ13/ServerDirectory/PublicResolver.php create mode 100644 tests/fixtures/server-directory-vzg.json create mode 100644 tests/server-directory-remote.php diff --git a/.github/workflows/verifier-integration.yml b/.github/workflows/verifier-integration.yml index 1ff09931..924a1424 100644 --- a/.github/workflows/verifier-integration.yml +++ b/.github/workflows/verifier-integration.yml @@ -9,6 +9,7 @@ on: - 'tests/http-dependency.php' - 'tests/server-directory.php' - 'tests/server-directory-startup.php' + - 'tests/server-directory-remote.php' - 'tests/fixtures/server-directory*.json' - 'tests/economy.php' - 'tests/economy-service.php' @@ -21,6 +22,7 @@ on: - 'tests/http-dependency.php' - 'tests/server-directory.php' - 'tests/server-directory-startup.php' + - 'tests/server-directory-remote.php' - 'tests/fixtures/server-directory*.json' - 'tests/economy.php' - 'tests/economy-service.php' @@ -53,6 +55,7 @@ jobs: - run: php tests/http-dependency.php - run: php tests/server-directory.php - run: php tests/server-directory-startup.php + - run: php tests/server-directory-remote.php - run: php tests/verifier-integration.php - run: php tests/economy.php - run: php tests/economy-service.php diff --git a/bot.php b/bot.php index 8496b68f..3666868d 100644 --- a/bot.php +++ b/bot.php @@ -208,6 +208,7 @@ function loadEnv(string $filePath): void 'verify_url' => getenv('VERIFIER_HOST_ADDR').':'.getenv('VERIFIER_HOST_PORT').'/verified/', // Local/Integrated Verifier Server // 'serverinfo_url' => '', // URL of the serverinfo.json file, defaults to the webserver if left blank // 'launcher_directory' => ['civ13.com' => ['tdm', 'nomads']], // Opt-in public status; see docs/launcher-directory.md + // 'launcher_directory_status_targets' => [], // Separate opt-in status-only inventory; never enables GameServer/pers 'ooc_badwords' => $ooc_badwords, 'ic_badwords' => $ic_badwords, 'folders' => [ diff --git a/docs/launcher-directory.md b/docs/launcher-directory.md index 2ef49bd7..9dc47dbc 100644 --- a/docs/launcher-directory.md +++ b/docs/launcher-directory.md @@ -1,62 +1,97 @@ # Live launcher server directory -The opt-in read-only endpoint is `GET /.well-known/civ13/servers.json`. It uses the -bot's existing ReactPHP HTTP listener. It does not create another listener, -scan ports, start servers, modify server configuration, or expose bot controls. -It is disabled unless `launcher_directory` is explicitly configured. +One opt-in read-only HTTPS authority serves both provider menus through the bot's +existing ReactPHP HTTP listener: + +- `https://civ13.com/.well-known/civ13/servers.json?provider=civ13.com` +- `https://civ13.com/.well-known/civ13/servers.json?provider=valzargaming.com` + +Only those exact raw query strings are accepted. Responses remain separate +schema-1 snapshots, containing only the selected provider's servers. No new HTTPS +service is needed on valzargaming.com. Both inventories trust civ13.com and both +menus become unavailable if that authority is unavailable. The directory never +scans ports, starts games, modifies game configuration, or exposes bot controls. +Both inventory options default to `[]`; the route is disabled when both are empty. ## Deployment requirements -Deployment and public reverse-proxy changes are separate operator actions. This -repository has no game/bot production-deployment workflow; its GitHub Actions -workflows test contracts and publish documentation only. +Deployment, production configuration, and public reverse-proxy changes require +separate operator approval. This repository has no game/bot production-deployment +workflow; its GitHub Actions workflows test contracts and publish documentation. 1. Install the updated bot code and existing Composer dependencies with PHP 8.3+ - using the installation's usual process. No additional dependency is needed. -2. Add a mapping to the bot's `$options`. Each value is a list of keys from that - installation's **enabled** Civ13 `GameServer` registry, for example: + through the installation's usual process. No new dependency is needed. +2. Configure only verified inventory in the bot's `$options`. Existing locally + managed games use keys from the installation's **enabled** GameServer registry: ```php 'launcher_directory' => [ 'civ13.com' => ['tdm', 'nomads'], - // Add valzargaming.com only with its verified, enabled production keys. + ], + 'launcher_directory_status_targets' => [], + ``` + + `tdm` and `nomads` are enabled in checked-in bot.php; that does not establish + the actual production roster. The checked-in VZG `pers` game is disabled. + Do not enable it just to publish discovery. GameServer construction requires a + local basedir and activates other bot behavior, so it is inappropriate for a + remote status-only target. + + The separate status-only list permits exactly five fields per row. This is a + **synthetic example**, not an approved production target or gameplay port: + + ```php + 'launcher_directory_status_targets' => [ + ['id' => 'vzg-fixture', 'name' => 'VZG fixture', + 'host' => 'valzargaming.com', 'port' => 24017, 'engine' => 'BYOND'], ], ``` - `tdm` and `nomads` are enabled in the checked-in `bot.php`; the checked-in - VZG `pers` entry is disabled. That is not evidence of the current production - roster. An operator must inspect the actual installation before enabling the - mapping. `['valzargaming.com' => []]` intentionally reports an empty roster; - do not use that to claim VZG discovery is deployed or tested. A disabled, - duplicate, unknown or invalid key disables the directory and logs a warning. - Up to 32 total targets are allowed. A key belongs to only one provider. -3. Verify that each configured server's existing `ip` and `port` are the desired - BYOND Topic target. The public join address is the mapped provider hostname - plus that same configured port, so the provider DNS and any existing port - forwarding must already route those ports correctly. A server is probed at - its configured address, which can be loopback/private for an on-host bot; - its internal address is never published. No port is specified in the launcher - or repeated in the directory mapping. -4. The game must answer BYOND Topic `?status=1&format=json` on its configured + Operators must verify the real port before adding a row. `host` must be exactly + civ13.com or valzargaming.com, `port` an integer 1–65535, and `engine` BYOND. + No IP override, URL, basedir, enabled flag, credentials, unknown fields or + request-supplied targets are allowed. These rows never construct, start or + enable a GameServer. A status-only row makes its provider configured even if + every probe fails. An explicit mapping such as `['valzargaming.com' => []]` + can intentionally configure an empty provider; it does not prove live discovery. + + Invalid configuration disables the directory and logs a warning. IDs must be + globally unique across both sources; host/port pairs must also be unique. + The same port on different providers is valid. The limit is **32 total targets + across both inventories**, not 32 per provider. +3. Managed targets are probed at their existing configured `ip` and `port`, which + may intentionally be loopback/private for an on-host bot. Internal addresses + are never published. Public joins use the mapped provider hostname and that + same configured port. Existing provider DNS and port routing must match. + + Status-only targets instead resolve the approved hostname afresh on each + probe, using asynchronous system-configured DNS with no application cache or + public IP pin. Consistent with the launcher, they require 1–32 IPv4 A answers; + the entire answer set must be ordinary public addresses. Private, loopback, + shared, link-local, reserved, documentation, benchmark, multicast and other + special-purpose answers are rejected. IPv6-only public targets are not + supported by this launcher contract. TCP uses the selected validated literal + without a second hostname lookup. A DNS change can take effect on the next + sweep, subject to the system resolver's normal behavior. Missing system DNS + fails closed; no fallback DNS provider is invented. +4. Each game must answer BYOND Topic `?status=1&format=json` on its configured gameplay/Topic port with `players`, `map_name`, `map_gamemode`, and `round_duration_in_ticks`. Zero players and a zero-duration lobby are valid. - A JSON `game_id` is recommended; if absent, the public round ID is empty. - A successful TCP handshake alone is not online evidence. -5. On each mapped provider, route the **exact** HTTPS path below to the existing - bot listener, preserving `Host: civ13.com` or `Host: valzargaming.com`: - - - `https://civ13.com/.well-known/civ13/servers.json` - - `https://valzargaming.com/.well-known/civ13/servers.json` + `game_id` is recommended; when absent the public round ID is empty. A successful + TCP handshake alone is not evidence that the game is online. +5. Route only `/.well-known/civ13/servers.json` on the existing **civ13.com** HTTPS + virtual host to the existing bot listener. Preserve `Host: civ13.com` and the + raw query string. The query is only an allowlisted snapshot selector; it never + triggers probes or supplies addresses. Repeated/extra parameters, percent + encodings, URLs, ports, unknown providers and an absent query return 400. Use the site's existing TLS certificate and reverse proxy. Serve GET/HEAD - without redirects, login pages, bot challenges, CDN caching, or stale-on-error - fallback. A normal website redirect to another host must not catch this path. - Do not expose the bot listener or proxy other bot API paths as part of this - change. The endpoint ignores forwarded-host headers and selects providers - only from its actual request URI/Host. An Nginx-style location could be: + without redirects, login pages, bot challenges, caching or stale-on-error + fallback. Do not expose the bot listener or proxy unrelated bot API paths. + The endpoint ignores forwarded-host headers. An example, for operator review: ```nginx - # Inside the existing civ13.com HTTPS virtual host. Adapt only after review. + # Inside the existing civ13.com HTTPS virtual host. location = /.well-known/civ13/servers.json { proxy_pass http://127.0.0.1:55555; proxy_set_header Host civ13.com; @@ -65,22 +100,20 @@ workflows test contracts and publish documentation only. } ``` - `55555` is the bot's checked-in default HTTP port, not a game-port discovery - list. Substitute the verified listener for the installation. Use - `Host valzargaming.com` in that provider's virtual host. Do not change - listeners, firewalls, TLS/security settings, or existing proxy trust rules - without the operator's separate approval. Apply any per-client rate limiting - at the trusted public proxy. The existing bot handler also rate-limits this - endpoint to 600 requests/minute per observed remote address; behind a proxy, - clients may share that budget. Do not trust new forwarded-IP headers merely - to bypass that limit. -6. Restart the bot through its existing approved deployment process. Verify each - HTTPS endpoint returns status 200 and `application/json`, contains only the - corresponding provider, and has fresh timestamps. Then verify a known live - server, a zero-player server, and an offline configured server. Compare counts, - map/mode and round duration with the game or `/players`; do not use old bot - file-cache data as proof of liveness. Neither code merge nor offline CI proves - this live deployment acceptance. + `55555` is the checked-in default bot HTTP port, not a game-port discovery + list. Substitute the verified existing listener. No VZG HTTPS route is needed. + Do not change listeners, firewalls, TLS/security settings or proxy trust rules + without separate approval. Apply per-client rate limiting at the trusted + public proxy. The bot also limits the endpoint to 600 requests/minute per + observed remote address; clients behind a proxy may share that budget. Do not + newly trust forwarded-IP headers merely to bypass it. +6. Restart through the approved deployment process. Verify both exact URLs return + status 200, application/json and fresh provider-specific records. A configured + provider with no verified online games returns 200 with `servers: []`; an + unconfigured provider returns 404. Warmup/stale sweeps return 503. Test a known + live, zero-player and offline configured game, and compare counts/map/mode/ + duration with the actual game or `/players`. Old cached files, merged code, + and offline CI do not establish live acceptance. ## JSON contract (schema 1) @@ -108,12 +141,12 @@ workflows test contracts and publish documentation only. } ``` -The port above is an illustrative response, read from the server registry at +The port above is illustrative, obtained from explicit operator inventory at runtime. All timestamps use UTC `yyyy-MM-ddTHH:mm:ssZ`. The envelope is valid for at most 60 seconds and never past the oldest included observation's expiry. HTTP reads cannot extend observations' lifetimes. An empty successful sweep returns `servers: []`; warmup/stale data returns 503 with Retry-After. Unknown -provider/path, query parameters, and non-GET/HEAD methods are rejected. Every +authority/path, noncanonical provider queries, and non-GET/HEAD methods are rejected. Every response is `Cache-Control: no-store`; no raw status payload is returned. Text limits are UTF-8 **bytes**: name 120, map 128, mode 80, round ID 80, @@ -121,8 +154,8 @@ and optional epoch 32. Well-formed supplementary-plane characters such as `🌍` are supported and occupy four bytes. Malformed UTF-8/surrogates, Unicode control/format characters, and line/paragraph separators are rejected. IDs use 1–64 ASCII letters, digits, underscores or hyphens. Players must be an integer -from 0 to 10,000. The two shared fixtures under `tests/fixtures/` cover ordinary -and supplementary Unicode output against the launcher's production parser. +from 0 to 10,000. The shared fixtures under `tests/fixtures/` cover managed, supplementary Unicode, +and status-only VZG output against the launcher's production parser. At bot readiness and every 15 seconds, one non-overlapping sweep asynchronously probes the configured targets (at most 32 concurrent connections). Each probe @@ -161,3 +194,11 @@ normalization, and pre-init route registration without probes. A source-order guard covers `Civ13::afterConstruct`: the game-server constructor loop runs before `HttpServiceManager` construction. This test bypasses bot startup, the HTTP handler's external-IP lookup, and live listening. + +`php tests/server-directory-remote.php` verifies the strict status-only schema, +unknown-field rejection, cross-source identity/host-port uniqueness, global +32-target bounds, slow-peer freshness, overlapping-sweep coalescing, partial +failures, dynamic DNS and validation before literal TCP connection. Both DNS and +TCP are injected, so the suite performs no external network I/O. The startup +suite also checks remote-only registration with empty GameServer registries and +no added lifecycle callbacks. All three suites run on PHP 8.3, 8.4 and 8.5 in CI. diff --git a/src/Civ13/Civ13.php b/src/Civ13/Civ13.php index 3702e8cc..5a5925c5 100644 --- a/src/Civ13/Civ13.php +++ b/src/Civ13/Civ13.php @@ -487,6 +487,7 @@ private function resolveOptions(array &$options = []): array 'http_port', 'http_whitelist', 'launcher_directory', + 'launcher_directory_status_targets', 'ic_badwords', 'legacy', 'moderate', @@ -528,6 +529,7 @@ private function resolveOptions(array &$options = []): array 'http_port' => 0, 'http_whitelist' => [], 'launcher_directory' => [], + 'launcher_directory_status_targets' => [], 'ic_badwords' => [], 'legacy' => true, 'moderate' => true, @@ -570,6 +572,7 @@ private function resolveOptions(array &$options = []): array ->setAllowedTypes('http_port', 'int') ->setAllowedTypes('http_whitelist', 'array') ->setAllowedTypes('launcher_directory', 'array') + ->setAllowedTypes('launcher_directory_status_targets', 'array') ->setAllowedTypes('ic_badwords', 'array') ->setAllowedTypes('legacy', 'bool') ->setAllowedTypes('moderate', 'bool') diff --git a/src/Civ13/HttpServiceManager.php b/src/Civ13/HttpServiceManager.php index baae662a..7a3ceb78 100644 --- a/src/Civ13/HttpServiceManager.php +++ b/src/Civ13/HttpServiceManager.php @@ -28,7 +28,6 @@ use React\Http\Message\Response as HttpResponse; use React\Promise\PromiseInterface; use React\Socket\SocketServer; -use React\Socket\Connector; class HttpServiceManager { @@ -1688,14 +1687,21 @@ function (ServerRequestInterface $request, string $endpoint, bool $whitelisted) /** Opt-in public status only. No endpoint input can select a probe address or port. */ private function __generateLauncherDirectoryEndpoint(): void { - if (!$providers = $this->civ13->options['launcher_directory'] ?? []) { + $providers = $this->civ13->options['launcher_directory'] ?? []; + $statusTargets = $this->civ13->options['launcher_directory_status_targets'] ?? []; + if (!$providers && !$statusTargets) { return; } try { $this->launcherDirectory = new Directory( $providers, $this->civ13->enabled_gameservers, - new ByondStatusProbe($this->civ13->loop, new Connector(['timeout' => ByondStatusProbe::TIMEOUT_SECONDS], $this->civ13->loop)) + new ByondStatusProbe( + $this->civ13->loop, + ByondStatusProbe::createConnector($this->civ13->loop), + ByondStatusProbe::createConnector($this->civ13->loop, publicOnly: true) + ), + statusTargets: $statusTargets ); } catch (\InvalidArgumentException $error) { $this->logger->warning('Launcher directory disabled: '.$error->getMessage()); diff --git a/src/Civ13/ServerDirectory/ByondStatusProbe.php b/src/Civ13/ServerDirectory/ByondStatusProbe.php index 32853969..92a8918c 100644 --- a/src/Civ13/ServerDirectory/ByondStatusProbe.php +++ b/src/Civ13/ServerDirectory/ByondStatusProbe.php @@ -5,10 +5,16 @@ namespace Civ13\ServerDirectory; use React\EventLoop\LoopInterface; +use React\Dns\Config\Config as DnsConfig; +use React\Dns\Resolver\Factory as DnsFactory; +use React\Dns\Resolver\ResolverInterface; use React\Promise\Deferred; use React\Promise\PromiseInterface; use React\Socket\ConnectionInterface; use React\Socket\ConnectorInterface; +use React\Socket\Connector; + +use function React\Promise\reject; /** One fixed, read-only BYOND Topic query. No HTTP, credentials or arbitrary queries. */ final class ByondStatusProbe @@ -16,12 +22,35 @@ final class ByondStatusProbe public const MAX_PACKET_BYTES = 65536; public const TIMEOUT_SECONDS = 3.0; - public function __construct(private LoopInterface $loop, private ConnectorInterface $connector) + public function __construct(private LoopInterface $loop, private ConnectorInterface $connector, private ?ConnectorInterface $publicConnector = null) { } - public function __invoke(string $address, int $port): PromiseInterface + /** No application DNS cache or startup IP pin; hostname probes resolve again each sweep. */ + public static function createConnector(LoopInterface $loop, ?ResolverInterface $resolver = null, ?ConnectorInterface $tcp = null, bool $publicOnly = false): ConnectorInterface { + if ($resolver === null) { + // Read system resolver configuration once at setup, never synchronously + // resolve a public hostname on the event loop or invent a DNS server. + $config = DnsConfig::loadSystemConfigBlocking(); + $resolver = $config->nameservers + ? (new DnsFactory())->create($config, $loop) + : new class implements ResolverInterface { + public function resolve($domain) { return reject(new \RuntimeException('System DNS unavailable')); } + public function resolveAll($domain, $type) { return reject(new \RuntimeException('System DNS unavailable')); } + }; + } + if ($publicOnly) { + $resolver = new PublicResolver($resolver); + } + return new Connector(['dns' => $resolver, 'tcp' => $tcp ?? true, 'tls' => false, 'unix' => false, 'happy_eyeballs' => !$publicOnly, 'timeout' => self::TIMEOUT_SECONDS], $loop); + } + + public function __invoke(string $address, int $port, bool $publicOnly = false): PromiseInterface + { + if ($publicOnly && ($this->publicConnector === null || !in_array($address, Directory::PROVIDERS, true))) { + return reject(new \InvalidArgumentException('Public probe must use an approved hostname and public-only connector')); + } $deferred = new Deferred(); $connection = null; $connecting = null; @@ -43,10 +72,10 @@ public function __invoke(string $address, int $port): PromiseInterface $error === null ? $deferred->resolve($status) : $deferred->reject($error); }; $timer = $this->loop->addTimer(self::TIMEOUT_SECONDS, static fn () => $finish(null, new \RuntimeException('Status probe timed out'))); - // Targets come exclusively from the bot's configured server registry. + // Targets come exclusively from operator-configured managed/status-only inventories. $host = str_contains($address, ':') ? '['.$address.']' : $address; try { - $connecting = $this->connector->connect('tcp://'.$host.':'.$port); + $connecting = ($publicOnly ? $this->publicConnector : $this->connector)->connect('tcp://'.$host.':'.$port); $connecting->then(function (ConnectionInterface $stream) use (&$connection, &$settled, $finish): void { if ($settled) { $stream->close(); diff --git a/src/Civ13/ServerDirectory/Directory.php b/src/Civ13/ServerDirectory/Directory.php index 23ef533d..07c35590 100644 --- a/src/Civ13/ServerDirectory/Directory.php +++ b/src/Civ13/ServerDirectory/Directory.php @@ -14,6 +14,7 @@ final class Directory { public const PATH = '/.well-known/civ13/servers.json'; + public const AUTHORITY = 'civ13.com'; public const PROVIDERS = ['civ13.com', 'valzargaming.com']; public const MAX_SERVERS = 32; public const MAX_AGE_SECONDS = 60; @@ -27,20 +28,21 @@ final class Directory /** * @param array> $providers Explicit provider => configured server keys. * @param array $registry The bot's enabled_gameservers, not request data. - * @param callable(string, int): PromiseInterface $probe + * @param callable(string, int, bool): PromiseInterface $probe The third argument requires public-only DNS for status-only rows. * @param callable(): int|null $clock Test seam; production uses UTC wall-clock seconds. + * @param list $statusTargets Operator-only, status-only inventory; never constructs GameServer. */ - public function __construct(array $providers, array $registry, private $probe, private $clock = null) + public function __construct(array $providers, array $registry, private $probe, private $clock = null, array $statusTargets = []) { $this->clock ??= time(...); $count = 0; $seen = []; + $ports = []; foreach ($providers as $host => $keys) { if (!in_array($host, self::PROVIDERS, true) || !is_array($keys) || !array_is_list($keys)) { throw new \InvalidArgumentException('Invalid launcher directory provider configuration'); } $this->targets[$host] = []; - $ports = []; foreach ($keys as $key) { if (!is_string($key) || !preg_match('/\A[a-zA-Z0-9_-]{1,64}\z/D', $key) || isset($seen[$key]) || !isset($registry[$key])) { throw new \InvalidArgumentException('Directory keys must uniquely name configured enabled servers'); @@ -55,13 +57,34 @@ public function __construct(array $providers, array $registry, private $probe, p if (!is_string($address) || strlen($address) > 253 || (!filter_var($address, FILTER_VALIDATE_IP) && !preg_match('/\A(?=.{1,253}\z)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\z/D', $address))) { throw new \InvalidArgumentException('Invalid configured probe address'); } - if ($port === false || isset($ports[$port]) || self::text($server->name ?? null, 120) === null || ++$count > self::MAX_SERVERS) { + if ($port === false || isset($ports[$host][$port]) || self::text($server->name ?? null, 120) === null || ++$count > self::MAX_SERVERS) { throw new \InvalidArgumentException('Invalid directory server or too many servers'); } - $ports[$port] = true; + $ports[$host][$port] = true; $this->targets[$host][] = ['id' => $key, 'name' => $server->name, 'host' => $host, 'port' => $port, 'address' => $address]; } } + if (!array_is_list($statusTargets)) { + throw new \InvalidArgumentException('Status-only targets must be a list'); + } + foreach ($statusTargets as $target) { + // This is deliberately not a GameServer config: no basedir, IP, URL, + // lifecycle flag, credentials or other permissive extension fields. + if (!is_array($target) || count($target) !== 5 || array_diff(array_keys($target), ['id', 'name', 'host', 'port', 'engine'])) { + throw new \InvalidArgumentException('Invalid status-only target fields'); + } + $id = $target['id']; + $host = $target['host']; + $port = $target['port']; + if (!is_string($id) || !preg_match('/\A[a-zA-Z0-9_-]{1,64}\z/D', $id) || isset($seen[$id]) || + !in_array($host, self::PROVIDERS, true) || !is_int($port) || $port < 1 || $port > 65535 || + isset($ports[$host][$port]) || $target['engine'] !== 'BYOND' || self::text($target['name'], 120) === null || ++$count > self::MAX_SERVERS) { + throw new \InvalidArgumentException('Invalid or duplicate status-only target'); + } + $seen[$id] = true; + $ports[$host][$port] = true; + $this->targets[$host][] = ['id' => $id, 'name' => $target['name'], 'host' => $host, 'port' => $port, 'address' => $host, 'public_only' => true]; + } } /** One bounded sweep; overlapping timers share it and HTTP requests never trigger probes. */ @@ -77,7 +100,7 @@ public function refresh(): PromiseInterface foreach ($this->targets as $targets) { foreach ($targets as $target) { try { - $request = ($this->probe)($target['address'], $target['port']); + $request = ($this->probe)($target['address'], $target['port'], $target['public_only'] ?? false); $requests[] = $request->then(function ($status) use ($target): ?array { return is_array($status) ? self::entry($target, $status, ($this->clock)()) : null; }, static fn () => null); diff --git a/src/Civ13/ServerDirectory/Endpoint.php b/src/Civ13/ServerDirectory/Endpoint.php index a6b5f019..c028f956 100644 --- a/src/Civ13/ServerDirectory/Endpoint.php +++ b/src/Civ13/ServerDirectory/Endpoint.php @@ -15,18 +15,29 @@ public function __construct(private Directory $directory) public function handle(ServerRequestInterface $request): Response { + $errorBody = static fn (string $code): string => $request->getMethod() === 'HEAD' ? '' : json_encode(['error' => $code], JSON_THROW_ON_ERROR); $headers = ['Content-Type' => 'application/json; charset=utf-8', 'Cache-Control' => 'no-store', 'X-Content-Type-Options' => 'nosniff']; if (!in_array($request->getMethod(), ['GET', 'HEAD'], true)) { - return new Response(405, $headers + ['Allow' => 'GET, HEAD'], '{"error":"method_not_allowed"}'); + return new Response(405, $headers + ['Allow' => 'GET, HEAD'], $errorBody('method_not_allowed')); } - if ($request->getUri()->getPath() !== Directory::PATH || !$this->directory->hasProvider($request->getUri()->getHost())) { - return new Response(404, $headers, '{"error":"not_found"}'); + if ($request->getUri()->getPath() !== Directory::PATH || $request->getUri()->getHost() !== Directory::AUTHORITY) { + return new Response(404, $headers, $errorBody('not_found')); } - if ($request->getUri()->getQuery() !== '') { - return new Response(400, $headers, '{"error":"query_parameters_not_supported"}'); + // Match raw canonical query bytes: no decoding, repeated parameters, + // extra fields, URLs, ports or client-supplied probe destinations. + $provider = match ($request->getUri()->getQuery()) { + 'provider=civ13.com' => 'civ13.com', + 'provider=valzargaming.com' => 'valzargaming.com', + default => null, + }; + if ($provider === null) { + return new Response(400, $headers, $errorBody('invalid_provider_query')); } - if (($snapshot = $this->directory->snapshot($request->getUri()->getHost())) === null) { - return new Response(503, $headers + ['Retry-After' => (string) Directory::REFRESH_SECONDS], $request->getMethod() === 'HEAD' ? '' : '{"error":"directory_unavailable"}'); + if (!$this->directory->hasProvider($provider)) { + return new Response(404, $headers, $errorBody('provider_unconfigured')); + } + if (($snapshot = $this->directory->snapshot($provider)) === null) { + return new Response(503, $headers + ['Retry-After' => (string) Directory::REFRESH_SECONDS], $errorBody('directory_unavailable')); } return new Response(200, $headers, $request->getMethod() === 'HEAD' ? '' : json_encode($snapshot, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)); } diff --git a/src/Civ13/ServerDirectory/PublicResolver.php b/src/Civ13/ServerDirectory/PublicResolver.php new file mode 100644 index 00000000..eabc7632 --- /dev/null +++ b/src/Civ13/ServerDirectory/PublicResolver.php @@ -0,0 +1,61 @@ +resolveAll($domain, Message::TYPE_A)->then(static function (array $addresses): string { + sort($addresses, SORT_STRING); + return $addresses[0]; + }); + } + + public function resolveAll($domain, $type) + { + if (!in_array($domain, Directory::PROVIDERS, true) || $type !== Message::TYPE_A) { + return reject(new \UnexpectedValueException('Public game probes require approved hostnames and IPv4 A records')); + } + return $this->resolver->resolveAll($domain, $type)->then(static function ($addresses): array { + if (!is_array($addresses) || !array_is_list($addresses) || !$addresses || count($addresses) > 32) { + throw new \UnexpectedValueException('Invalid public DNS answer count'); + } + foreach ($addresses as $address) { + self::validate($address); + } + return $addresses; + }); + } + + private static function validate(mixed $address): void + { + if (!is_string($address) || filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_GLOBAL_RANGE) === false) { + throw new \UnexpectedValueException('Non-global IPv4 DNS answer rejected'); + } + // PHP's global-range filter omits multicast and allows some special + // assignments. Match the launcher's conservative special-purpose denylist. + $bytes = array_map('intval', explode('.', $address)); + if ($bytes[0] >= 224 || ($bytes[0] === 192 && ( + ($bytes[1] === 0 && $bytes[2] === 0) || + ($bytes[1] === 31 && $bytes[2] === 196) || + ($bytes[1] === 52 && $bytes[2] === 193) || + ($bytes[1] === 88 && $bytes[2] === 99) || + ($bytes[1] === 175 && $bytes[2] === 48) + ))) { + throw new \UnexpectedValueException('Special-purpose IPv4 DNS answer rejected'); + } + } +} diff --git a/tests/fixtures/server-directory-vzg.json b/tests/fixtures/server-directory-vzg.json new file mode 100644 index 00000000..fafb3476 --- /dev/null +++ b/tests/fixtures/server-directory-vzg.json @@ -0,0 +1 @@ +{"schema":1,"generated_at":"2026-10-19T17:00:00Z","expires_at":"2026-10-19T17:01:00Z","servers":[{"id":"vzg-fixture","name":"VZG fixture","host":"valzargaming.com","port":24017,"engine":"BYOND","online":true,"checked_at":"2026-10-19T17:00:00Z","players":0,"map":"Gaia","mode":"Nomads","round":{"id":"round-42","duration_seconds":1234},"epoch":"1200"}]} diff --git a/tests/server-directory-remote.php b/tests/server-directory-remote.php new file mode 100644 index 00000000..0df98f5e --- /dev/null +++ b/tests/server-directory-remote.php @@ -0,0 +1,151 @@ + 0, 'map_name' => 'Gaia', 'map_gamemode' => 'Nomads', 'game_id' => 'round-42', 'round_duration_in_ticks' => 12345, 'map_age' => 1200]; +$now = 1800000000; +$clock = static function () use (&$now): int { return $now; }; +$registry = ['managed' => (object) ['enabled' => true, 'ip' => '127.0.0.1', 'port' => '24017', 'name' => 'Managed fixture']]; +$target = ['id' => 'vzg-fixture', 'name' => 'VZG fixture', 'host' => 'valzargaming.com', 'port' => 24017, 'engine' => 'BYOND']; +$calls = []; $remoteHealthy = true; +$probe = static function ($address, $port, $publicOnly) use (&$calls, &$remoteHealthy, $status) { + $calls[] = [$address, $port, $publicOnly]; + return $publicOnly && !$remoteHealthy ? reject(new RuntimeException('Remote offline')) : resolve($status); +}; +$directory = new Directory(['civ13.com' => ['managed']], $registry, $probe, $clock, [$target]); +$directory->refresh(); +remoteCheck($calls === [['127.0.0.1', 24017, false], ['valzargaming.com', 24017, true]], 'Probe destinations/public-only mode differ from explicit inventory'); +remoteCheck(count($directory->snapshot('civ13.com')['servers']) === 1 && count($directory->snapshot('valzargaming.com')['servers']) === 1, 'Same port on different approved hosts should coexist'); +$remoteHealthy = false; $directory->refresh(); +remoteCheck($directory->snapshot('valzargaming.com')['servers'] === [] && count($directory->snapshot('civ13.com')['servers']) === 1, 'Remote failure retained stale entry or removed managed server'); + +foreach (['ip' => '127.0.0.1', 'url' => 'https://example.invalid', 'basedir' => '/tmp', 'enabled' => true, 'token' => 'secret'] as $field => $value) { + remoteThrows(static fn () => new Directory([], [], $probe, $clock, [$target + [$field => $value]])); +} +foreach (['host' => ['evil.invalid', 'localhost', '127.0.0.1', 'https://valzargaming.com', 'valzargaming.com:1714', 'VALZARGAMING.COM', 'valzargaming.com.', []], 'port' => [0, 65536, '24017', true, 24017.0], 'engine' => ['OpenDream', 'byond', ''], 'name' => ['', "bad\nname"], 'id' => ['', '../bad']] as $field => $values) { + foreach ($values as $value) { + remoteThrows(static fn () => new Directory([], [], $probe, $clock, [array_replace($target, [$field => $value])])); + } +} +$missing = $target; unset($missing['engine']); +remoteThrows(static fn () => new Directory([], [], $probe, $clock, [$missing])); +remoteThrows(static fn () => new Directory([], [], $probe, $clock, ['named' => $target])); +remoteThrows(static fn () => new Directory([], [], $probe, $clock, [$target, $target])); +remoteThrows(static fn () => new Directory(['civ13.com' => ['managed']], $registry, $probe, $clock, [array_replace($target, ['id' => 'managed'])])); +remoteThrows(static fn () => new Directory(['civ13.com' => ['managed']], $registry, $probe, $clock, [array_replace($target, ['host' => 'civ13.com'])])); +$many = []; +for ($i = 0; $i < 31; $i++) { $many[] = array_replace($target, ['id' => 'remote'.$i, 'port' => 25000 + $i]); } +new Directory(['civ13.com' => ['managed']], $registry, $probe, $clock, $many); +$many[] = array_replace($target, ['id' => 'remote31', 'port' => 25031]); +remoteThrows(static fn () => new Directory(['civ13.com' => ['managed']], $registry, $probe, $clock, $many)); + +// An early local result must never acquire the later completion time of a peer. +$late = new Deferred(); $sweeps = 0; +$slow = new Directory(['civ13.com' => ['managed']], $registry, static function ($address) use ($late, $status, &$sweeps) { $sweeps++; return $address === '127.0.0.1' ? resolve($status) : $late->promise(); }, $clock, [$target]); +$pending = $slow->refresh(); remoteCheck($pending === $slow->refresh() && $sweeps === 2, 'Overlapping mixed-source sweep launched'); +$early = $now; $now += 59; $late->resolve($status); +remoteCheck(strtotime($slow->snapshot('civ13.com')['expires_at']) === $early + 60, 'Batch completion extended earlier probe lifetime'); +remoteCheck(strtotime($slow->snapshot('valzargaming.com')['servers'][0]['checked_at']) === $now, 'Remote completion timestamp was lost'); +$now += 2; +remoteCheck($slow->snapshot('civ13.com')['servers'] === [] && count($slow->snapshot('valzargaming.com')['servers']) === 1, 'Old entry survived peer freshness'); + +final class ChangingDirectoryResolver implements ResolverInterface +{ + public array $addresses = ['93.184.215.14']; + public int $calls = 0; + public function resolve($domain) { $this->calls++; return resolve($this->addresses[0]); } + public function resolveAll($domain, $type) { $this->calls++; return $type === Message::TYPE_A ? resolve($this->addresses) : reject(new RuntimeException('No fixture AAAA record')); } +} +$raw = new ChangingDirectoryResolver(); $public = new PublicResolver($raw); +foreach (['127.0.0.1', '10.0.0.1', '172.16.0.1', '192.168.0.1', '169.254.0.1', '0.0.0.0', '100.64.0.1', '192.0.0.9', '192.88.99.1', '192.31.196.1', '192.52.193.1', '192.175.48.1', '192.0.2.1', '198.18.0.1', '198.51.100.1', '203.0.113.1', '224.0.0.1', '240.0.0.1', '255.255.255.255', '::', '::1', 'fc00::1', 'fe80::1', 'ff02::1', '2001:db8::1', '2001::1', '2002:7f00:1::1', '3fff::1', '4000::1', '64:ff9b::7f00:1', '::ffff:127.0.0.1', '2606:4700:4700::1111'] as $invalid) { + $raw->addresses = [$invalid]; $failed = false; + $public->resolve('valzargaming.com')->then(null, static function () use (&$failed) { $failed = true; }); + remoteCheck($failed, 'Non-global address accepted: '.$invalid); + $raw->addresses = ['93.184.215.14', $invalid]; $failed = false; + $public->resolveAll('valzargaming.com', Message::TYPE_A)->then(null, static function () use (&$failed) { $failed = true; }); + remoteCheck($failed, 'Mixed public/non-global answer was accepted'); +} + +foreach (['93.184.215.14', '93.184.215.15'] as $valid) { + $raw->addresses = [$valid]; $actual = null; + $public->resolve('valzargaming.com')->then(static function ($address) use (&$actual) { $actual = $address; }); + remoteCheck($actual === $valid, 'Ordinary public IPv4 rejected'); +} + +foreach ([[], array_fill(0, 33, '93.184.215.14'), ['not-a-list' => '93.184.215.14']] as $invalidAnswers) { + $raw->addresses = $invalidAnswers; $failed = false; + $public->resolve('valzargaming.com')->then(null, static function () use (&$failed) { $failed = true; }); + remoteCheck($failed, 'Unbounded/malformed DNS answers accepted'); +} + +foreach (['evil.invalid', '127.0.0.1', 'VALZARGAMING.COM'] as $domain) { + $before = $raw->calls; $failed = false; + $public->resolve($domain)->then(null, static function () use (&$failed) { $failed = true; }); + remoteCheck($failed && $raw->calls === $before, 'Unapproved hostname reached DNS'); +} + +final class RemoteDirectoryConnection extends EventEmitter implements ConnectionInterface +{ + public bool $closed = false; + public function __construct(private StreamSelectLoop $loop, private string $reply) {} + public function isReadable() { return !$this->closed; } + public function isWritable() { return !$this->closed; } + public function pause() {} + public function resume() {} + public function pipe(WritableStreamInterface $dest, array $options = []) { return Util::pipe($this, $dest, $options); } + public function write($data) { $this->loop->futureTick(fn () => $this->emit('data', [$this->reply])); return !$this->closed; } + public function getRemoteAddress() { return 'tcp://93.184.215.14:24017'; } + public function getLocalAddress() { return 'tcp://127.0.0.1:1'; } + public function end($data = null) { $this->close(); } + public function close() { if (!$this->closed) { $this->closed = true; $this->emit('close'); $this->removeAllListeners(); } } +} +$loop = new StreamSelectLoop(); +$body = "\x06".json_encode($status, JSON_THROW_ON_ERROR)."\0"; +$wire = "\0\x83".pack('n', strlen($body)).$body; +$tcp = new class($loop, $wire) implements ConnectorInterface { + public array $uris = []; + public function __construct(private StreamSelectLoop $loop, private string $reply) {} + public function connect($uri) { $this->uris[] = $uri; return resolve(new RemoteDirectoryConnection($this->loop, $this->reply)); } +}; +$resolver = new ChangingDirectoryResolver(); +$connector = ByondStatusProbe::createConnector($loop, $resolver, $tcp, true); +$probe = new ByondStatusProbe($loop, $tcp, $connector); +foreach (['93.184.215.14', '93.184.215.15'] as $address) { + $resolver->addresses = [$address]; $before = $resolver->calls; $value = null; $error = null; + $probe('valzargaming.com', 24017, true)->then(static function ($result) use (&$value) { $value = $result; }, static function ($failure) use (&$error) { $error = $failure; }); + $loop->run(); + remoteCheck($value === $status && $error === null && $resolver->calls > $before, 'DNS was pinned/cached across probes or valid reply failed'); + remoteCheck(parse_url(end($tcp->uris), PHP_URL_HOST) === $address, 'TCP did not use the validated address'); +} +$resolver->addresses = ['127.0.0.1']; $count = count($tcp->uris); $failed = false; +$probe('valzargaming.com', 24017, true)->then(null, static function () use (&$failed) { $failed = true; }); $loop->run(); +remoteCheck($failed && count($tcp->uris) === $count, 'Private DNS reached TCP'); +$failed = false; $probe('127.0.0.1', 24017, true)->then(null, static function () use (&$failed) { $failed = true; }); +remoteCheck($failed && count($tcp->uris) === $count, 'Public-only probe accepted a literal address'); + +$fixture = json_decode(file_get_contents(__DIR__.'/fixtures/server-directory-vzg.json'), true, 16, JSON_THROW_ON_ERROR); +$directory = new Directory([], [], static fn () => resolve($status), static fn () => strtotime('2026-10-19T17:00:00Z'), [$target]); +$directory->refresh(); remoteCheck($directory->snapshot('valzargaming.com') === $fixture, 'Shared VZG fixture differs from status-only projection'); +echo "PASS remote directory: canonical configured inventory, no GameServer activation, cross-source uniqueness/bounds, partial failures, per-probe freshness, dynamic public DNS and validated TCP binding.\n"; diff --git a/tests/server-directory-startup.php b/tests/server-directory-startup.php index 9317b13c..2a80d8b4 100644 --- a/tests/server-directory-startup.php +++ b/tests/server-directory-startup.php @@ -3,7 +3,7 @@ declare(strict_types=1); // Real GameServer construction and HTTP route registration, with I/O boundaries -// bypassed. No .env, bot startup, Discord initialization, DNS, sockets or files. +// bypassed. No .env, bot startup, Discord initialization, DNS queries or sockets. require $argv[1] ?? __DIR__.'/../vendor/autoload.php'; use Civ13\Civ13; @@ -76,7 +76,7 @@ public function deferUntilReady(callable $callback, ?string $function = null): v startupCheck($directory instanceof Directory && $directory->hasProvider('civ13.com'), 'Valid pre-init registry disabled directory registration'); $handlers = $manager->httpHandler->get('handlers'); startupCheck(isset($handlers[Directory::PATH]), 'Directory route was not registered'); -$response = $handlers[Directory::PATH](new ServerRequest('GET', 'https://civ13.com'.Directory::PATH), Directory::PATH, false); +$response = $handlers[Directory::PATH](new ServerRequest('GET', 'https://civ13.com'.Directory::PATH.'?provider=civ13.com'), Directory::PATH, false); startupCheck($response->getStatusCode() === 503, 'Registered directory should warm up without probing during construction'); startupCheck((new ReflectionProperty(HttpServiceManager::class, 'launcherDirectoryTimer'))->getValue($manager) === null, 'Directory timer started before HTTP readiness'); @@ -87,4 +87,21 @@ public function deferUntilReady(callable $callback, ?string $function = null): v $directory->refresh(); startupCheck(array_column($directory->snapshot('civ13.com')['servers'], 'port') === [12345, 12346], 'Real GameServer string ports were not published as integers'); +// Status-only inventory must register independently without constructing any +// GameServer, enabling pers, adding lifecycle callbacks or requiring a basedir. +$remoteBot = new DirectoryStartupBot(); +$remoteBot->options['launcher_directory_status_targets'] = [ + ['id' => 'remote-fixture', 'name' => 'Remote fixture', 'host' => 'valzargaming.com', 'port' => 24017, 'engine' => 'BYOND'], +]; +$remoteManager = (new ReflectionClass(HttpServiceManager::class))->newInstanceWithoutConstructor(); +$remoteManager->civ13 = $remoteBot; +$remoteManager->logger = $remoteBot->logger; +$remoteManager->httpHandler = (new ReflectionClass(HttpHandler::class))->newInstanceWithoutConstructor(); +(new ReflectionMethod(HttpServiceManager::class, '__generateLauncherDirectoryEndpoint'))->invoke($remoteManager); +$remoteDirectory = (new ReflectionProperty(HttpServiceManager::class, 'launcherDirectory'))->getValue($remoteManager); +startupCheck($remoteDirectory instanceof Directory && $remoteDirectory->hasProvider('valzargaming.com'), 'Status-only inventory required managed GameServer registration'); +startupCheck(!$remoteDirectory->hasProvider('civ13.com'), 'Unconfigured provider was invented'); +startupCheck($remoteBot->gameservers === [] && $remoteBot->enabled_gameservers === [] && $remoteBot->deferred === [], 'Status-only inventory activated a GameServer or lifecycle callback'); +startupCheck((new ReflectionProperty(HttpServiceManager::class, 'launcherDirectoryTimer'))->getValue($remoteManager) === null, 'Status-only probe timer started before readiness'); + echo "PASS directory startup: synchronous real GameServer registration, disabled/bad-directory exclusion, production constructor order, real pre-init HTTP registration, deferred probing and string-port normalization.\n"; diff --git a/tests/server-directory.php b/tests/server-directory.php index 1313d8fc..631f5058 100644 --- a/tests/server-directory.php +++ b/tests/server-directory.php @@ -131,7 +131,7 @@ public function connect($uri) { return resolve($this->connection); } $clock = static function () use (&$now): int { return $now; }; $directory = new Directory(['civ13.com' => ['zero', 'offline', 'bad'], 'valzargaming.com' => ['other']], $registry, $probe, $clock); $endpoint = new Endpoint($directory); -$request = static fn ($method = 'GET', $host = 'civ13.com', $suffix = '') => new ServerRequest($method, 'https://'.$host.Directory::PATH.$suffix); +$request = static fn ($method = 'GET', $host = 'civ13.com', $suffix = '?provider=civ13.com') => new ServerRequest($method, 'https://'.$host.Directory::PATH.$suffix); directoryCheck($directory->snapshot('civ13.com') === null && $endpoint->handle($request())->getStatusCode() === 503 && $calls === [], 'Warmup triggered a probe or reported online'); $directory->refresh(); $snapshot = $directory->snapshot('civ13.com'); @@ -145,9 +145,20 @@ public function connect($uri) { return resolve($this->connection); } directoryCheck(!str_contains($json, 'private-') && !str_contains($json, '127.0.0.1') && !str_contains($json, '_checked'), 'Private status or internal fields leaked'); directoryCheck(json_decode($json, true, 16, JSON_THROW_ON_ERROR) === $snapshot && strlen($json) < 65536, 'Invalid/oversized public JSON'); directoryCheck($snapshot['generated_at'] === gmdate('Y-m-d\TH:i:s\Z', $now) && strtotime($snapshot['expires_at']) === $now + 60, 'Timestamp contract differs'); -foreach ([['POST', 'civ13.com', '', 405], ['GET', 'evil.invalid', '', 404], ['GET', 'civ13.com', '?host=evil.invalid&port=1', 400], ['GET', 'civ13.com', '/extra', 404]] as [$method, $host, $suffix, $expected]) { +foreach ([['POST', 'civ13.com', '?provider=civ13.com', 405], ['GET', 'evil.invalid', '?provider=civ13.com', 404], ['GET', 'valzargaming.com', '?provider=valzargaming.com', 404], ['GET', 'civ13.com', '?host=evil.invalid&port=1', 400], ['GET', 'civ13.com', '/extra?provider=civ13.com', 404]] as [$method, $host, $suffix, $expected]) { directoryCheck($endpoint->handle($request($method, $host, $suffix))->getStatusCode() === $expected, 'Request allowlist failed'); } +foreach (['', '?provider=evil.invalid', '?provider=CIV13.COM', '?provider=civ13.com&provider=civ13.com', '?provider=civ13.com&port=1714', '?provider=%63iv13.com', '?provider=civ13%2ecom', '?provider=https://civ13.com', '?provider=civ13.com:1714', '?provider=civ13.com&', '?provider[]=civ13.com', '?provider=civ13.com;port=1714'] as $suffix) { + directoryCheck($endpoint->handle($request('GET', 'civ13.com', $suffix))->getStatusCode() === 400, 'Noncanonical provider query was accepted'); +} +foreach (['', '?provider=unknown'] as $suffix) { + $head = $endpoint->handle($request('HEAD', 'civ13.com', $suffix)); + directoryCheck($head->getStatusCode() === 400 && (string) $head->getBody() === '', 'HEAD query errors returned a body'); +} +$head = $endpoint->handle($request('HEAD', 'evil.invalid')); +directoryCheck($head->getStatusCode() === 404 && (string) $head->getBody() === '', 'HEAD authority errors returned a body'); +$remoteResponse = $endpoint->handle($request('GET', 'civ13.com', '?provider=valzargaming.com')); +directoryCheck($remoteResponse->getStatusCode() === 200 && json_decode((string) $remoteResponse->getBody(), true)['servers'][0]['host'] === 'valzargaming.com', 'Central authority did not select remote provider'); directoryCheck((string) $endpoint->handle($request('HEAD'))->getBody() === '' && count($calls) === 4, 'HEAD/request handlers performed work or returned a body'); $now += 59; directoryCheck(strtotime($directory->snapshot('civ13.com')['expires_at']) === $now + 1, 'Reading cache extended observation lifetime'); @@ -193,6 +204,8 @@ public function connect($uri) { return resolve($this->connection); } } $empty = new Directory(['valzargaming.com' => []], [], $probe, $clock); $empty->refresh(); directoryCheck($empty->snapshot('valzargaming.com')['servers'] === [], 'Explicitly empty provider did not report zero online servers'); +directoryCheck((new Endpoint($empty))->handle($request('GET', 'civ13.com', '?provider=valzargaming.com'))->getStatusCode() === 200, 'Configured empty provider was unavailable'); +directoryCheck((new Endpoint($empty))->handle($request())->getStatusCode() === 404, 'Unconfigured provider was reported as an empty roster'); $large = []; for ($i = 0; $i < 33; $i++) { $large['server'.$i] = clone $registry['zero']; $large['server'.$i]->port = (string) (13000 + $i); } directoryThrows(static fn () => new Directory(['civ13.com' => array_keys($large)], $large, $probe, $clock)); From 886b8652229bd78bb6be3c680de25ec87180085a Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 19 Oct 2026 15:55:42 -0400 Subject: [PATCH 5/8] Add isolated loopback-only directory entrypoint --- .github/workflows/verifier-integration.yml | 6 ++ docs/launcher-directory-standalone.md | 67 +++++++++++++++++++++ docs/launcher-directory.md | 2 + scripts/directory/inventory.example.json | 1 + scripts/directory/serve.php | 68 +++++++++++++++++++++ src/Civ13/ServerDirectory/Standalone.php | 58 ++++++++++++++++++ tests/server-directory-standalone.php | 69 ++++++++++++++++++++++ 7 files changed, 271 insertions(+) create mode 100644 docs/launcher-directory-standalone.md create mode 100644 scripts/directory/inventory.example.json create mode 100644 scripts/directory/serve.php create mode 100644 src/Civ13/ServerDirectory/Standalone.php create mode 100644 tests/server-directory-standalone.php diff --git a/.github/workflows/verifier-integration.yml b/.github/workflows/verifier-integration.yml index 924a1424..0ac1e311 100644 --- a/.github/workflows/verifier-integration.yml +++ b/.github/workflows/verifier-integration.yml @@ -10,6 +10,8 @@ on: - 'tests/server-directory.php' - 'tests/server-directory-startup.php' - 'tests/server-directory-remote.php' + - 'tests/server-directory-standalone.php' + - 'scripts/directory/**' - 'tests/fixtures/server-directory*.json' - 'tests/economy.php' - 'tests/economy-service.php' @@ -23,6 +25,8 @@ on: - 'tests/server-directory.php' - 'tests/server-directory-startup.php' - 'tests/server-directory-remote.php' + - 'tests/server-directory-standalone.php' + - 'scripts/directory/**' - 'tests/fixtures/server-directory*.json' - 'tests/economy.php' - 'tests/economy-service.php' @@ -56,6 +60,8 @@ jobs: - run: php tests/server-directory.php - run: php tests/server-directory-startup.php - run: php tests/server-directory-remote.php + - run: php -l scripts/directory/serve.php + - run: php tests/server-directory-standalone.php - run: php tests/verifier-integration.php - run: php tests/economy.php - run: php tests/economy-service.php diff --git a/docs/launcher-directory-standalone.md b/docs/launcher-directory-standalone.md new file mode 100644 index 00000000..d0f31bf5 --- /dev/null +++ b/docs/launcher-directory-standalone.md @@ -0,0 +1,67 @@ +# Isolated directory runtime + +Use this optional entrypoint only when restarting the existing bot is unsafe or +unnecessary. It composes the same Directory, Endpoint and public-only BYOND +probes; it never constructs Civ13, GameServer or Discord, and has no bot control +routes. It does not replace the existing bot or its configured game lifecycle. + +## Approval and dependency gate + +Deploy a reviewed, immutable source revision to a **separate** directory. Do not +checkout/pull/reset the running bot, replace its vendor tree, touch untracked +custom handlers/secrets, or stop/restart its processes. The operator must first +verify the existing PHP runtime and Composer dependency package versions and +hashes, including every Composer `autoload_files.php` entry. No application-root, +untracked configuration, secret file or unexpected autoload side effect is +acceptable. A separately pinned dependency artifact is preferred; an existing +vendor tree may be reused read-only only after this review and with changes held +until the directory is stopped/revalidated. Merely finding vendor/autoload.php +is not evidence that it is safe or compatible. The preflight must not execute it. + +The source namespace is explicitly prepended to the verified Composer loader, +so the standalone runtime uses its own pinned directory classes rather than an +older bot checkout's classes. Bot.php, .env and secret bootstrap files are never +included. Empty or malformed inventory exits before executing the autoloader. + +## Configuration and operation + +`scripts/directory/inventory.example.json` contains an empty, disabled inventory. +The example's loopback port is illustrative, not a production reservation. The +only permitted top-level fields are integer `listen_port` (1024–65535) and list +`targets`; each target has exactly the five status-only fields documented in +[the directory contract](launcher-directory.md). Both approved public hostnames +are allowed, but only verified game ports should be configured. An unconfigured +provider remains unavailable, not an invented successful empty roster. + +The entrypoint syntax is: + +``` +php scripts/directory/serve.php inventory.json /verified/vendor/autoload.php +``` + +The listener is hardwired to 127.0.0.1. There is no listen-host override. Select +an unused local port after inspecting current sockets. Only the two exact +civ13.com provider-query URLs are served; all game probes require fresh validated +public IPv4 A answers. Inventory never chooses an arbitrary URL or private probe +address. If an on-host public hostname cannot reach its game, inspect routing; +do not bypass the public-address policy by inventing an IP override. + +Use an independently supervised non-root process, with no bot/game descendants +or shared lifecycle dependency. Stop affects only that listener/timer/process. +No process manager configuration is installed by this patch. A shared bounded +600-request/minute budget protects the loopback endpoint without trusting +forwarded IPs; the existing Apache proxy should enforce its normal public limits. +No credentials or raw exceptions/status appear in runtime logs. Controlled +startup diagnostics identify inventory, autoload, runtime or bind phases. + +Before public routing, verify the isolated listener locally using Host civ13.com +and both exact provider queries, fresh timestamps, and real BYOND status. Then +review a narrowly scoped Apache route in the existing civ13.com SSL vhost, +preserving the raw query and Host, bypassing any general redirect, and disabling +caching/stale fallback. Review existing ProxyPass/Rewrite ordering first. Validate +Apache syntax before an approved graceful reload; do not restart Apache, Gaia, +Help, or the old bot. Retain a route/service rollback plan before activation. + +This document is a deployment plan, not permission to change host configuration. +The CI tests validate composition and fail-closed behavior, not host deployment +or successful live game joins. diff --git a/docs/launcher-directory.md b/docs/launcher-directory.md index 9dc47dbc..2448c516 100644 --- a/docs/launcher-directory.md +++ b/docs/launcher-directory.md @@ -13,6 +13,8 @@ menus become unavailable if that authority is unavailable. The directory never scans ports, starts games, modifies game configuration, or exposes bot controls. Both inventory options default to `[]`; the route is disabled when both are empty. +If restarting the live bot is unsafe, the optional [isolated directory runtime](launcher-directory-standalone.md) reuses this contract without booting the bot or games. Its dependency and host-review gates must be satisfied first. + ## Deployment requirements Deployment, production configuration, and public reverse-proxy changes require diff --git a/scripts/directory/inventory.example.json b/scripts/directory/inventory.example.json new file mode 100644 index 00000000..31c2332b --- /dev/null +++ b/scripts/directory/inventory.example.json @@ -0,0 +1 @@ +{"listen_port":25113,"targets":[]} diff --git a/scripts/directory/serve.php b/scripts/directory/serve.php new file mode 100644 index 00000000..c5826b9f --- /dev/null +++ b/scripts/directory/serve.php @@ -0,0 +1,68 @@ + 32768) { + throw new RuntimeException('Inventory unavailable'); + } + $json = file_get_contents($argv[1], false, null, 0, 32769); + if ($json === false || strlen($json) > 32768) { throw new RuntimeException('Inventory over bound'); } + $config = json_decode($json, true, 16, JSON_THROW_ON_ERROR); + if (!is_array($config)) { throw new RuntimeException('Inventory must be an object'); } + require_once __DIR__.'/../../src/Civ13/ServerDirectory/Directory.php'; + require_once __DIR__.'/../../src/Civ13/ServerDirectory/Standalone.php'; + Standalone::validateShape($config); + new Directory([], [], static fn () => throw new LogicException('Validation cannot probe'), statusTargets: $config['targets']); + if ($config['targets'] === []) { fwrite(STDERR, "Directory disabled: inventory is empty.\n"); exit(0); } + $phase = 'autoload'; + $autoload = realpath($argv[2]); + if ($autoload === false || !is_file($autoload) || basename($autoload) !== 'autoload.php') { + throw new RuntimeException('Verified dependency autoloader unavailable'); + } + $loader = require $autoload; + if (!$loader instanceof Composer\Autoload\ClassLoader) { throw new RuntimeException('Composer loader required'); } + // Prefer the pinned directory source even when reusing an older bot's vendor tree. + $loader->addPsr4('Civ13\\ServerDirectory\\', __DIR__.'/../../src/Civ13/ServerDirectory', true); + $phase = 'runtime'; + $loop = Loop::get(); + $connector = ByondStatusProbe::createConnector($loop, publicOnly: true); + $service = new Standalone($config, new ByondStatusProbe($loop, $connector, $connector)); + $phase = 'bind'; + $socket = new SocketServer($service->address(), [], $loop); + $server = new HttpServer($loop, $service->handle(...)); + $server->on('error', static fn () => fwrite(STDERR, "Directory HTTP error.\n")); + $server->listen($socket); + $phase = 'runtime'; + $refresh = static fn () => $service->directory->refresh()->then(null, static fn () => fwrite(STDERR, "Directory refresh failed.\n")); + $refresh(); + $timer = $loop->addPeriodicTimer(Directory::REFRESH_SECONDS, $refresh); + if (function_exists('pcntl_signal')) { + $stop = static function () use ($loop, $socket, $timer): void { + $socket->close(); + $loop->cancelTimer($timer); + $loop->stop(); + }; + $loop->addSignal(SIGTERM, $stop); + $loop->addSignal(SIGINT, $stop); + } + fwrite(STDERR, "Read-only directory listening on ".$service->address()."\n"); + $loop->run(); +} catch (Throwable) { + // Never dump config, dependency internals or raw game status into service logs. + fwrite(STDERR, "Directory startup failed (phase: ".$phase."); check the reviewed deployment prerequisites.\n"); + exit(1); +} diff --git a/src/Civ13/ServerDirectory/Standalone.php b/src/Civ13/ServerDirectory/Standalone.php new file mode 100644 index 00000000..8a8360d4 --- /dev/null +++ b/src/Civ13/ServerDirectory/Standalone.php @@ -0,0 +1,58 @@ +clock ??= time(...); + self::validateShape($config); + $this->port = $config['listen_port']; + $this->directory = new Directory([], [], $probe, $this->clock, $config['targets']); + $this->endpoint = new Endpoint($this->directory); + $this->enabled = $config['targets'] !== []; + } + + /** Pure schema check usable before any dependency autoloader is executed. */ + public static function validateShape(array $config): void + { + if (count($config) !== 2 || array_diff(array_keys($config), ['listen_port', 'targets']) || + !is_int($config['listen_port']) || $config['listen_port'] < 1024 || $config['listen_port'] > 65535 || !is_array($config['targets'])) { + throw new \InvalidArgumentException('Expected only an unprivileged listen_port and targets list'); + } + } + + public function address(): string + { + return '127.0.0.1:'.$this->port; + } + + public function handle(ServerRequestInterface $request): Response + { + // One bounded budget for the trusted local proxy, not unbounded per-IP state. + $now = ($this->clock)(); + if ($now < $this->window || $now - $this->window >= 60) { + $this->window = $now; + $this->requests = 0; + } + if (++$this->requests > 600) { + return new Response(429, ['Content-Type' => 'application/json', 'Cache-Control' => 'no-store', 'Retry-After' => '60', 'X-Content-Type-Options' => 'nosniff'], + $request->getMethod() === 'HEAD' ? '' : '{"error":"rate_limited"}'); + } + return $this->endpoint->handle($request); + } +} diff --git a/tests/server-directory-standalone.php b/tests/server-directory-standalone.php new file mode 100644 index 00000000..63d072fd --- /dev/null +++ b/tests/server-directory-standalone.php @@ -0,0 +1,69 @@ + 0, 'map_name' => 'Gaia', 'map_gamemode' => 'Nomads', 'round_duration_in_ticks' => 0]); +}; +$config = ['listen_port' => 25113, 'targets' => [['id' => 'fixture', 'name' => 'Fixture', 'host' => 'civ13.com', 'port' => 24017, 'engine' => 'BYOND']]]; +$service = new Standalone($config, $probe, static function () use (&$now) { return $now; }); +$request = static fn ($method = 'GET', $suffix = '?provider=civ13.com') => new ServerRequest($method, 'https://civ13.com'.Directory::PATH.$suffix); +standaloneCheck($service->enabled && $service->address() === '127.0.0.1:25113', 'Listener is not fixed loopback'); +standaloneCheck($service->handle($request())->getStatusCode() === 503 && $calls === [], 'Construction/request triggered probes'); +$service->directory->refresh(); +standaloneCheck($calls === [['civ13.com', 24017, true]], 'Standalone bypassed public-only status target policy'); +standaloneCheck($service->handle($request())->getStatusCode() === 200, 'Fresh standalone snapshot unavailable'); +standaloneCheck($service->handle($request('GET', '?provider=valzargaming.com'))->getStatusCode() === 404, 'Unconfigured provider invented'); +standaloneCheck($service->handle($request('POST'))->getStatusCode() === 405, 'Mutation method accepted'); +standaloneCheck($service->handle(new ServerRequest('GET', 'http://civ13.com/restart'))->getStatusCode() === 404, 'Bot control route exposed'); +for ($i = 5; $i < 600; $i++) { $service->handle($request()); } +standaloneCheck($service->handle($request('HEAD'))->getStatusCode() === 429 && (string) $service->handle($request('HEAD'))->getBody() === '', 'Bounded request budget/HEAD response failed'); +$now += 60; +standaloneCheck($service->handle($request())->getStatusCode() === 503, 'Rate reset renewed stale observation'); +$empty = new Standalone(array_replace($config, ['targets' => []]), $probe); +standaloneCheck(!$empty->enabled, 'Empty default inventory enabled listener'); +foreach (['listen_host' => '0.0.0.0', 'autoload' => '/tmp/evil', 'token' => 'secret', 'managed' => ['pers']] as $field => $value) { + standaloneReject(static fn () => new Standalone($config + [$field => $value], $probe)); +} +foreach ([0, 80, 65536, '25113', false, 25113.0] as $port) { standaloneReject(static fn () => new Standalone(array_replace($config, ['listen_port' => $port]), $probe)); } +standaloneCheck(!class_exists(Civ13\Civ13::class, false) && !class_exists(Civ13\GameServer::class, false) && !class_exists(Discord\Discord::class, false), 'Directory-only composition loaded bot/game classes'); +echo "PASS standalone directory: loopback-only, default-empty, public-only probes, no bot bootstrap/control routes, bounded requests and stale fail-closed responses.\n"; + +// The CLI rejects bad inventory and exits for empty inventory before executing +// even a supplied autoloader. Only synthetic local files/processes are involved. +$temp = sys_get_temp_dir().'/civ13-directory-test-'.bin2hex(random_bytes(8)); +mkdir($temp); +try { + $marker = $temp.'/loaded'; + $autoload = $temp.'/autoload.php'; + file_put_contents($autoload, ' 80, 'targets' => []], ['listen_port' => 25113, 'targets' => [['host' => 'evil.invalid']]], ['listen_port' => 25113, 'targets' => []]] as $index => $inventory) { + $file = $temp.'/inventory.json'; + file_put_contents($file, json_encode($inventory, JSON_THROW_ON_ERROR)); + $process = proc_open([PHP_BINARY, __DIR__.'/../scripts/directory/serve.php', $file, $autoload], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + standaloneCheck(is_resource($process), 'Synthetic CLI process unavailable'); + fclose($pipes[0]); + $output = stream_get_contents($pipes[1]).stream_get_contents($pipes[2]); + fclose($pipes[1]); fclose($pipes[2]); + $exit = proc_close($process); + standaloneCheck(!file_exists($marker), 'Disabled/invalid inventory executed dependency autoload'); + standaloneCheck($exit === ($index === 2 ? 0 : 1), 'CLI inventory exit status differs'); + standaloneCheck(str_contains($output, $index === 2 ? 'inventory is empty' : 'phase: inventory'), 'Controlled startup phase/disabled diagnostic missing'); + } +} finally { + foreach (glob($temp.'/*') as $file) { unlink($file); } + rmdir($temp); +} +echo "PASS standalone CLI: invalid/default-empty inventory checked before autoload, controlled diagnostics, no external I/O.\n"; From 16dbf28e430b23c107762f867bac8b7af704a926 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 19 Oct 2026 16:10:34 -0400 Subject: [PATCH 6/8] Prepare isolated directory dependency lock in CI --- .github/workflows/directory-bundle.yml | 42 ++++++++++++++++++++++++++ packaging/directory/composer.json | 21 +++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 .github/workflows/directory-bundle.yml create mode 100644 packaging/directory/composer.json diff --git a/.github/workflows/directory-bundle.yml b/.github/workflows/directory-bundle.yml new file mode 100644 index 00000000..300e08be --- /dev/null +++ b/.github/workflows/directory-bundle.yml @@ -0,0 +1,42 @@ +name: Isolated directory bundle +on: + push: + paths: + - 'packaging/directory/**' + - 'src/Civ13/ServerDirectory/**' + - 'scripts/directory/**' + - 'tests/server-directory*.php' + - 'tests/fixtures/server-directory*.json' + - '.github/workflows/directory-bundle.yml' + pull_request: + paths: + - 'packaging/directory/**' + - 'src/Civ13/ServerDirectory/**' + - 'scripts/directory/**' + - 'tests/server-directory*.php' + - 'tests/fixtures/server-directory*.json' + - '.github/workflows/directory-bundle.yml' + workflow_dispatch: +permissions: + contents: read +jobs: + bundle: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + # Bootstrap produces only a lock for review, never a deployable bundle. + - name: Resolve candidate dependency lock + run: composer update --working-dir=packaging/directory --no-dev --no-scripts --no-plugins --prefer-dist --no-interaction + - run: composer audit --working-dir=packaging/directory --locked --no-dev + - uses: actions/upload-artifact@v4 + with: + name: directory-lock-bootstrap-${{ github.event.pull_request.head.sha || github.sha }} + path: packaging/directory/composer.lock + if-no-files-found: error + retention-days: 7 diff --git a/packaging/directory/composer.json b/packaging/directory/composer.json new file mode 100644 index 00000000..0e6d099b --- /dev/null +++ b/packaging/directory/composer.json @@ -0,0 +1,21 @@ +{ + "name": "civ13/launcher-directory-runtime", + "description": "Isolated read-only Civ13 launcher directory; no bot bootstrap", + "type": "project", + "license": "MIT", + "require": { + "php": "^8.3", + "react/http": "1.11.1", + "react/socket": "1.17.0", + "react/dns": "1.14.0", + "react/event-loop": "1.6.0", + "react/promise": "3.3.0", + "react/stream": "1.4.0", + "evenement/evenement": "3.0.2", + "psr/http-message": "1.1" + }, + "autoload": {"psr-4": {"Civ13\\ServerDirectory\\": "src/Civ13/ServerDirectory/"}}, + "config": {"allow-plugins": false, "sort-packages": true, "platform": {"php": "8.3.0"}}, + "minimum-stability": "stable", + "prefer-stable": true +} From 2b85f7c88b1f740a450252c714369a72c7bcc44a Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 19 Oct 2026 16:14:00 -0400 Subject: [PATCH 7/8] Build verified directory bundle from isolated pinned dependencies --- .github/workflows/directory-bundle.yml | 40 +- .gitignore | 5 + docs/launcher-directory-standalone.md | 24 + packaging/directory/build.py | 73 +++ packaging/directory/composer.lock | 722 +++++++++++++++++++++++++ packaging/directory/test_verify.py | 37 ++ packaging/directory/verify_bundle.py | 77 +++ 7 files changed, 970 insertions(+), 8 deletions(-) create mode 100644 packaging/directory/build.py create mode 100644 packaging/directory/composer.lock create mode 100644 packaging/directory/test_verify.py create mode 100644 packaging/directory/verify_bundle.py diff --git a/.github/workflows/directory-bundle.yml b/.github/workflows/directory-bundle.yml index 300e08be..c445a4ab 100644 --- a/.github/workflows/directory-bundle.yml +++ b/.github/workflows/directory-bundle.yml @@ -22,21 +22,45 @@ permissions: jobs: bundle: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.3', '8.4', '8.5'] steps: - uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} - uses: shivammathur/setup-php@v2 with: - php-version: '8.3' + php-version: ${{ matrix.php }} coverage: none - # Bootstrap produces only a lock for review, never a deployable bundle. - - name: Resolve candidate dependency lock - run: composer update --working-dir=packaging/directory --no-dev --no-scripts --no-plugins --prefer-dist --no-interaction - - run: composer audit --working-dir=packaging/directory --locked --no-dev + - name: Install only the committed directory dependency lock + run: | + test -f packaging/directory/composer.lock + composer install --working-dir=packaging/directory --no-dev --no-scripts --no-plugins --prefer-dist --no-interaction + composer validate --working-dir=packaging/directory --no-check-publish + composer check-platform-reqs --working-dir=packaging/directory --no-dev + composer audit --working-dir=packaging/directory --locked --no-dev + - name: Build and verify isolated bundle + run: | + python3 packaging/directory/test_verify.py + python3 packaging/directory/build.py + python3 packaging/directory/verify_bundle.py build/directory/civ13-directory-*.tar.gz + cd build/directory/civ13-directory + sha256sum --check SHA256SUMS > /dev/null + - name: Test only the bundled dependency environment + run: | + find build/directory/civ13-directory/src build/directory/civ13-directory/scripts -name '*.php' -print0 | xargs -0 -n1 php -l + php tests/server-directory.php build/directory/civ13-directory/vendor/autoload.php + php tests/server-directory-remote.php build/directory/civ13-directory/vendor/autoload.php + php tests/server-directory-standalone.php build/directory/civ13-directory/vendor/autoload.php - uses: actions/upload-artifact@v4 + if: matrix.php == '8.4' with: - name: directory-lock-bootstrap-${{ github.event.pull_request.head.sha || github.sha }} - path: packaging/directory/composer.lock + name: civ13-directory-${{ github.event.pull_request.head.sha || github.sha }} + path: | + build/directory/*.tar.gz + build/directory/*.sha256 + build/directory/verify_bundle.py if-no-files-found: error - retention-days: 7 + retention-days: 14 diff --git a/.gitignore b/.gitignore index c6391932..4a89b0b3 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,8 @@ test.php /tools/ /phive.xml /.phive/ + +# Standalone directory dependencies are CI-built; its minimal lock is reviewed. +/packaging/directory/vendor/ +/packaging/directory/__pycache__/ +!packaging/directory/composer.lock diff --git a/docs/launcher-directory-standalone.md b/docs/launcher-directory-standalone.md index d0f31bf5..d0ada295 100644 --- a/docs/launcher-directory-standalone.md +++ b/docs/launcher-directory-standalone.md @@ -23,6 +23,30 @@ so the standalone runtime uses its own pinned directory classes rather than an older bot checkout's classes. Bot.php, .env and secret bootstrap files are never included. Empty or malformed inventory exits before executing the autoloader. +## Pinned CI bundle + +The `Isolated directory bundle` workflow installs only +`packaging/directory/composer.lock`, with plugins/scripts disabled and Composer +audit enabled. It builds and tests the minimal dependency environment on PHP +8.3, 8.4 and 8.5. The artifact from PHP 8.4 includes only directory sources, +locked dependencies, empty example inventory, documentation and license. It +contains no bot entrypoint, Discord dependency, credentials or live settings. +ReactPHP HTTP is pinned to patched 1.11.1; the older live bot's 1.11.0 dependency +must not be reused for this endpoint. + +Before transfer/installation, obtain the artifact from the reviewed exact commit +and compare its external SHA256 with the trusted CI/download record. Run the +supplied `verify_bundle.py` against the archive **before extraction**. It checks +bounded sizes, safe paths/modes, no links/devices, complete file checksums, source +commit, lock hash, package inventory and empty default inventory. This establishes +artifact consistency, not independent authenticity: the trusted commit/artifact +channel and separately communicated outer hash are still required. Source and +per-file hashes are also stored inside the archive. The build produces no +production installation or public release. + +Extract only into a new versioned directory outside the running bot checkout, +and use the bundle's own vendor/autoload.php. Do not replace bot dependencies. + ## Configuration and operation `scripts/directory/inventory.example.json` contains an empty, disabled inventory. diff --git a/packaging/directory/build.py b/packaging/directory/build.py new file mode 100644 index 00000000..572082da --- /dev/null +++ b/packaging/directory/build.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Stage only the directory and locked dependencies. Never deploy or execute bot code.""" +import gzip +import hashlib +import json +from pathlib import Path +import shutil +import subprocess +import tarfile + +ROOT = Path(__file__).resolve().parents[2] +OUTPUT = ROOT / 'build/directory' +STAGE = OUTPUT / 'civ13-directory' +SOURCE_FILES = [ + 'src/Civ13/ServerDirectory/' + name + '.php' + for name in ['Directory', 'Endpoint', 'ByondStatusProbe', 'PublicResolver', 'Standalone'] +] + ['scripts/directory/serve.php', 'scripts/directory/inventory.example.json', + 'docs/launcher-directory.md', 'docs/launcher-directory-standalone.md', 'LICENSE'] + +def sha(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + +def main(): + revision = subprocess.check_output(['git', '-C', str(ROOT), 'rev-parse', 'HEAD'], text=True).strip() + committed_lock = subprocess.check_output(['git', '-C', str(ROOT), 'show', 'HEAD:packaging/directory/composer.lock']) + lock = ROOT / 'packaging/directory/composer.lock' + if committed_lock != lock.read_bytes(): + raise ValueError('Dependency lock must match the committed revision') + if OUTPUT.exists(): + raise ValueError('Build output already exists; use a clean CI checkout') + STAGE.mkdir(parents=True) + for name in SOURCE_FILES: + source = ROOT / name + if source.is_symlink() or not source.is_file(): + raise ValueError('Unexpected source file type') + destination = STAGE / name + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, destination) + for name in ['composer.json', 'composer.lock']: + shutil.copyfile(ROOT / 'packaging/directory' / name, STAGE / name) + vendor = ROOT / 'packaging/directory/vendor' + if any(path.is_symlink() for path in vendor.rglob('*')): + raise ValueError('Dependency tree must not contain symlinks') + shutil.copytree(vendor, STAGE / 'vendor') + packages = json.loads(lock.read_text())['packages'] + (STAGE / 'SOURCE.json').write_text(json.dumps({ + 'repository': 'https://github.com/Valgorithms/Civilizationbot', + 'commit': revision, 'composer_lock_sha256': sha(lock), + 'packages': {row['name']: row['version'] for row in packages}, + }, indent=2, sort_keys=True) + '\n') + files = sorted(path for path in STAGE.rglob('*') if path.is_file()) + if len(files) > 5000 or sum(path.stat().st_size for path in files) > 32 * 1024 * 1024: + raise ValueError('Unexpected bundle size') + (STAGE / 'SHA256SUMS').write_text(''.join(sha(path) + ' ' + path.relative_to(STAGE).as_posix() + '\n' for path in files)) + archive = OUTPUT / ('civ13-directory-' + revision + '.tar.gz') + with archive.open('wb') as output, gzip.GzipFile(filename='', mode='wb', fileobj=output, mtime=0) as compressed, tarfile.open(fileobj=compressed, mode='w') as tar: + for path in [STAGE] + sorted(STAGE.rglob('*')): + info = tar.gettarinfo(str(path), arcname=path.relative_to(OUTPUT).as_posix()) + info.uid = info.gid = info.mtime = 0 + info.uname = info.gname = '' + info.mode = 0o755 if path.is_dir() else 0o644 + if path.is_file(): + with path.open('rb') as data: + tar.addfile(info, data) + else: + tar.addfile(info) + (OUTPUT / (archive.name + '.sha256')).write_text(sha(archive) + ' ' + archive.name + '\n') + shutil.copyfile(ROOT / 'packaging/directory/verify_bundle.py', OUTPUT / 'verify_bundle.py') + print(archive) + print('SHA256 ' + sha(archive)) + +if __name__ == '__main__': + main() diff --git a/packaging/directory/composer.lock b/packaging/directory/composer.lock new file mode 100644 index 00000000..84dbd2c5 --- /dev/null +++ b/packaging/directory/composer.lock @@ -0,0 +1,722 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "3475f01cd4c2a1cc30013e4b75ed41d1", + "packages": [ + { + "name": "evenement/evenement", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Evenement\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + } + ], + "description": "Événement is a very simple event dispatching library for PHP", + "keywords": [ + "event-dispatcher", + "event-emitter" + ], + "support": { + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" + }, + "time": "2023-08-08T05:53:35+00:00" + }, + { + "name": "fig/http-message-util", + "version": "1.1.5", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message-util.git", + "reference": "9d94dc0154230ac39e5bf89398b324a86f63f765" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message-util/zipball/9d94dc0154230ac39e5bf89398b324a86f63f765", + "reference": "9d94dc0154230ac39e5bf89398b324a86f63f765", + "shasum": "" + }, + "require": { + "php": "^5.3 || ^7.0 || ^8.0" + }, + "suggest": { + "psr/http-message": "The package containing the PSR-7 interfaces" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Fig\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Utility classes and constants for use with PSR-7 (psr/http-message)", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-message-util/issues", + "source": "https://github.com/php-fig/http-message-util/tree/1.1.5" + }, + "time": "2020-11-24T22:02:12+00:00" + }, + { + "name": "psr/http-message", + "version": "1.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/cb6ce4845ce34a8ad9e68117c10ee90a29919eba", + "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/1.1" + }, + "time": "2023-04-04T09:50:52+00:00" + }, + { + "name": "react/cache", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" + }, + { + "name": "react/dns", + "version": "v1.14.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/dns.git", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Dns\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.14.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-18T19:34:28+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-17T20:46:25+00:00" + }, + { + "name": "react/http", + "version": "v1.11.1", + "source": { + "type": "git", + "url": "https://github.com/reactphp/http.git", + "reference": "e5b8eb2654dec6cbbc692a39f05ea4ecd40c8cc1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/http/zipball/e5b8eb2654dec6cbbc692a39f05ea4ecd40c8cc1", + "reference": "e5b8eb2654dec6cbbc692a39f05ea4ecd40c8cc1", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "fig/http-message-util": "^1.1", + "php": ">=5.3.0", + "psr/http-message": "^1.0", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.3 || ^1.2.1", + "react/socket": "^1.16", + "react/stream": "^1.4" + }, + "require-dev": { + "clue/http-proxy-react": "^1.8", + "clue/reactphp-ssh-proxy": "^1.4", + "clue/socks-react": "^1.4", + "phpunit/phpunit": "^9.6 || ^8.5 || ^5.7 || ^4.8.36", + "react/async": "^4.2 || ^3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Http\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven, streaming HTTP client and server implementation for ReactPHP", + "keywords": [ + "async", + "client", + "event-driven", + "http", + "http client", + "http server", + "https", + "psr-7", + "reactphp", + "server", + "streaming" + ], + "support": { + "issues": "https://github.com/reactphp/http/issues", + "source": "https://github.com/reactphp/http/tree/v1.11.1" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2026-09-09T07:08:28+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" + }, + { + "name": "react/socket", + "version": "v1.17.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/socket.git", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Socket\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", + "keywords": [ + "Connection", + "Socket", + "async", + "reactphp", + "stream" + ], + "support": { + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.17.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-19T20:47:34+00:00" + }, + { + "name": "react/stream", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" + }, + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", + "keywords": [ + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" + ], + "support": { + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-11T12:45:25+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": "^8.3" + }, + "platform-dev": {}, + "platform-overrides": { + "php": "8.3.0" + }, + "plugin-api-version": "2.9.0" +} diff --git a/packaging/directory/test_verify.py b/packaging/directory/test_verify.py new file mode 100644 index 00000000..64fcbd95 --- /dev/null +++ b/packaging/directory/test_verify.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +import hashlib +import io +import json +from pathlib import Path +import tarfile +import tempfile +from verify_bundle import verify, REQUIRED + +lock = json.dumps({'packages': [{'name': 'react/http', 'version': 'v1.11.1'}]}).encode() +files = {'composer.lock': lock, 'scripts/directory/inventory.example.json': b'{"targets":[]}', + 'SOURCE.json': json.dumps({'repository': 'https://github.com/Valgorithms/Civilizationbot', 'commit': '1' * 40, + 'composer_lock_sha256': hashlib.sha256(lock).hexdigest(), 'packages': {'react/http': 'v1.11.1'}}).encode()} +for required in REQUIRED: + files.setdefault(required, b'fixture') +files['SHA256SUMS'] = ''.join(hashlib.sha256(value).hexdigest() + ' ' + name + '\n' for name, value in files.items()).encode() +with tempfile.TemporaryDirectory() as root: + for case in ['valid', 'traversal', 'absolute', 'symlink', 'duplicate', 'mismatch', 'executable']: + path = Path(root) / (case + '.tar.gz') + with tarfile.open(path, 'w:gz') as archive: + for name, data in files.items(): + info = tarfile.TarInfo('civ13-directory/' + name); info.size = len(data); info.mode = 0o644 + if case == 'mismatch' and name == 'composer.lock': data = b'X' * len(data) + if case == 'executable' and name == 'composer.lock': info.mode = 0o755 + archive.addfile(info, io.BytesIO(data)) + if case in {'traversal', 'absolute', 'symlink', 'duplicate'}: + name = {'traversal': 'civ13-directory/../escape', 'absolute': '/escape', 'symlink': 'civ13-directory/link', 'duplicate': 'civ13-directory/composer.lock'}[case] + info = tarfile.TarInfo(name); info.mode = 0o644 + if case == 'symlink': info.type = tarfile.SYMTYPE; info.linkname = '/etc/passwd' + archive.addfile(info, io.BytesIO(b'')) + try: + verify(path) + except (ValueError, KeyError): + if case == 'valid': raise + else: + if case != 'valid': raise RuntimeError('Unsafe bundle accepted: ' + case) +print('PASS bundle verifier: valid, traversal, absolute path, symlink, duplicate, corruption and mode controls') diff --git a/packaging/directory/verify_bundle.py b/packaging/directory/verify_bundle.py new file mode 100644 index 00000000..a369b770 --- /dev/null +++ b/packaging/directory/verify_bundle.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Verify a directory bundle without extraction or execution. Compare the outer +SHA256 with the value obtained through the reviewed CI/artifact channel first. +""" +import hashlib +import json +from pathlib import Path, PurePosixPath +import re +import sys +import tarfile + +MAX_BYTES = 32 * 1024 * 1024 +SOURCE_FILES = {'src/Civ13/ServerDirectory/' + name + '.php' for name in ['Directory', 'Endpoint', 'ByondStatusProbe', 'PublicResolver', 'Standalone']} +REQUIRED = SOURCE_FILES | {'scripts/directory/serve.php', 'scripts/directory/inventory.example.json', 'vendor/autoload.php', 'composer.json', 'composer.lock', 'SOURCE.json', 'LICENSE'} +ALLOWED_ROOT = REQUIRED | {'docs/launcher-directory.md', 'docs/launcher-directory-standalone.md'} + +def verify(path): + if path.stat().st_size > MAX_BYTES: + raise ValueError('Compressed size exceeds bound') + files = {} + total = 0 + with tarfile.open(path, 'r:gz') as archive: + for number, member in enumerate(archive): + name = PurePosixPath(member.name) + if number >= 6000 or name.is_absolute() or '..' in name.parts or not name.parts or name.parts[0] != 'civ13-directory' or member.name != name.as_posix(): + raise ValueError('Unsafe archive path/count') + if not member.isdir() and not member.isfile(): + raise ValueError('Links/devices are prohibited') + if member.mode != (0o755 if member.isdir() else 0o644): + raise ValueError('Unexpected archive permissions') + if member.isdir(): + continue + relative = name.relative_to('civ13-directory').as_posix() + if relative in files or any(ord(char) < 32 or ord(char) == 127 for char in relative): + raise ValueError('Duplicate/control-character file path') + total += member.size + if member.size < 0 or total > MAX_BYTES: + raise ValueError('Uncompressed size exceeds bound') + files[relative] = archive.extractfile(member).read() + manifest = files.pop('SHA256SUMS').decode('utf-8') + expected = {} + for line in manifest.splitlines(): + match = re.fullmatch(r'([a-f0-9]{64}) (.+)', line) + if not match or match[2] in expected: + raise ValueError('Invalid checksum manifest') + expected[match[2]] = match[1] + if files.keys() != expected.keys() or any(hashlib.sha256(data).hexdigest() != expected[name] for name, data in files.items()): + raise ValueError('Bundle contents do not match manifest') + if not REQUIRED.issubset(files) or any(name not in ALLOWED_ROOT and not name.startswith('vendor/') for name in files): + raise ValueError('Missing or unexpected runtime file') + source = json.loads(files['SOURCE.json']) + if source.get('repository') != 'https://github.com/Valgorithms/Civilizationbot' or not re.fullmatch(r'[a-f0-9]{40}', source.get('commit', '')): + raise ValueError('Invalid source identity') + if source['composer_lock_sha256'] != hashlib.sha256(files['composer.lock']).hexdigest(): + raise ValueError('Lock hash mismatch') + lock = json.loads(files['composer.lock']) + if source['packages'] != {row['name']: row['version'] for row in lock['packages']}: + raise ValueError('Package inventory mismatch') + if source['packages'].get('react/http') != 'v1.11.1' or any('discord' in name.lower() for name in source['packages']): + raise ValueError('Unexpected HTTP/bot dependency') + if json.loads(files['scripts/directory/inventory.example.json'])['targets'] != []: + raise ValueError('Example inventory must remain disabled') + if any(name.endswith(('.env', 'bot.php')) or name.startswith('src/Civ13/GameServer') for name in files): + raise ValueError('Application/config files in directory-only bundle') + return source, len(files), total + +if __name__ == '__main__': + try: + if len(sys.argv) != 2: + raise ValueError('Usage: python3 verify_bundle.py directory.tar.gz') + path = Path(sys.argv[1]) + source, count, total = verify(path) + print('PASS bundle: commit=' + source['commit'] + ' files=' + str(count) + ' bytes=' + str(total)) + print('SHA256 ' + hashlib.sha256(path.read_bytes()).hexdigest()) + except (OSError, ValueError, KeyError, TypeError, tarfile.TarError) as error: + print('Bundle rejected: ' + str(error), file=sys.stderr) + sys.exit(1) From 449c2fb9011e0884dcbf94d00b30478f6aac422f Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Tue, 20 Oct 2026 22:47:17 -0400 Subject: [PATCH 8/8] Probe validated public DNS addresses within one bounded status deadline --- .github/workflows/directory-bundle.yml | 1 + .github/workflows/verifier-integration.yml | 1 + docs/launcher-directory-standalone.md | 4 + docs/launcher-directory.md | 18 +++- scripts/directory/serve.php | 3 +- src/Civ13/HttpServiceManager.php | 6 +- .../ServerDirectory/ByondStatusProbe.php | 100 ++++++++++++++--- src/Civ13/ServerDirectory/Directory.php | 20 +++- tests/server-directory-addresses.php | 101 ++++++++++++++++++ 9 files changed, 228 insertions(+), 26 deletions(-) create mode 100644 tests/server-directory-addresses.php diff --git a/.github/workflows/directory-bundle.yml b/.github/workflows/directory-bundle.yml index c445a4ab..16880385 100644 --- a/.github/workflows/directory-bundle.yml +++ b/.github/workflows/directory-bundle.yml @@ -53,6 +53,7 @@ jobs: find build/directory/civ13-directory/src build/directory/civ13-directory/scripts -name '*.php' -print0 | xargs -0 -n1 php -l php tests/server-directory.php build/directory/civ13-directory/vendor/autoload.php php tests/server-directory-remote.php build/directory/civ13-directory/vendor/autoload.php + php tests/server-directory-addresses.php build/directory/civ13-directory/vendor/autoload.php php tests/server-directory-standalone.php build/directory/civ13-directory/vendor/autoload.php - uses: actions/upload-artifact@v4 if: matrix.php == '8.4' diff --git a/.github/workflows/verifier-integration.yml b/.github/workflows/verifier-integration.yml index 0ac1e311..07e7d96c 100644 --- a/.github/workflows/verifier-integration.yml +++ b/.github/workflows/verifier-integration.yml @@ -60,6 +60,7 @@ jobs: - run: php tests/server-directory.php - run: php tests/server-directory-startup.php - run: php tests/server-directory-remote.php + - run: php tests/server-directory-addresses.php - run: php -l scripts/directory/serve.php - run: php tests/server-directory-standalone.php - run: php tests/verifier-integration.php diff --git a/docs/launcher-directory-standalone.md b/docs/launcher-directory-standalone.md index d0ada295..ada9ba6c 100644 --- a/docs/launcher-directory-standalone.md +++ b/docs/launcher-directory-standalone.md @@ -47,6 +47,10 @@ production installation or public release. Extract only into a new versioned directory outside the running bot checkout, and use the bundle's own vendor/autoload.php. Do not replace bot dependencies. +Use a bundle containing the reviewed multi-address status selection fix when +an approved hostname has several public A records. The old single-address +selection can omit a healthy server if another public address is unavailable. + ## Configuration and operation `scripts/directory/inventory.example.json` contains an empty, disabled inventory. diff --git a/docs/launcher-directory.md b/docs/launcher-directory.md index 2448c516..8e7901ca 100644 --- a/docs/launcher-directory.md +++ b/docs/launcher-directory.md @@ -72,8 +72,16 @@ workflow; its GitHub Actions workflows test contracts and publish documentation. the entire answer set must be ordinary public addresses. Private, loopback, shared, link-local, reserved, documentation, benchmark, multicast and other special-purpose answers are rejected. IPv6-only public targets are not - supported by this launcher contract. TCP uses the selected validated literal - without a second hostname lookup. A DNS change can take effect on the next + supported by this launcher contract. One complete validated A-answer snapshot + is deduplicated and tried in deterministic order. Literal status attempts are + sequential: each receives an equal share of the remaining three-second total + DNS/connect/read deadline. A silent or incomplete first address cannot consume + every later address's budget. Only a complete valid status result is selected; + TCP success alone is insufficient. There remains at most one active socket + per target and 32 across the bounded sweep. No second hostname lookup occurs. + Short time slices or an exhausted deadline can still leave a slow service + unavailable; this is bounded best-effort selection, not guaranteed failover. + A DNS change can take effect on the next sweep, subject to the system resolver's normal behavior. Missing system DNS fails closed; no fallback DNS provider is invented. 4. Each game must answer BYOND Topic `?status=1&format=json` on its configured @@ -204,3 +212,9 @@ failures, dynamic DNS and validation before literal TCP connection. Both DNS and TCP are injected, so the suite performs no external network I/O. The startup suite also checks remote-only registration with empty GameServer registries and no added lifecycle callbacks. All three suites run on PHP 8.3, 8.4 and 8.5 in CI. + +`php tests/server-directory-addresses.php` exercises the production probe factory +with injected DNS/TCP: both answer orders, silent/malformed/incomplete first +answers, full-set safety validation before TCP, duplicate suppression, one DNS +snapshot, literal/port binding, shared deadlines, cancellation, late callbacks +and the 32-active-socket ceiling across concurrent targets. It opens no sockets. diff --git a/scripts/directory/serve.php b/scripts/directory/serve.php index c5826b9f..f341f284 100644 --- a/scripts/directory/serve.php +++ b/scripts/directory/serve.php @@ -39,8 +39,7 @@ $loader->addPsr4('Civ13\\ServerDirectory\\', __DIR__.'/../../src/Civ13/ServerDirectory', true); $phase = 'runtime'; $loop = Loop::get(); - $connector = ByondStatusProbe::createConnector($loop, publicOnly: true); - $service = new Standalone($config, new ByondStatusProbe($loop, $connector, $connector)); + $service = new Standalone($config, ByondStatusProbe::create($loop)); $phase = 'bind'; $socket = new SocketServer($service->address(), [], $loop); $server = new HttpServer($loop, $service->handle(...)); diff --git a/src/Civ13/HttpServiceManager.php b/src/Civ13/HttpServiceManager.php index 7a3ceb78..9bb881b0 100644 --- a/src/Civ13/HttpServiceManager.php +++ b/src/Civ13/HttpServiceManager.php @@ -1696,11 +1696,7 @@ private function __generateLauncherDirectoryEndpoint(): void $this->launcherDirectory = new Directory( $providers, $this->civ13->enabled_gameservers, - new ByondStatusProbe( - $this->civ13->loop, - ByondStatusProbe::createConnector($this->civ13->loop), - ByondStatusProbe::createConnector($this->civ13->loop, publicOnly: true) - ), + ByondStatusProbe::create($this->civ13->loop), statusTargets: $statusTargets ); } catch (\InvalidArgumentException $error) { diff --git a/src/Civ13/ServerDirectory/ByondStatusProbe.php b/src/Civ13/ServerDirectory/ByondStatusProbe.php index 92a8918c..554daf28 100644 --- a/src/Civ13/ServerDirectory/ByondStatusProbe.php +++ b/src/Civ13/ServerDirectory/ByondStatusProbe.php @@ -22,24 +22,38 @@ final class ByondStatusProbe public const MAX_PACKET_BYTES = 65536; public const TIMEOUT_SECONDS = 3.0; - public function __construct(private LoopInterface $loop, private ConnectorInterface $connector, private ?ConnectorInterface $publicConnector = null) + public function __construct(private LoopInterface $loop, private ConnectorInterface $connector, private ?ConnectorInterface $publicConnector = null, + private float $timeoutSeconds = self::TIMEOUT_SECONDS, private ?PublicResolver $publicResolver = null, private $monotonic = null) { + if (!is_finite($timeoutSeconds) || $timeoutSeconds <= 0 || $timeoutSeconds > self::TIMEOUT_SECONDS) { + throw new \InvalidArgumentException('Status deadline must be positive and at most three seconds'); + } + $this->monotonic ??= static fn (): float => hrtime(true) / 1e9; + } + + /** Production composition: one validated DNS snapshot and sequential literal attempts. */ + public static function create(LoopInterface $loop, ?ResolverInterface $resolver = null, ?ConnectorInterface $tcp = null, ?callable $monotonic = null, float $timeoutSeconds = self::TIMEOUT_SECONDS): self + { + $resolver ??= self::systemResolver($loop); + return new self($loop, self::createConnector($loop, $resolver, $tcp), null, + $timeoutSeconds, new PublicResolver($resolver), $monotonic); + } + + private static function systemResolver(LoopInterface $loop): ResolverInterface + { + $config = DnsConfig::loadSystemConfigBlocking(); + return $config->nameservers ? (new DnsFactory())->create($config, $loop) + : new class implements ResolverInterface { + public function resolve($domain) { return reject(new \RuntimeException('System DNS unavailable')); } + public function resolveAll($domain, $type) { return reject(new \RuntimeException('System DNS unavailable')); } + }; } /** No application DNS cache or startup IP pin; hostname probes resolve again each sweep. */ public static function createConnector(LoopInterface $loop, ?ResolverInterface $resolver = null, ?ConnectorInterface $tcp = null, bool $publicOnly = false): ConnectorInterface { - if ($resolver === null) { - // Read system resolver configuration once at setup, never synchronously - // resolve a public hostname on the event loop or invent a DNS server. - $config = DnsConfig::loadSystemConfigBlocking(); - $resolver = $config->nameservers - ? (new DnsFactory())->create($config, $loop) - : new class implements ResolverInterface { - public function resolve($domain) { return reject(new \RuntimeException('System DNS unavailable')); } - public function resolveAll($domain, $type) { return reject(new \RuntimeException('System DNS unavailable')); } - }; - } + // Never invent a resolver or synchronously look up a hostname on the loop. + $resolver ??= self::systemResolver($loop); if ($publicOnly) { $resolver = new PublicResolver($resolver); } @@ -48,10 +62,16 @@ public function resolveAll($domain, $type) { return reject(new \RuntimeException public function __invoke(string $address, int $port, bool $publicOnly = false): PromiseInterface { + if ($publicOnly && $this->publicResolver !== null) { + return $this->probePublic($address, $port); + } if ($publicOnly && ($this->publicConnector === null || !in_array($address, Directory::PROVIDERS, true))) { return reject(new \InvalidArgumentException('Public probe must use an approved hostname and public-only connector')); } - $deferred = new Deferred(); + $finish = null; + $deferred = new Deferred(static function () use (&$finish): void { + if ($finish !== null) { $finish(null, new \RuntimeException('Status probe cancelled')); } + }); $connection = null; $connecting = null; $settled = false; @@ -71,7 +91,7 @@ public function __invoke(string $address, int $port, bool $publicOnly = false): } $error === null ? $deferred->resolve($status) : $deferred->reject($error); }; - $timer = $this->loop->addTimer(self::TIMEOUT_SECONDS, static fn () => $finish(null, new \RuntimeException('Status probe timed out'))); + $timer = $this->loop->addTimer($this->timeoutSeconds, static fn () => $finish(null, new \RuntimeException('Status probe timed out'))); // Targets come exclusively from operator-configured managed/status-only inventories. $host = str_contains($address, ':') ? '['.$address.']' : $address; try { @@ -112,6 +132,58 @@ public function __invoke(string $address, int $port, bool $publicOnly = false): return $deferred->promise(); } + private function probePublic(string $host, int $port): PromiseInterface + { + if (!in_array($host, Directory::PROVIDERS, true) || $port < 1 || $port > 65535) { + return reject(new \InvalidArgumentException('Invalid public status target')); + } + $deadline = ($this->monotonic)() + $this->timeoutSeconds; + $settled = false; $pending = null; $timer = null; $finish = null; + $deferred = new Deferred(static function () use (&$finish): void { + if ($finish !== null) { $finish(null, new \RuntimeException('Public status probe cancelled')); } + }); + $finish = function (?array $status, ?\Throwable $error = null) use (&$settled, &$pending, &$timer, $deferred): void { + if ($settled) { return; } + $settled = true; + if ($timer !== null) { $this->loop->cancelTimer($timer); } + if ($pending !== null) { $pending->cancel(); } + $error === null ? $deferred->resolve($status) : $deferred->reject($error); + }; + $timer = $this->loop->addTimer($this->timeoutSeconds, static fn () => $finish(null, new \RuntimeException('Public status probe timed out'))); + $pending = $this->publicResolver->resolveAll($host, \React\Dns\Model\Message::TYPE_A); + $pending->then(function (array $addresses) use ($port, $deadline, &$settled, &$pending, $finish): void { + // PublicResolver validated EVERY answer before any socket can open. + $addresses = array_values(array_unique($addresses)); + sort($addresses, SORT_STRING); + $index = 0; + $next = function () use (&$next, &$index, $addresses, $port, $deadline, &$settled, &$pending, $finish): void { + if ($settled) { return; } + $remaining = $deadline - ($this->monotonic)(); + $count = count($addresses) - $index; + if ($remaining <= 0 || $remaining > $this->timeoutSeconds || $count === 0) { + $finish(null, new \RuntimeException('No complete public status response')); + return; + } + // One socket per target, with a fair share of the SAME total budget. + // Slow candidates cannot consume the entire window before later A records. + $literal = $addresses[$index++]; + $pending = (new self($this->loop, $this->connector, timeoutSeconds: $remaining / $count))($literal, $port); + $pending->then(function (array $status) use ($next, $finish, $deadline, &$settled): void { + if ($settled) { return; } + if (!Directory::statusIsComplete($status)) { $next(); return; } + $remaining = $deadline - ($this->monotonic)(); + if ($remaining <= 0 || $remaining > $this->timeoutSeconds) { + $finish(null, new \RuntimeException('Public status deadline expired')); + return; + } + $finish($status); + }, static function () use ($next, &$settled): void { if (!$settled) { $next(); } }); + }; + $next(); + }, static fn (\Throwable $error) => $finish(null, $error)); + return $deferred->promise(); + } + /** Returns null only for an incomplete frame; rejects all non-string/non-JSON replies. */ public static function decodePacket(string $packet): ?array { diff --git a/src/Civ13/ServerDirectory/Directory.php b/src/Civ13/ServerDirectory/Directory.php index 07c35590..7d95a43a 100644 --- a/src/Civ13/ServerDirectory/Directory.php +++ b/src/Civ13/ServerDirectory/Directory.php @@ -156,7 +156,22 @@ public function hasProvider(string $host): bool return isset($this->targets[$host]); } + /** Same completeness policy used before selecting a public DNS candidate. */ + public static function statusIsComplete(array $status): bool + { + return self::metadata($status) !== null; + } + private static function entry(array $target, array $status, int $now): ?array + { + $metadata = self::metadata($status); + return $metadata === null ? null : array_merge([ + 'id' => $target['id'], 'name' => $target['name'], 'host' => $target['host'], 'port' => $target['port'], + 'engine' => 'BYOND', 'online' => true, 'checked_at' => self::timestamp($now), + ], $metadata, ['_checked' => $now]); + } + + private static function metadata(array $status): ?array { $players = $status['players'] ?? null; $ticks = $status['round_duration_in_ticks'] ?? null; @@ -168,9 +183,8 @@ private static function entry(array $target, array $status, int $now): ?array return null; } $entry = [ - 'id' => $target['id'], 'name' => $target['name'], 'host' => $target['host'], 'port' => $target['port'], - 'engine' => 'BYOND', 'online' => true, 'checked_at' => self::timestamp($now), 'players' => $players, - 'map' => $map, 'mode' => $mode, 'round' => ['id' => $id, 'duration_seconds' => (int) floor($ticks / 10)], '_checked' => $now, + 'players' => $players, 'map' => $map, 'mode' => $mode, + 'round' => ['id' => $id, 'duration_seconds' => (int) floor($ticks / 10)], ]; // Explicit allowlist only: never pass through ckeys, IPs, CIDs, admins, or account links. $epoch = $status['map_age'] ?? null; diff --git a/tests/server-directory-addresses.php b/tests/server-directory-addresses.php new file mode 100644 index 00000000..f459a5f6 --- /dev/null +++ b/tests/server-directory-addresses.php @@ -0,0 +1,101 @@ +calls++; + $deferred = new Deferred(); + $this->loop->addTimer($this->delay, fn () => $deferred->resolve($this->answers)); + return $deferred->promise(); + } +} +final class AddressConnection extends EventEmitter implements ConnectionInterface { + public bool $closed = false; + public function __construct(private StreamSelectLoop $loop, private string $kind, private AddressTcp $owner) {} + public function isReadable() { return !$this->closed; } + public function isWritable() { return !$this->closed; } + public function pause() {} + public function resume() {} + public function pipe(WritableStreamInterface $dest, array $options = []) { return Util::pipe($this, $dest, $options); } + public function write($data) { + addressCheck($data === "\0\x83".pack('n', strlen("?status=1&format=json\0") + 5).str_repeat("\0",5)."?status=1&format=json\0", 'Non-fixed status query'); + if ($this->kind === 'silent') { return true; } + $json = $this->kind === 'incomplete' ? '{}' : '{"players":0,"map_name":"Europe","map_gamemode":"Nomads","round_duration_in_ticks":0}'; + $body = "\x06".$json."\0"; + $wire = $this->kind === 'malformed' ? 'HTTP/1.1 200 OK' : "\0\x83".pack('n',strlen($body)).$body; + $this->loop->futureTick(function () use ($wire): void { if (!$this->closed) { $this->emit('data',[$wire]); } }); + return true; + } + public function getRemoteAddress() { return 'tcp://93.184.215.14:24017'; } + public function getLocalAddress() { return 'tcp://127.0.0.1:1'; } + public function end($data = null) { $this->close(); } + public function close() { if (!$this->closed) { $this->closed=true; $this->owner->active--; $this->emit('close'); $this->removeAllListeners(); } } +} +final class AddressTcp implements ConnectorInterface { + public array $uris=[]; + public int $active=0; + public int $peak=0; + public function __construct(private StreamSelectLoop $loop, public array $kinds=[]) {} + public function connect($uri) { + $host=parse_url($uri,PHP_URL_HOST); + addressCheck(filter_var($host,FILTER_VALIDATE_IP,FILTER_FLAG_IPV4)!==false,'TCP received hostname'); + $this->uris[]=$uri; $this->active++; $this->peak=max($this->peak,$this->active); + return resolve(new AddressConnection($this->loop,$this->kinds[$host]??'valid',$this)); + } +} +function runAddressProbe(array $answers, array $kinds=[], float $dnsDelay=0, float $budget=.08): array { + $loop=new StreamSelectLoop(); $resolver=new AddressResolver($answers,$loop,$dnsDelay); $tcp=new AddressTcp($loop,$kinds); + $probe=ByondStatusProbe::create($loop,$resolver,$tcp,timeoutSeconds:$budget); + $value=null; $error=null; $start=microtime(true); + $probe('valzargaming.com',1714,true)->then(static function($v)use(&$value){$value=$v;},static function($e)use(&$error){$error=$e;}); + $loop->run(); + addressCheck($tcp->active===0,'Socket retained after settlement'); + return [$value,$error,$resolver,$tcp,microtime(true)-$start]; +} +foreach ([['72.218.249.221','192.64.119.81'],['192.64.119.81','72.218.249.221']] as $answers) { + foreach (['silent','malformed','incomplete'] as $failure) { + [$value,$error,$resolver,$tcp]=runAddressProbe($answers,['192.64.119.81'=>$failure]); + addressCheck($error===null && $value['players']===0,'Healthy later public address lost'); + addressCheck(count($tcp->uris)===2 && $tcp->peak===1 && $resolver->calls===1,'Fanout, retries, or DNS re-resolution'); + addressCheck(parse_url($tcp->uris[1],PHP_URL_HOST)==='72.218.249.221' && parse_url($tcp->uris[1],PHP_URL_PORT)===1714,'Wrong selected literal/port'); + } +} +foreach ([[],array_fill(0,33,'72.218.249.221'),['72.218.249.221','127.0.0.1'],['10.0.0.1','72.218.249.221'],['192.0.2.1','72.218.249.221']] as $answers) { + [$value,$error,$resolver,$tcp]=runAddressProbe($answers); + addressCheck($value===null && $error!==null && $tcp->uris===[],'Unsafe/full answer set reached TCP'); +} +[$value,$error,$resolver,$tcp]=runAddressProbe(['72.218.249.221','72.218.249.221']); +addressCheck($error===null && count($tcp->uris)===1,'Duplicate addresses retried'); +[$value,$error,$resolver,$tcp,$elapsed]=runAddressProbe(['72.218.249.221','192.64.119.81'],['72.218.249.221'=>'silent','192.64.119.81'=>'silent']); +addressCheck($error!==null && $value===null && count($tcp->uris)===2 && $elapsed<.5,'Shared total deadline or all-failure cleanup'); +[$value,$error,$resolver,$tcp,$elapsed]=runAddressProbe(['72.218.249.221'],[],.12,.04); +addressCheck($error!==null && $tcp->uris===[],'Late DNS completion opened socket after deadline'); + +$loop=new StreamSelectLoop(); $resolver=new AddressResolver(['72.218.249.221','192.64.119.81'],$loop); +$tcp=new AddressTcp($loop,['192.64.119.81'=>'silent']); $probe=ByondStatusProbe::create($loop,$resolver,$tcp,timeoutSeconds:.08); +$success=0; for($i=0;$i<32;$i++) { $probe('valzargaming.com',1024+$i,true)->then(static function()use(&$success){$success++;}); } +$loop->run(); addressCheck($success===32 && $tcp->peak===32 && $tcp->active===0,'32-target sweep exceeded global socket bound or lost peers'); + +$loop=new StreamSelectLoop(); $resolver=new AddressResolver(['72.218.249.221'],$loop); $tcp=new AddressTcp($loop,['72.218.249.221'=>'silent']); +$probe=ByondStatusProbe::create($loop,$resolver,$tcp,timeoutSeconds:.08); $cancelled=false; +$pending=$probe('valzargaming.com',1714,true); $pending->then(null,static function()use(&$cancelled){$cancelled=true;}); +$loop->addTimer(.005,static fn()=>$pending->cancel()); $loop->run(); +addressCheck($cancelled && $tcp->active===0,'Cancellation retained an active socket'); +echo "PASS public address selection: both DNS orders, silent/malformed/incomplete fallback, whole-set rejection before TCP, duplicate suppression, one DNS snapshot, literal/port binding, shared deadline, late callbacks, cancellation and 32-global-socket ceiling.\n";