diff --git a/.github/workflows/directory-bundle.yml b/.github/workflows/directory-bundle.yml new file mode 100644 index 00000000..16880385 --- /dev/null +++ b/.github/workflows/directory-bundle.yml @@ -0,0 +1,67 @@ +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 + 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: ${{ matrix.php }} + coverage: none + - 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-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' + with: + 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: 14 diff --git a/.github/workflows/verifier-integration.yml b/.github/workflows/verifier-integration.yml index 4572d0fd..07e7d96c 100644 --- a/.github/workflows/verifier-integration.yml +++ b/.github/workflows/verifier-integration.yml @@ -7,6 +7,12 @@ on: - 'src/**' - 'tests/verifier-integration.php' - 'tests/http-dependency.php' + - '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' - '.github/workflows/verifier-integration.yml' @@ -16,6 +22,12 @@ on: - 'src/**' - 'tests/verifier-integration.php' - 'tests/http-dependency.php' + - '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' - '.github/workflows/verifier-integration.yml' @@ -45,6 +57,12 @@ 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/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 - run: php tests/economy.php - run: php tests/economy-service.php 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/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..3666868d 100644 --- a/bot.php +++ b/bot.php @@ -207,6 +207,8 @@ 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 + // '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-standalone.md b/docs/launcher-directory-standalone.md new file mode 100644 index 00000000..ada9ba6c --- /dev/null +++ b/docs/launcher-directory-standalone.md @@ -0,0 +1,95 @@ +# 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. + +## 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. + +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. +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 new file mode 100644 index 00000000..8e7901ca --- /dev/null +++ b/docs/launcher-directory.md @@ -0,0 +1,220 @@ +# Live launcher server directory + +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. + +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 +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+ + 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'], + ], + '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'], + ], + ``` + + 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. 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 + gameplay/Topic port with `players`, `map_name`, `map_gamemode`, and + `round_duration_in_ticks`. Zero players and a zero-duration lobby are valid. + `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, 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. + 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 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) + +```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 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 +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, +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 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 +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. + +`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. + +`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. + +`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/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.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 +} 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) 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..f341f284 --- /dev/null +++ b/scripts/directory/serve.php @@ -0,0 +1,67 @@ + 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(); + $service = new Standalone($config, ByondStatusProbe::create($loop)); + $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/Civ13.php b/src/Civ13/Civ13.php index 1beea869..5a5925c5 100644 --- a/src/Civ13/Civ13.php +++ b/src/Civ13/Civ13.php @@ -486,6 +486,8 @@ private function resolveOptions(array &$options = []): array 'http_key', 'http_port', 'http_whitelist', + 'launcher_directory', + 'launcher_directory_status_targets', 'ic_badwords', 'legacy', 'moderate', @@ -526,6 +528,8 @@ private function resolveOptions(array &$options = []): array 'http_key' => '', 'http_port' => 0, 'http_whitelist' => [], + 'launcher_directory' => [], + 'launcher_directory_status_targets' => [], 'ic_badwords' => [], 'legacy' => true, 'moderate' => true, @@ -567,6 +571,8 @@ private function resolveOptions(array &$options = []): array ->setAllowedTypes('http_key', 'string') ->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 9e3a3287..9bb881b0 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; @@ -42,6 +45,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 +61,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 +91,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 +234,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 +1684,35 @@ 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 + { + $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, + ByondStatusProbe::create($this->civ13->loop), + statusTargets: $statusTargets + ); + } 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..554daf28 --- /dev/null +++ b/src/Civ13/ServerDirectory/ByondStatusProbe.php @@ -0,0 +1,212 @@ + 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 + { + // Never invent a resolver or synchronously look up a hostname on the loop. + $resolver ??= self::systemResolver($loop); + 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->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')); + } + $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; + $timer = null; + $finish = function (?array $status, ?\Throwable $error = null) use ($deferred, &$connection, &$connecting, &$settled, &$timer): void { + if ($settled) { + return; + } + $settled = true; + if ($timer !== null) { + $this->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($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 { + $connecting = ($publicOnly ? $this->publicConnector : $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(); + } + + 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 + { + 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..7d95a43a --- /dev/null +++ b/src/Civ13/ServerDirectory/Directory.php @@ -0,0 +1,210 @@ +> $providers Explicit provider => configured server keys. + * @param array $registry The bot's enabled_gameservers, not request data. + * @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, 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] = []; + 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[$host][$port]) || self::text($server->name ?? null, 120) === null || ++$count > self::MAX_SERVERS) { + throw new \InvalidArgumentException('Invalid directory server or too many servers'); + } + $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. */ + 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'], $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); + } 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]); + } + + /** 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; + $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 = [ + '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; + $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..c028f956 --- /dev/null +++ b/src/Civ13/ServerDirectory/Endpoint.php @@ -0,0 +1,44 @@ + $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'], $errorBody('method_not_allowed')); + } + if ($request->getUri()->getPath() !== Directory::PATH || $request->getUri()->getHost() !== Directory::AUTHORITY) { + return new Response(404, $headers, $errorBody('not_found')); + } + // 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 (!$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/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/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/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/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-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"; 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-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"; diff --git a/tests/server-directory-startup.php b/tests/server-directory-startup.php new file mode 100644 index 00000000..2a80d8b4 --- /dev/null +++ b/tests/server-directory-startup.php @@ -0,0 +1,107 @@ +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.'?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'); + +// 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'); + +// 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 new file mode 100644 index 00000000..631f5058 --- /dev/null +++ b/tests/server-directory.php @@ -0,0 +1,231 @@ +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":"\ud800"}'), statusPacket('{"x":"\udc00"}'), statusPacket('{"x":"'."\xed\xa0\x80".'"}'), 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 = '?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'); +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', '?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'); +$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", "\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(); + 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'); +$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; +$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'); +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)); +$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'); +$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";