diff --git a/.github/workflows/portable.yml b/.github/workflows/portable.yml index dfb72074..74a43752 100644 --- a/.github/workflows/portable.yml +++ b/.github/workflows/portable.yml @@ -20,11 +20,14 @@ jobs: - run: php tests/windows/constructor.php vendor/autoload.php --negative-control - run: php tests/windows/constructor.php vendor/autoload.php --cache-negative-control - run: php tests/windows/portable.php + - run: php tests/windows/hosted-verifier.php + - run: php tests/windows/native-lifecycle.php - run: php tests/windows/bootstrap.php - run: php tests/windows/bootstrap.php --negative-control - run: php tests/windows/process.php - run: php tests/windows/shell-guard.php - run: php tests/windows/ready.php + - run: php tests/windows/ready.php vendor/autoload.php --hosted - run: php tests/windows/ready.php vendor/autoload.php --unconfigured - run: php -l bot.php - run: php -l src/Civ13/Civ13.php diff --git a/bot.php b/bot.php index 2c9d16ab..cb2f6294 100644 --- a/bot.php +++ b/bot.php @@ -476,7 +476,7 @@ function loadEnv(string $filePath, bool $explicit = false): array $hidden_options['web_address'] = '127.0.0.1'; $hidden_options['http_port'] = $portable['http_port']; $hidden_options['functions'] = []; // No ambient production callbacks. - $options['verify_url'] = ''; // No authority writes or fallback to the official verifier. + // runtimeOptions permits only an explicit hosted GET consumer, never authority writes. $options['webserver_url'] = '127.0.0.1'; } $options = array_merge($options, $hidden_options); diff --git a/config/examples/windows-native-lifecycle.json b/config/examples/windows-native-lifecycle.json new file mode 100644 index 00000000..b6716af7 --- /dev/null +++ b/config/examples/windows-native-lifecycle.json @@ -0,0 +1,16 @@ +{ + "enabled": false, + "supervisor_root": "D:/Civ13-local/TEST1714-supervisor", + "supervisor_sha256": "REPLACE_WITH_REVIEWED_SUPERVISOR_SHA256", + "guard_sha256": "REPLACE_WITH_REVIEWED_MASTER_GUARD_SHA256", + "config_sha256": "REPLACE_WITH_REVIEWED_SUPERVISOR_CONFIG_SHA256", + "daemon": "D:/Civ13-local/BYOND/516.1681/bin/dd.exe", + "daemon_sha256": "REPLACE_WITH_REVIEWED_DAEMON_SHA256", + "expected": { + "revision": "REPLACE_WITH_FRESHLY_VERIFIED_MASTER_COMMIT", + "map": "NOMADS_GAIA", + "generation": "REPLACE_WITH_VERIFIED_ROUND_V2_GENERATION", + "dmb_sha256": "REPLACE_WITH_EXACT_MASTER_DMB_SHA256", + "rsc_sha256": "REPLACE_WITH_EXACT_MASTER_RSC_SHA256" + } +} diff --git a/config/examples/windows-test1714.json b/config/examples/windows-test1714.json index 5fe6558b..7650dbb1 100644 --- a/config/examples/windows-test1714.json +++ b/config/examples/windows-test1714.json @@ -22,6 +22,7 @@ "ip": "127.0.0.1", "port": 1714, "enabled": false, + "native_lifecycle": {"enabled": false}, "supported": false, "moderate": false, "legacy_relay": false, diff --git a/docs/windows-hosted-verifier.md b/docs/windows-hosted-verifier.md new file mode 100644 index 00000000..612a5040 --- /dev/null +++ b/docs/windows-hosted-verifier.md @@ -0,0 +1,58 @@ +# Portable hosted verification reads + +An explicit portable profile may select the hosted Civilizationbot's existing +public projection with: + +```json +"verification_source": { + "mode": "read-only", + "url": "http://civ13.com:55555/verified" +} +``` + +This exact HTTP GET contract comes from the bot's `SocketServer` HTTP listener +and `/verified` route. It is a legacy unsigned public read projection, not a +signed replica or authenticated TLS transport. No authority token is needed or +sent. Do not configure `CIV_TOKEN`, copy production credentials, run a local +VerifierServer, or interpret the Desktop instance as a verification authority. +The separate app's own Discord token remains private and unrelated. + +The consumer uses the bot's asynchronous ReactPHP HTTP browser without a proxy, +disables redirects, bounds each request to 10 seconds and response bytes to +4 MiB, and validates at most 50,000 one-to-one +`ss13`/`discord` records from the hosted collection (JSON list or keyed object), +normalizes legacy account display spelling to the bot's existing lookup form, +rejects normalization collisions, and retains only those fields in memory. It reads at +startup and every 30 minutes, replacing the prior view even when the valid list +is empty. Non-200, timeout, malformed, duplicate or oversized responses mark +the source unavailable and retain the last valid list in memory as stale. +Lookups then reject as unavailable; the local `/verified` read endpoint reports +503 with staleness/last-success metadata. No stale disk cache or +provisional registration can become an alternative source. The projection has +no remote revision/expiry; the refresh interval is not proof of instantaneous +remote freshness. + +Staff may invoke `/refreshverification` in the configured guild. It uses the +same staff-role/Moderate Members/Administrator/Ban Members check as the staff +panel, rechecks permission after an ephemeral acknowledgement, and reports +fresh/unavailable, last successful refresh UTC and retained record count. +There is one GET plus at most one retry on a transport rejection (20 seconds +total HTTP deadline); HTTP/schema failures are not retried. Concurrent refreshes and +queued bursts within ten seconds are coalesced. Failed refresh never advances +the last-success timestamp; no valid initial list remains unavailable. A view +older than 30 minutes cannot authorize even if the periodic callback is late. + +Read-only mode does not register the legacy verifier's member/thread mutation +listeners, approve guild join requests or run verification-driven role/ban +timers. It does not synchronize Discord roles. Registration, token challenges, +provisioning and deletion are rejected before local or remote writes. Direct +users to the primary hosted bot to complete or change verification. Other bot +commands retain their existing authorization checks; this mode grants none. + +`php tests/windows/hosted-verifier.php` uses synthetic records and an injected +asynchronous browser around the real setup/parser/write barriers. It proves +unrelated timers progress while refresh is pending. It opens no socket, +loads no environment/token and makes no Discord or hosted service change. +Live deployment additionally needs an exact endpoint read and normal runtime +ownership/activation checks. Never log response bodies or verified identities +as readiness evidence; retain only status, schema and record count. diff --git a/docs/windows-native-lifecycle.md b/docs/windows-native-lifecycle.md new file mode 100644 index 00000000..5fe59911 --- /dev/null +++ b/docs/windows-native-lifecycle.md @@ -0,0 +1,165 @@ +# Native Windows supervisor adapter (disabled by default) + +This adds a bot-side adapter and a versioned supervisor spool contract. It does +**not** add a game supervisor consumer, upgrade an installed supervisor, install +dependencies, provision credentials, launch a game or remove a halt latch. +The installed Desktop supervisor does not implement this protocol. Keep the +adapter disabled until a separately reviewed compatible consumer exists. + +The native bot entry points retain their default rejection. An explicitly enabled +`servers..native_lifecycle` object in a portable profile allows `Host` to +request `start`, `Kill` to request **save-stop**, and `Restart` to request +`restart`. The server must also be explicitly enabled and bound to loopback +TEST1714. Native MapSwap and all legacy shell maintenance remain disabled. +Linux without a portable profile retains its existing routing. + +The adapter does not execute Python, DreamDaemon, arbitrary argv, shell text, +process-name kills or port-owner termination. It submits fixed private files to +an independently operated supervisor. Discord arguments never select paths, +commands, revisions, maps, saves or PIDs. Existing Discord command authorization +is unchanged; no registration or live interaction is performed by this change. + +## Explicit profile binding + +The existing `config/examples/windows-test1714.json` retains +`"native_lifecycle": {"enabled": false}`. The separate +`config/examples/windows-native-lifecycle.json` shows the complete object an +operator may later place under that key. Its placeholders deliberately fail +validation if enabled without verified values. Do not reuse another bot's token +or environment. No credentials belong in this object or in spool records. + +Activation requires all of the following: + +- A separate portable bot profile, private destinations and environment file, + and dependencies in the intended durable bot installation. +- An existing private `/bot-control` directory, with no linked + paths and local access restricted by the operator. This PR changes no ACLs. +- Reviewed SHA-256 pins for the installed `software/supervisor.py`, + `software/master_guard.py`, `supervisor.json`, daemon and exact DMB/RSC pair. +- A clean dedicated `master` repository selected by portable `gitdir`, canonical + Civ13 origin, exact master revision, map and completed saved generation. +- An explicit server `basedir` inside `/runs` that is also the + actual game working directory. The consumer must preserve that binding for + this operation; a different run directory is rejected. Moving normal file IPC + to a new run requires a separate profile/restart review because GameServer's + existing resolved paths are readonly. +- Supervisor config fields `root`, `repository`, `branch: "master"`, + `bind: "127.0.0.1"`, `port: 1714`, the exact `map` and `daemon`, and + `bot_control_protocol: "civilizationbot.native-supervisor.v1"`. + +Changing a pinned source/config/build requires explicit profile review and bot +restart. The adapter never updates a checkout, compiles, selects an old save, +substitutes a fresh round or silently relaxes save validation. + +## Supervisor consumer contract + +The trusted consumer must be reviewed with the pinned source; advertising a +capability is not an independent audit of that source. The protocol is a local +operator boundary, not a sandbox against another process with write access to +the private directory. A consumer must serialize with all its existing lifecycle +operators, including scheduled/manual updates. It must **not** consume these +requests without the same ownership and save safety guarantees. + +The adapter's `description()` returns the complete `binding` and its SHA-256 +(UTF-8 JSON, PHP JSON_UNESCAPED_SLASHES, field order as returned). The consumer +publishes `capability.json` atomically with: + +```json +{ + "protocol": "civilizationbot.native-supervisor.v1", + "binding_sha256": "64 lowercase hex characters", + "heartbeat_unix": 0, + "epoch": "32 lowercase hex characters", + "sequence": 1, + "master_verified": true, + "busy": false, + "process": null +} +``` + +The heartbeat must be within ten seconds, not more than two seconds in the +future. `epoch` identifies one reconciled supervisor session; `sequence` must +advance after each operation. `process: null` asserts that the supervisor has +established a stopped state with no conflicting owner/listener. A running +process record instead contains `pid` (positive integer), `creation_filetime` +(decimal string), canonical `executable` and `working_directory`, and the bound +`dmb_sha256`/`rsc_sha256`. The consumer must obtain these from the actual process, +not a port label, filename, stale PID or intended command line. + +The adapter briefly holds a nonblocking exclusive `adapter.lock`, persists `pending.json`, +then publishes `request.json` using an exclusive hard link to a flushed temporary +file. A filesystem without the required hard-link/fsync behavior fails closed. +The packet contains only protocol, random operation ID, fixed action, +epoch/sequence, binding hash, complete binding, and the observed `before` process. +An existing request/pending record or consumed epoch/sequence refuses submission. +The publication lock is released before waiting; completion reacquires it only +for its short verification/acknowledgement transition. A separate process can +inspect or reconcile state while an operation is pending, but must not replay it. + +On accepting an operation the consumer must: + +1. Revalidate all pins, protocol, epoch/sequence, expected master/map/save and + actual ownership under its own lifecycle lock. Fetch canonical origin/master, + require clean branch master equal to that verified ref, and fail closed when + offline, stale, dirty or diverged. Never reset, force checkout or stash. +2. For start, refuse an existing/conflicting daemon and verify the selected + completed generation and import intent before startup. For save-stop/restart, + match PID plus creation time, executable, working directory and binary hashes; + refuse busy/error/client/hold conditions. Use only the authenticated existing + save/shutdown API. A lost reply is unknown, not permission to retry or kill. +3. Validate the completed save and acknowledged exit before restart. Start only + the bound binary with loopback binding, then verify actual ownership and + application readiness/imported generation. TCP-only success is insufficient. +4. Persist a single immutable `result-.json` atomically, consume the + request exactly once, and advance its capability sequence. Recovery must + reconcile durable pending/result/state rather than replay a request. + +Completion results must echo protocol, operation, action, epoch, sequence, +binding hash and exact `before`; set `outcome: "complete"`, +`master_verified: true`, `ready` (false for save-stop, true otherwise), and +`after` (null only for save-stop). A running `after` uses the process schema +above. Restart must establish a different creation identity. Save-stop/restart +also require `exit_acknowledged: true`, `save_complete: true`, a valid +`saved_generation` and `save_receipt_sha256`. Start/restart additionally return +the matching `restored_generation`. + +The bot accepts these as a pinned supervisor's report, not independent native +acceptance. Real process/socket, actual save receipt, logs, imported world and +readiness evidence remain required before deployment is considered verified. + +The bot polls without blocking its event loop. Queueing is never reported as +success. A ten-minute bot timeout does **not** kill the supervisor or daemon. +Timeout, malformed/unbound response, failed/incomplete save, changed pins, or bot +restart leaves the pending record intact and blocks another request. Successful +completion retains the result plus a consumed epoch/sequence marker before +clearing only the exact pending record. No automatic retry or recovery clearing +is provided; an operator must reconcile evidence before changing those records. +Longer game operations can finish independently after the bot timeout. +If save-stop/restart advances the generation, a durable marker blocks subsequent +operations using the original binding. The operator must review the newly saved +generation, update the explicit profile and supervisor capability binding, and +restart the bot before a later start can proceed. Old results and reconciliation +markers remain evidence; the adapter never selects the original save again. + +## Current Desktop launch blockers + +The discovered installation still hardcodes Europe while the preserved current +save is Gaia; its configured initial save is older Europe. The Gaia generation +lacks a restart intent, and HALTED.json remains present. The old supervisor also +lacks this spool consumer and explicit loopback bind contract. None is repaired +or bypassed by enabling a flag or installing Composer dependencies. The actual +master binary has not been rebuilt/refreshed. Keep launch disabled while the +supervisor contract, exact build, matching save/intent and operator profile are +resolved and validated under the existing native allocator. + +## Offline checks + +`php tests/windows/native-lifecycle.php [EXPLICIT_VENDOR_AUTOLOAD]` uses inert +temporary files, an injected clock, fake loop, supervisor receipts and message +objects. It covers fixed routing, default rejection, pins, bad capability, +serialization, stale capability replay, process identity, save/exit/import +controls, malformed/unknown outcomes, interrupted publication and bot restart. +Two real inert PHP children additionally compete for one temporary spool; only +one may publish. No supervisor, game, bot +bootstrap, credential load or network runs. Existing portable constructor, +shell-guard, ready and real inert PHP child contracts remain separate checks. diff --git a/docs/windows-test1714.md b/docs/windows-test1714.md index 79873461..0c1d7ff8 100644 --- a/docs/windows-test1714.md +++ b/docs/windows-test1714.md @@ -59,8 +59,11 @@ The HTTP listener binds 127.0.0.1 on the explicit profile port (distinct from171 Portable mode removes ambient callback functions, official verifier server construction and the official verification URL. Verification integration remains unavailable until a separate reviewed authoritative read-only contract is supplied. -Native Host/Kill/Restart/MapSwap fail closed in portable/Windows mode before game -messages or timers. This change supplies no game lifecycle replacement. +Native Host/Kill/Restart/MapSwap fail closed by default in portable/Windows mode +before game messages or timers. The separately documented +[native supervisor adapter](windows-native-lifecycle.md) is explicitly disabled +and requires a compatible reviewed supervisor consumer. It supplies no direct +game lifecycle replacement; MapSwap and legacy shell operations remain disabled. Legacy shell maintenance is also unavailable in Windows/portable mode: chat updatedeps/pullrepo/ts/ss14 and slash pull/update report unavailable rather than diff --git a/src/Civ13/Civ13.php b/src/Civ13/Civ13.php index 50f70379..4f3d07c1 100644 --- a/src/Civ13/Civ13.php +++ b/src/Civ13/Civ13.php @@ -541,6 +541,7 @@ private function resolveOptions(array &$options = []): array 'stats' => null, 'technician_id' => '116927250145869826', 'verify_url' => 'http://valzargaming.com:8080/verified/', + 'verification_read_only' => false, 'web_address' => '', 'webapi' => null, 'webserver_url' => 'www.valzargaming.com', @@ -583,6 +584,7 @@ private function resolveOptions(array &$options = []): array ->setAllowedTypes('stats', ['null', Stats::class]) ->setAllowedTypes('technician_id', 'string') ->setAllowedTypes('verify_url', 'string') + ->setAllowedTypes('verification_read_only', 'bool') ->setAllowedTypes('web_address', 'string') ->setAllowedTypes('webapi', ['null', 'resource', HttpServer::class]) ->setAllowedTypes('webserver_url', 'string'); @@ -1407,12 +1409,14 @@ private function declareListeners(): void }); $this->discord->on('GUILD_JOIN_REQUEST_CREATE', function (GuildJoinRequest $request): void { + if ($this->verifier->readOnly) { return; } if ($this->verifier->getVerifiedMemberItems()->get('discord', $request->user_id)) { $request->approve(); } }); $this->discord->on('GUILD_JOIN_REQUEST_UPDATE', function (GuildJoinRequest $request): void { + if ($this->verifier->readOnly) { return; } if ($this->verifier->getVerifiedMemberItems()->get('discord', $request->user_id)) { $request->approve(); } @@ -1551,6 +1555,7 @@ public function listbans(Message $message, ?string $key = null): PromiseInterfac */ public function bancheckTimer(): TimerInterface|false { + if (isset($this->verifier) && $this->verifier->readOnly) { return false; } // We don't want the persistence server to do this function if (! $this->enabled_gameservers) { return false; diff --git a/src/Civ13/GameServer.php b/src/Civ13/GameServer.php index dec01eed..b0cf8318 100644 --- a/src/Civ13/GameServer.php +++ b/src/Civ13/GameServer.php @@ -67,6 +67,7 @@ class GameServer public Logger $logger; public StreamSelectLoop $loop; public bool $ready = false; + private ?NativeLifecycle $nativeLifecycle = null; // Resolved paths private readonly string $serverdata; @@ -148,6 +149,12 @@ public function __construct(public Civ13 &$civ13, array &$options) } else { $this->enabled = $options['enabled'] ?? false; } + if (($options['native_lifecycle']['enabled'] ?? false) === true) { + if (PHP_OS_FAMILY !== 'Windows' && !getenv('CIVILIZATIONBOT_PROFILE')) { + throw new \RuntimeException('Native supervisor adapter requires an explicit portable profile'); + } + $this->nativeLifecycle = new NativeLifecycle($options['native_lifecycle'], $options, $civ13->gitdir, $this->loop); + } $this->legacy = $options['legacy'] ?? true; $this->moderate = $options['moderate'] ?? true; $this->panic_bunker = $options['panic_bunker'] ?? false; @@ -766,9 +773,9 @@ public function DirectMessage(string $message, string $sender, string $recipient * * @param Message|null $message An optional message object that can be used to react to the hosting action. */ - public function Host(?Message $message = null): void + public function Host(?Message $message = null): ?PromiseInterface { - if (PHP_OS_FAMILY === 'Windows' || getenv('CIVILIZATIONBOT_PROFILE')) { throw new \RuntimeException('Native lifecycle controls are unavailable on Windows'); } + if (PHP_OS_FAMILY === 'Windows' || getenv('CIVILIZATIONBOT_PROFILE')) { return $this->nativeRequest('start', $message); } OSFunctions::execInBackground("python3 {$this->basedir}".Civ13::updateserverabspaths); if (file_exists($this->basedir.Civ13::serverdata)) { OSFunctions::execInBackground("rm -f {$this->basedir}".Civ13::serverdata); @@ -786,11 +793,12 @@ public function Host(?Message $message = null): void } else { $this->logger->info("Server host timer already exists for {$this->key}."); } + return null; } /** Shuts the server down (via `killciv13`), optionally announcing it in OOC first. */ - public function Kill(?Message $message = null, bool $notify = true): void + public function Kill(?Message $message = null, bool $notify = true): ?PromiseInterface { - if (PHP_OS_FAMILY === 'Windows' || getenv('CIVILIZATIONBOT_PROFILE')) { throw new \RuntimeException('Native lifecycle controls are unavailable on Windows'); } + if (PHP_OS_FAMILY === 'Windows' || getenv('CIVILIZATIONBOT_PROFILE')) { return $this->nativeRequest('save-stop', $message); } if ($notify) { $sender = ($message && $message->user_id) ? $this->civ13->verifier->getVerifiedItem($message->user_id)['ss13'] : ($this->civ13->discord->id ?? $this->civ13->discord->username); $this->OOCMessage("Server is shutting down. To get notified when we go live again, please join us on Discord at {$this->civ13->discord_formatted}", $sender); @@ -801,11 +809,12 @@ public function Kill(?Message $message = null, bool $notify = true): void $message->react('👍'); } }); + return null; } /** Kills the server and re-hosts it 20s later, optionally announcing the restart in OOC. */ - public function Restart(?Message $message = null, bool $notify = true): void + public function Restart(?Message $message = null, bool $notify = true): ?PromiseInterface { - if (PHP_OS_FAMILY === 'Windows' || getenv('CIVILIZATIONBOT_PROFILE')) { throw new \RuntimeException('Native lifecycle controls are unavailable on Windows'); } + if (PHP_OS_FAMILY === 'Windows' || getenv('CIVILIZATIONBOT_PROFILE')) { return $this->nativeRequest('restart', $message); } $this->Kill(null, false); $this->civ13->loop->addTimer(20, function () use ($message, &$gameserver): void { $this->Host($message); @@ -813,6 +822,16 @@ public function Restart(?Message $message = null, bool $notify = true): void if ($notify) { $this->OOCMessage("Server is now restarting. To share your feedback or experiences for this round, please join us on Discord at {$this->civ13->discord_formatted}", $message ? ($this->civ13->verifier->getVerifiedItem($message->author)['ss13'] ?? $this->civ13->discord->username) : $this->civ13->discord->username); } + return null; + } + + /** Success is acknowledged only after the supervisor supplies a bound completion result. */ + private function nativeRequest(string $action, ?Message $message): PromiseInterface + { + if ($this->nativeLifecycle === null) { throw new \RuntimeException('Native lifecycle controls are unavailable on Windows'); } + return $this->nativeLifecycle->request($action)->then(static function (array $result) use ($message): mixed { + return $message ? $message->react('👍')->then(static fn () => $result) : $result; + }); } /** * Swaps the current map to the specified map. diff --git a/src/Civ13/HostedVerificationSnapshot.php b/src/Civ13/HostedVerificationSnapshot.php new file mode 100644 index 00000000..9a29da83 --- /dev/null +++ b/src/Civ13/HostedVerificationSnapshot.php @@ -0,0 +1,56 @@ +clock = \Closure::fromCallable($clock ?? time(...)); } + public function records(): array { return $this->records ?? []; } + + public function status(): array + { + $age = $this->success === null ? null : max(0, ($this->clock)() - $this->success); + $available = !$this->failed && $age !== null && $age < self::FRESH_SECONDS; + return ['available' => $available, 'stale' => !$available, 'has_valid_list' => $this->records !== null, + 'last_success_utc' => $this->success === null ? null : gmdate('Y-m-d\TH:i:s\Z', $this->success), + 'age_seconds' => $age, 'records' => $this->records === null ? null : count($this->records), + 'refresh_in_progress' => $this->busy]; + } + + /** One bounded GET per attempt; overlapping and queued bursts share the prior result. */ + public function refresh(callable $fetch): \React\Promise\PromiseInterface + { + $now = ($this->clock)(); + if ($this->pending !== null) { return $this->pending->then(static fn (array $status): array => array_replace($status, ['coalesced' => true])); } + if ($this->attempt !== null && $now - $this->attempt < self::COALESCE_SECONDS) { + return \React\Promise\resolve($this->status() + ['coalesced' => true]); + } + $this->busy = true; $this->attempt = $now; + $deferred = new \React\Promise\Deferred(); $this->pending = $deferred->promise(); $pending = $this->pending; + $finish = function (bool $valid, ?array $records = null) use ($deferred): void { + if ($valid) { $this->records = $records; $this->success = ($this->clock)(); } + $this->failed = !$valid; $this->busy = false; $this->pending = null; + $deferred->resolve($this->status() + ['coalesced' => false]); + }; + try { $response = \React\Promise\resolve($fetch()); } + catch (\Throwable) { $finish(false); return $pending; } + $response->then(function (array $response) use ($finish): void { + try { + if (($response['http_status'] ?? null) !== 200 || !is_string($response['json'] ?? null)) { throw new \RuntimeException('Unavailable'); } + $finish(true, HostedVerificationSource::validate($response['json'])); + } catch (\Throwable) { $finish(false); } + }, static function () use ($finish): void { $finish(false); }); + return $pending; + } +} diff --git a/src/Civ13/HostedVerificationSource.php b/src/Civ13/HostedVerificationSource.php new file mode 100644 index 00000000..9051b663 --- /dev/null +++ b/src/Civ13/HostedVerificationSource.php @@ -0,0 +1,56 @@ + self::MAX_BYTES) { throw new \RuntimeException('Hosted verification response too large'); } + $records = json_decode($json, true, 32, JSON_THROW_ON_ERROR); + if (!is_array($records) || count($records) > 50000 || !in_array(ltrim($json)[0] ?? '', ['[', '{'], true)) { + throw new \RuntimeException('Hosted verification response is not a record collection'); + } + $normalized = []; $accounts = []; $users = []; + foreach ($records as $record) { + if (!is_array($record) || !is_string($record['ss13'] ?? null) || !is_string($record['discord'] ?? null) + || $record['ss13'] === '' || strlen($record['ss13']) > 256 + || preg_match('/[\x00-\x1F\x7F]/', $record['ss13']) === 1 + || preg_match('/^[0-9]{17,20}$/D', $record['discord']) !== 1) { + throw new \RuntimeException('Hosted verification record contract failed'); + } + // Match Civ13::sanitizeInput account lookups, including legacy display spelling. + $account = str_replace(['.', '_', '-', '+', ' '], '', strtolower($record['ss13'])); + if ($account === '' || isset($accounts[$account]) || isset($users[$record['discord']])) { + throw new \RuntimeException('Hosted verification account is empty or ambiguous'); + } + $accounts[$account] = true; $users[$record['discord']] = true; + $normalized[] = ['ss13' => $account, 'discord' => $record['discord']]; + } + return $normalized; + } + + /** Asynchronous GET only; no credentials, redirects, cache or raw response logging. */ + public static function fetch(\React\Http\Browser $browser): \React\Promise\PromiseInterface + { + $client = $browser->withTimeout(10)->withFollowRedirects(false)->withRejectErrorResponse(false)->withResponseBuffer(self::MAX_BYTES); + $read = static function (int $attempt) use ($client, &$read): \React\Promise\PromiseInterface { + return $client->get(self::URL, ['Accept' => 'application/json'])->then( + static function (\Psr\Http\Message\ResponseInterface $response): array { + if ($response->getStatusCode() !== 200) { return ['json' => '', 'http_status' => 0]; } + try { return ['json' => json_encode(self::validate((string)$response->getBody()), JSON_THROW_ON_ERROR), 'http_status' => 200]; } + catch (\Throwable) { return ['json' => '', 'http_status' => 0]; } + }, + static function (\Throwable $error) use ($attempt, &$read): mixed { + if ($attempt === 0 && !$error instanceof \OverflowException) { return $read(1); } + return ['json' => '', 'http_status' => 0]; + } + ); + }; + return $read(0); + } +} diff --git a/src/Civ13/HttpServiceManager.php b/src/Civ13/HttpServiceManager.php index 9bb5e2a0..1a5c49bd 100644 --- a/src/Civ13/HttpServiceManager.php +++ b/src/Civ13/HttpServiceManager.php @@ -903,8 +903,10 @@ function sendGetRequest(endpoint) { ) ->offsetSet( '/verified', - fn (ServerRequestInterface $request, string $endpoint, bool $whitelisted): HttpResponse => HttpResponse::json( - $this->civ13->verifier->verified + fn (ServerRequestInterface $request, string $endpoint, bool $whitelisted): HttpResponse => + (isset($this->civ13->verifier->readOnly) && $this->civ13->verifier->readOnly && !$this->civ13->verifier->hostedStatus()['available']) + ? HttpResponse::json(['error' => 'Hosted verification unavailable', 'source' => $this->civ13->verifier->hostedStatus()])->withStatus(503) + : HttpResponse::json($this->civ13->verifier->verified ->filter(fn ($item) => $item['ss13'] && $item['discord']) ->toArray() ), diff --git a/src/Civ13/NativeLifecycle.php b/src/Civ13/NativeLifecycle.php new file mode 100644 index 00000000..bb3d1d6b --- /dev/null +++ b/src/Civ13/NativeLifecycle.php @@ -0,0 +1,309 @@ +clock ??= static fn () => hrtime(true); + if (($server['enabled'] ?? null) !== true || ($server['ip'] ?? null) !== '127.0.0.1' || (string) ($server['port'] ?? '') !== '1714') { + throw new \InvalidArgumentException('Native lifecycle requires loopback TEST1714'); + } + $root = self::directory($settings['supervisor_root'] ?? ''); + $runtime = self::directory($server['basedir'] ?? ''); + $repository = self::directory($repository); + if (!str_starts_with($runtime, $root.DIRECTORY_SEPARATOR.'runs'.DIRECTORY_SEPARATOR)) { + throw new \InvalidArgumentException('Explicit supervisor-owned run directory required'); + } + $this->directory = self::directory($root.'/bot-control'); + $expected = $settings['expected'] ?? []; + if (!is_array($expected) || array_keys($expected) !== ['revision', 'map', 'generation', 'dmb_sha256', 'rsc_sha256']) { + throw new \InvalidArgumentException('Exact build, map and save binding required'); + } + if (!is_string($expected['revision']) || !preg_match('/^[a-f0-9]{40}$/D', $expected['revision']) + || !in_array($expected['map'], ['NOMADS_GAIA', 'NOMADS_EUROPE'], true) + || !is_string($expected['generation']) || !preg_match('/^round-v2:[a-f0-9]{32}$/D', $expected['generation'])) { + throw new \InvalidArgumentException('Invalid native build, map or save identity'); + } + foreach (['dmb_sha256', 'rsc_sha256'] as $field) { self::digest($expected[$field]); } + $this->pins = []; + foreach (['software/supervisor.py' => 'supervisor_sha256', 'software/master_guard.py' => 'guard_sha256', 'supervisor.json' => 'config_sha256'] as $relative => $field) { + self::digest($settings[$field] ?? null); + $this->pins[$root.'/'.$relative] = $settings[$field]; + } + $daemon = self::file($settings['daemon'] ?? ''); + self::digest($settings['daemon_sha256'] ?? null); + $this->pins[$daemon] = $settings['daemon_sha256']; + $this->pins[$runtime.'/civ13.dmb'] = $expected['dmb_sha256']; + $this->pins[$runtime.'/civ13.rsc'] = $expected['rsc_sha256']; + $this->binding = [ + 'supervisor_root' => $root, 'runtime_directory' => $runtime, + 'repository' => $repository, 'branch' => 'master', + 'origin' => 'https://github.com/Civ13/Civ13.git', 'bind' => '127.0.0.1', 'port' => 1714, + 'daemon' => $daemon, 'expected' => $expected, + 'supervisor_sha256' => $settings['supervisor_sha256'], + 'guard_sha256' => $settings['guard_sha256'], 'config_sha256' => $settings['config_sha256'], + ]; + $this->bindingHash = hash('sha256', json_encode($this->binding, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)); + $this->verifyPins(); + $config = self::read($root.'/supervisor.json'); + if (self::directory($config['root'] ?? '') !== $root || self::directory($config['repository'] ?? '') !== $repository + || ($config['branch'] ?? null) !== 'master' || ($config['bind'] ?? null) !== '127.0.0.1' + || ($config['port'] ?? null) !== 1714 || ($config['map'] ?? null) !== $expected['map'] + || self::file($config['daemon'] ?? '') !== $daemon || ($config['bot_control_protocol'] ?? null) !== self::PROTOCOL) { + throw new \InvalidArgumentException('Supervisor does not implement the reviewed loopback native contract'); + } + } + + /** Public protocol description for the separately reviewed supervisor; contains no credentials. */ + public function description(): array + { + return ['protocol' => self::PROTOCOL, 'binding' => $this->binding, 'binding_sha256' => $this->bindingHash]; + } + + /** No action, token load, world query, or request write; usable by operator preflight. */ + public function inspect(): array + { + $this->verifyPins(); + $root = $this->binding['supervisor_root']; + if (file_exists($root.'/HALTED.json')) { + throw new \RuntimeException('Supervisor is halted; operator reconciliation required'); + } + if (file_exists($this->directory.'/reconcile-'.$this->bindingHash.'.json')) { + throw new \RuntimeException('Saved generation advanced; reconcile the explicit profile before another operation'); + } + $capability = self::read($this->directory.'/capability.json'); + if (($capability['protocol'] ?? null) !== self::PROTOCOL || ($capability['binding_sha256'] ?? null) !== $this->bindingHash + || !is_int($capability['heartbeat_unix'] ?? null) || $capability['heartbeat_unix'] < time() - 10 + || $capability['heartbeat_unix'] > time() + 2 + || !is_int($capability['sequence'] ?? null) || $capability['sequence'] < 0 + || !is_string($capability['epoch'] ?? null) || !preg_match('/^[a-f0-9]{32}$/D', $capability['epoch']) + || ($capability['master_verified'] ?? null) !== true || ($capability['busy'] ?? null) !== false) { + throw new \RuntimeException('Missing, stale or incompatible supervisor capability'); + } + if (!array_key_exists('process', $capability)) { throw new \RuntimeException('Supervisor process state is unknown'); } + $this->process($capability['process']); + return $capability; + } + + /** The pending latch survives timeout, malformed results, failed operations and bot restart. */ + public function request(string $action): PromiseInterface + { + if (!in_array($action, ['start', 'save-stop', 'restart'], true)) { + throw new \InvalidArgumentException('Unsupported native lifecycle action'); + } + $capability = $this->inspect(); + if (($action === 'start') !== ($capability['process'] === null)) { + throw new \RuntimeException('Native action does not match the owned process state'); + } + $lock = $this->lock(); + if ($lock === null) { throw new \RuntimeException('Native lifecycle transition already in progress'); } + $pending = $this->directory.'/pending.json'; + $request = $this->directory.'/request.json'; + $operation = bin2hex(random_bytes(16)); + $resultPath = $this->directory.'/result-'.$operation.'.json'; + $temporary = $this->directory.'/request-'.$operation.'.tmp'; + $used = $this->directory.'/used-'.$capability['epoch'].'-'.$capability['sequence'].'.json'; + try { + clearstatcache(); + if (file_exists($pending) || is_link($pending) || file_exists($request) || is_link($request) || file_exists($used) || is_link($used)) { + throw new \RuntimeException('Unresolved native operation; inspect supervisor evidence before retry'); + } + // Recheck after serialization, before creating any request. + $current = $this->inspect(); + if ($current['epoch'] !== $capability['epoch'] || $current['sequence'] !== $capability['sequence'] || $current['process'] !== $capability['process']) { throw new \RuntimeException('Supervisor epoch or owned process changed'); } + $packet = ['protocol' => self::PROTOCOL, 'operation' => $operation, 'action' => $action, + 'epoch' => $capability['epoch'], 'sequence' => $capability['sequence'], 'binding_sha256' => $this->bindingHash, + 'binding' => $this->binding, 'before' => $capability['process']]; + $bytes = json_encode($packet, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + self::create($pending, $bytes); + self::create($temporary, $bytes); + // Hard-link publication is exclusive: it cannot replace a concurrent request. + if (!link($temporary, $request)) { throw new \RuntimeException('Native request publication uncertain; inspect pending operation'); } + unlink($temporary); + flock($lock, LOCK_UN); fclose($lock); $lock = null; + $deferred = new Deferred(); + $deadline = ($this->clock)() + 600 * 1000000000; + $timer = $this->loop->addPeriodicTimer(0.25, function ($timer) use ($deferred, $deadline, $pending, $request, $resultPath, $packet, $bytes, $used): void { + $completionLock = null; + try { + clearstatcache(); + if (!file_exists($resultPath)) { + if (($this->clock)() < $deadline) { return; } + throw new \RuntimeException('Native outcome unknown; pending operation retained; do not retry'); + } + $completionLock = $this->lock(); + if ($completionLock === null) { + if (($this->clock)() < $deadline) { return; } + throw new \RuntimeException('Native completion transition busy; pending operation retained'); + } + $this->verifyPins(); + $result = $this->result(self::read($resultPath), $packet); + if (file_exists($request) || self::bytes($pending) !== $bytes) { + throw new \RuntimeException('Supervisor request consumption or pending identity is unverified'); + } + // Keep the supervisor's immutable result; clear only this exact successful pending record. + self::create($used, json_encode(['operation' => $packet['operation'], 'result_sha256' => hash_file('sha256', $resultPath)], JSON_THROW_ON_ERROR)); + if ($packet['action'] !== 'start' && $result['saved_generation'] !== $this->binding['expected']['generation']) { + self::create($this->directory.'/reconcile-'.$this->bindingHash.'.json', json_encode([ + 'operation' => $packet['operation'], 'binding_sha256' => $this->bindingHash, + 'saved_generation' => $result['saved_generation'], + ], JSON_THROW_ON_ERROR)); + } + if (!unlink($pending)) { throw new \RuntimeException('Native result complete but pending acknowledgement failed'); } + $this->loop->cancelTimer($timer); + flock($completionLock, LOCK_UN); fclose($completionLock); $completionLock = null; + $deferred->resolve($result); + } catch (\Throwable $error) { + $this->loop->cancelTimer($timer); + if (is_resource($completionLock)) { flock($completionLock, LOCK_UN); fclose($completionLock); } + $deferred->reject($error); + } + }); + return $deferred->promise(); + } catch (\Throwable $error) { + // A request may already have been consumed. Never remove its persistent latch or retry. + if (is_resource($lock)) { flock($lock, LOCK_UN); fclose($lock); } + throw $error; + } + } + + /** Held only for durable publication/completion transitions, never while waiting for the game. */ + private function lock(): mixed + { + $path = $this->directory.'/adapter.lock'; + if (is_link($path)) { throw new \RuntimeException('Unsafe native adapter lock'); } + $lock = fopen($path, 'c+b'); + if ($lock === false) { throw new \RuntimeException('Native adapter lock unavailable'); } + if (!flock($lock, LOCK_EX | LOCK_NB)) { fclose($lock); return null; } + return $lock; + } + + private function result(array $result, array $packet): array + { + foreach (['protocol', 'operation', 'action', 'epoch', 'sequence', 'binding_sha256', 'before'] as $key) { + if (!array_key_exists($key, $result) || $result[$key] !== $packet[$key]) { throw new \UnexpectedValueException('Unbound native supervisor result'); } + } + if (($result['outcome'] ?? null) !== 'complete' || ($result['master_verified'] ?? null) !== true + || ($result['ready'] ?? null) !== ($packet['action'] !== 'save-stop') || !array_key_exists('after', $result)) { + throw new \RuntimeException('Native operation rejected, incomplete or unknown; inspect retained evidence'); + } + $this->process($result['after']); + if ($packet['action'] === 'save-stop' ? $result['after'] !== null : $result['after'] === null) { + throw new \UnexpectedValueException('Native result process state mismatch'); + } + if ($packet['action'] !== 'start') { + if (($result['exit_acknowledged'] ?? null) !== true || ($result['save_complete'] ?? null) !== true + || !is_string($result['saved_generation'] ?? null) || !preg_match('/^round-v2:[a-f0-9]{32}$/D', $result['saved_generation'])) { + throw new \UnexpectedValueException('Save-safe shutdown evidence is incomplete'); + } + self::digest($result['save_receipt_sha256'] ?? null); + } + if ($packet['action'] === 'restart' && $result['after']['creation_filetime'] === $packet['before']['creation_filetime']) { + throw new \UnexpectedValueException('Restart did not establish a fresh owned process'); + } + if ($packet['action'] !== 'save-stop' && (($result['restored_generation'] ?? null) !== ($packet['action'] === 'restart' ? $result['saved_generation'] : $this->binding['expected']['generation']))) { + throw new \UnexpectedValueException('Replacement did not restore the intended save'); + } + return $result; + } + + private function process(mixed $process): void + { + if ($process === null) { return; } + if (!is_array($process) || !is_int($process['pid'] ?? null) || $process['pid'] < 1 + || !is_string($process['creation_filetime'] ?? null) || !preg_match('/^[1-9][0-9]{0,19}$/D', $process['creation_filetime']) + || ($process['executable'] ?? null) !== $this->binding['daemon'] + || ($process['working_directory'] ?? null) !== $this->binding['runtime_directory'] + || ($process['dmb_sha256'] ?? null) !== $this->binding['expected']['dmb_sha256'] + || ($process['rsc_sha256'] ?? null) !== $this->binding['expected']['rsc_sha256']) { + throw new \UnexpectedValueException('Unverified native process identity'); + } + } + + private function verifyPins(): void + { + clearstatcache(); + self::directory($this->directory); + foreach ($this->pins as $path => $hash) { + if (self::file($path) !== realpath($path) || !hash_equals($hash, hash_file('sha256', $path))) { + throw new \RuntimeException('Native supervisor, guard, configuration or build changed; review required'); + } + } + } + + private static function digest(mixed $value): void + { + if (!is_string($value) || !preg_match('/^[a-f0-9]{64}$/D', $value)) { throw new \InvalidArgumentException('Explicit SHA-256 required'); } + } + + private static function path(string $path): string + { + if (!PortableProfile::absolute($path) || str_contains($path, "\0")) { throw new \InvalidArgumentException('Absolute local native path required'); } + $resolved = realpath($path); + if ($resolved === false || str_starts_with($resolved, '\\\\')) { throw new \InvalidArgumentException('Existing local native path required'); } + for ($part = $path; dirname($part) !== $part; $part = dirname($part)) { + if (is_link($part)) { throw new \InvalidArgumentException('Linked native paths are unsupported'); } + } + return $resolved; + } + + private static function directory(string $path): string + { + $resolved = self::path($path); + if (!is_dir($resolved)) { throw new \InvalidArgumentException('Native directory required'); } + return $resolved; + } + + private static function file(string $path): string + { + $resolved = self::path($path); + if (!is_file($resolved)) { throw new \InvalidArgumentException('Native file required'); } + return $resolved; + } + + private static function bytes(string $path): string + { + self::file($path); + $stream = fopen($path, 'rb'); + if ($stream === false) { throw new \RuntimeException('Native record unavailable'); } + try { $bytes = stream_get_contents($stream, 16385); } finally { fclose($stream); } + if ($bytes === false || strlen($bytes) > 16384) { throw new \RuntimeException('Native record unavailable or oversized'); } + return $bytes; + } + + private static function read(string $path): array + { + $value = json_decode(self::bytes($path), true, 16, JSON_THROW_ON_ERROR); + if (!is_array($value) || array_is_list($value)) { throw new \RuntimeException('Native object record required'); } + return $value; + } + + private static function create(string $path, string $bytes): void + { + $stream = fopen($path, 'xb'); + if ($stream === false) { throw new \RuntimeException('Native record already exists or is unavailable'); } + try { + if (fwrite($stream, $bytes) !== strlen($bytes) || !fflush($stream) || !fsync($stream)) { + throw new \RuntimeException('Native request persistence failed; inspect pending operation'); + } + } finally { fclose($stream); } + } +} diff --git a/src/Civ13/Panels.php b/src/Civ13/Panels.php index cef5d2b8..6612779c 100644 --- a/src/Civ13/Panels.php +++ b/src/Civ13/Panels.php @@ -79,6 +79,13 @@ private function registerCommands(): void $this->setup = true; $this->discord->application->commands->freshen()->then(function (GlobalCommandRepository $commands): void { + if ($this->civ13->verifier->readOnly && !$commands->get('name', 'refreshverification')) { + $commands->save(new Command($this->discord, [ + 'name' => 'refreshverification', 'description' => 'Refresh Desktop verification from the primary hosted bot.', + 'contexts' => [Interaction::CONTEXT_TYPE_GUILD], + 'default_member_permissions' => (string) new RolePermission($this->discord, ['moderate_members' => true]), + ])); + } if (! $commands->get('name', 'staffpanel')) { $commands->save(new Command($this->discord, [ 'name' => 'staffpanel', @@ -99,6 +106,22 @@ private function registerCommands(): void private function registerListeners(): void { + if ($this->civ13->verifier->readOnly) { + $this->discord->listenCommand('refreshverification', function (Interaction $i): PromiseInterface { + if ($i->guild_id !== $this->civ13->civ13_guild_id || !$this->isStaff($i->member)) { return $this->deny($i); } + return $i->acknowledgeWithResponse(true)->then(function () use ($i): PromiseInterface { + // Recheck staff at execution, after the asynchronous acknowledgement. + if (!$this->isStaff($i->member)) { return $i->updateOriginalResponse($this->plain('You no longer have permission.')); } + return $this->civ13->verifier->refreshHosted()->then(function (array $status) use ($i): PromiseInterface { + $message = ($status['available'] ? 'Hosted verification refreshed.' : 'Hosted verification unavailable; retained list is STALE and cannot authorize.') + .' Last successful refresh: '.($status['last_success_utc'] ?? 'never') + .'; records: '.($status['records'] ?? 'unavailable') + .($status['coalesced'] ? '; shared recent/in-progress refresh (10-second limit).' : ''); + return $i->updateOriginalResponse($this->plain($message)); + }); + }); + }); + } $this->discord->listenCommand('staffpanel', function (Interaction $i): PromiseInterface { if (! $this->isStaff($i->member)) { return $i->respondWithMessage($this->plain('You need a staff role or `Moderate Members` to open this.'), true); diff --git a/src/Civ13/PortableProfile.php b/src/Civ13/PortableProfile.php index 647cbf14..2d12f4e4 100644 --- a/src/Civ13/PortableProfile.php +++ b/src/Civ13/PortableProfile.php @@ -25,6 +25,12 @@ public static function load(string $file): array if (!is_string($server[$field] ?? null) || $server[$field] === '') { throw new \InvalidArgumentException('Explicit server identity required'); } } if (!is_bool($server['enabled'] ?? null)) { throw new \InvalidArgumentException('Explicit enabled flag required'); } + if (isset($server['native_lifecycle'])) { + if (!is_array($server['native_lifecycle']) || !is_bool($server['native_lifecycle']['enabled'] ?? null) + || ($server['native_lifecycle']['enabled'] && !$server['enabled'])) { + throw new \InvalidArgumentException('Explicit native lifecycle and server activation flags required'); + } + } foreach (['discussion','playercount','ooc','lobby','asay','ic','transit','adminlog','debug','garbage','runtime','attack'] as $field) { if (!is_string($server[$field] ?? null) || ($server[$field] !== '' && !preg_match('/^[0-9]{17,20}$/D', $server[$field]))) { throw new \InvalidArgumentException('Explicit channel string required'); } } @@ -57,6 +63,16 @@ public static function runtimeOptions(array $profile): array $options['filecache_path'] = $profile['state_dir'].'/json/'; $options['discover_external_ip'] = false; $options['verify_url'] = ''; + $options['verification_read_only'] = false; + if (array_key_exists('verification_source', $profile)) { + if (!is_array($profile['verification_source']) || count($profile['verification_source']) !== 2 + || ($profile['verification_source']['mode'] ?? null) !== 'read-only' + || ($profile['verification_source']['url'] ?? null) !== 'http://civ13.com:55555/verified') { + throw new \InvalidArgumentException('Expected the explicit hosted read-only verification source'); + } + $options['verify_url'] = $profile['verification_source']['url']; + $options['verification_read_only'] = true; + } $options['webserver_url'] = '127.0.0.1'; return $options; } diff --git a/src/Civ13/Slash.php b/src/Civ13/Slash.php index c8a49392..4e700d72 100644 --- a/src/Civ13/Slash.php +++ b/src/Civ13/Slash.php @@ -608,6 +608,17 @@ private function __declareListeners(): void return $this->respondWithMessage($interaction, Civ13::createBuilder()->setContent("No gamserver found for `{$interaction->data->options['server']->value}`")); } $gameserver = &$this->civ13->enabled_gameservers[$interaction->data->options['server']->value]; + if (PHP_OS_FAMILY === 'Windows' || getenv('CIVILIZATIONBOT_PROFILE')) { + // Acknowledge within Discord's deadline; never equate a queued request with readiness. + return $interaction->acknowledgeWithResponse(true)->then(function () use ($interaction, $gameserver): PromiseInterface { + try { $request = $gameserver->Restart(); } + catch (\Throwable) { return $interaction->updateOriginalResponse(Civ13::createBuilder()->setContent('Native restart is unavailable; inspect the local supervisor profile.')); } + return $request->then( + fn () => $interaction->updateOriginalResponse(Civ13::createBuilder()->setContent('The local supervisor reports save-safe restart and readiness.')), + fn () => $interaction->updateOriginalResponse(Civ13::createBuilder()->setContent('Native restart rejected or outcome unknown. Inspect the retained supervisor operation before retrying.')) + ); + }); + } $gameserver->Restart(); return $this->respondWithMessage($interaction, Civ13::createBuilder()->setContent("Attempted to kill, update, and bring up `{$gameserver->name}` ip}:{$gameserver->port}>")); diff --git a/src/Civ13/Verifier.php b/src/Civ13/Verifier.php index 2d86ee71..7db1c136 100644 --- a/src/Civ13/Verifier.php +++ b/src/Civ13/Verifier.php @@ -39,6 +39,9 @@ class Verifier public Discord $discord; public Logger $logger; public readonly string $verify_url; + public readonly bool $readOnly; + private ?HostedVerificationSnapshot $hosted = null; + private const READ_ONLY_MESSAGE = 'Desktop uses the hosted Civilizationbot verification source. Complete verification with the primary bot.'; public CollectionInterface $verified; // This probably needs a default value for Collection, maybe make it a Repository instead? public CollectionInterface $pending; public CollectionInterface $provisional; // Allow provisional registration if the website is down, then try to verify when it comes back up @@ -58,7 +61,18 @@ public function __construct(Civ13 &$civ13, array $options = []) $this->logger = &$civ13->logger; $this->resolveOptions($options); $this->verify_url = $options['verify_url']; + $this->readOnly = ($options['verification_read_only'] ?? false) === true; + if ($this->readOnly && $this->verify_url !== HostedVerificationSource::URL) { + throw new \InvalidArgumentException('Unexpected read-only verification source'); + } $this->pending = new Collection([], 'discord'); + if ($this->readOnly) { + $this->hosted = new HostedVerificationSnapshot(); + $this->provisional = new Collection([], 'ss13'); + $this->verified = new Collection([], 'discord'); + $this->afterConstruct(); + return; + } if (! $provisional = $this->civ13->VarLoad('provisional.json')) { $provisional = []; $this->civ13->VarSave('provisional.json', $provisional); @@ -77,6 +91,16 @@ public function resolveOptions(array &$options) /** Wires up `GUILD_MEMBER_ADD` handling (role sync plus an 8640s verify-or-kick timer) and runs {@see setup()} once the bot is ready. */ public function afterConstruct(): void { + if ($this->readOnly) { + // No role sync, provision replay, registration or member/thread mutation listeners. + $initialize = function (): void { + $this->refreshHosted(); + $this->verifierStatusTimer(); + $this->setup(); + }; + $this->civ13->ready ? $initialize() : $this->discord->once('init', $initialize); + return; + } $this->civ13->discord->on('GUILD_MEMBER_ADD', function (Member $member): void { $this->getVerified(); $this->joinRoles($member); @@ -275,6 +299,7 @@ public function verifierStatusChannelUpdate(bool $status): ?PromiseInterface */ public function joinRoles(Member $member, bool $send_welcome_message = true): ?PromiseInterface { + if (isset($this->readOnly) && $this->readOnly) { return null; } if ($member->guild_id === $this->civ13->civ13_guild_id && $item = $this->get('discord', $member->id)) { if (! isset($item['ss13'])) { $this->logger->warning("Verified member `{$member->id}` does not have an SS13 ckey assigned to them."); @@ -316,6 +341,7 @@ public function joinRoles(Member $member, bool $send_welcome_message = true): ?P */ public function generateToken(string $ckey, string $discord_id, string $charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', int $length = 50): string { + if (isset($this->readOnly) && $this->readOnly) { throw new \LogicException(self::READ_ONLY_MESSAGE); } if ($item = $this->pending->get('ss13', $ckey)) { return $item['token']; } @@ -344,6 +370,7 @@ public function generateToken(string $ckey, string $discord_id, string $charset */ public function checkToken(string $discord_id): bool { // Check if the user set their token + if (isset($this->readOnly) && $this->readOnly) { return false; } if (! $item = $this->pending->get('discord', $discord_id)) { return false; } // User is not in pending collection (This should never happen and is probably a programming error) @@ -368,6 +395,7 @@ public function checkToken(string $discord_id): bool */ public function process(string $ckey, string $discord_id, ?Member $m = null): string { + if (isset($this->readOnly) && $this->readOnly) { return self::READ_ONLY_MESSAGE; } if (! $ckey = Civ13::sanitizeInput($ckey)) { return 'Invalid ckey!'; } @@ -460,6 +488,7 @@ public function process(string $ckey, string $discord_id, ?Member $m = null): st */ public function new(string $discord_id): array // ['success' => bool, 'error' => string] { // Attempt to verify a user + if (isset($this->readOnly) && $this->readOnly) { return ['success' => false, 'error' => self::READ_ONLY_MESSAGE]; } if (! $item = $this->pending->get('discord', $discord_id)) { return ['success' => false, 'error' => "This error should never happen. If this error persists, contact <@{$this->civ13->technician_id}>."]; } @@ -488,6 +517,7 @@ public function new(string $discord_id): array // ['success' => bool, 'error' => */ public function register(string $ckey, string $discord_id): array // ['success' => bool, 'error' => string] { + if (isset($this->readOnly) && $this->readOnly) { return ['success' => false, 'error' => self::READ_ONLY_MESSAGE]; } $this->civ13->permitCkey($ckey, true); return $this->verify($ckey, $discord_id); @@ -504,6 +534,7 @@ public function register(string $ckey, string $discord_id): array // ['success' */ public function verify(string $ckey, string $discord_id, bool $provisional = false): array // ['success' => bool, 'error' => string] { // Send $_POST information to the website. Only call this function after the token verification process has been completed! + if (isset($this->readOnly) && $this->readOnly) { return ['success' => false, 'error' => self::READ_ONLY_MESSAGE]; } $success = false; $error = ''; @@ -634,6 +665,7 @@ public function verify(string $ckey, string $discord_id, bool $provisional = fal */ public function unverify(string $id): array // ['success' => bool, 'message' => string] { + if (isset($this->readOnly) && $this->readOnly) { return ['success' => false, 'message' => self::READ_ONLY_MESSAGE]; } if (! $verified_array = $this->civ13->VarLoad('verified.json')) { $this->logger->warning('Unable to load the verified list.'); @@ -744,6 +776,7 @@ private function __unverify(string $id): array */ private function __verifyRequest(array $postfields): array { + if (isset($this->readOnly) && $this->readOnly) { return ['response' => self::READ_ONLY_MESSAGE, 'http_status' => 503]; } $postfields['token'] = $this->civ13->civ_token; if (isset($this->civ13->verifier_server) && $this->civ13->verifier_server !== null) { // Mock @@ -798,6 +831,7 @@ private function __verifyRequest(array $postfields): array */ public function provisionalRegistration(string $ckey, string $discord_id): bool { + if (isset($this->readOnly) && $this->readOnly) { return false; } $provisionalRegistration = function (string $ckey, string $discord_id) use (&$provisionalRegistration) { if ($this->verified->get('discord', $discord_id)) { // User already verified, this function shouldn't be called (may happen anyway because of the timer) $this->provisional->pull($ckey); @@ -876,6 +910,7 @@ public function provisionalRegistration(string $ckey, string $discord_id): bool */ public function provision(?string $ckey = '', ?string $discord_id = ''): PromiseInterface { + if (isset($this->readOnly) && $this->readOnly) { return reject(new \LogicException(self::READ_ONLY_MESSAGE)); } if (! $ckey || ! $discord_id) { return reject(new \InvalidArgumentException('Invalid format! Please use the format `provision ; `.')); } @@ -925,6 +960,10 @@ public function isVerified(...$inputs): bool */ public function getVerified(bool $initialize = true): CollectionInterface { + if ($this->readOnly) { + if (!$this->hostedStatus()['available']) { throw new VerifierException('Hosted verification unavailable; last valid list retained as stale'); } + return $this->verified; + } $this->logger->debug('Refreshing verified list...'); ['json' => $json, 'http_status' => $http_status] = $this->fetchVerifiedData(); @@ -963,6 +1002,10 @@ public function getVerified(bool $initialize = true): CollectionInterface */ public function fetchVerifiedData(): array { + if (isset($this->readOnly) && $this->readOnly) { + return $this->hostedStatus()['available'] ? ['json' => json_encode($this->hosted->records(), JSON_THROW_ON_ERROR), 'http_status' => 200] + : ['json' => '', 'http_status' => 0]; + } if (isset($this->civ13->verifier_server) && $this->civ13->verifier_server !== null) { return [ 'json' => json_encode($this->civ13->verifier_server->getState()->getVerifyList()), @@ -1038,6 +1081,7 @@ public function getVerifiedItem(Member|User|array|string $input): ?array */ public function getVerifiedMemberItems(): CollectionInterface { + if (isset($this->readOnly) && $this->readOnly && !$this->hostedStatus()['available']) { throw new VerifierException('Hosted verification unavailable'); } if ($guild = $this->civ13->discord->guilds->get('id', $this->civ13->civ13_guild_id)) { return $this->verified->filter(function ($v) use ($guild) { return $guild->members->has($v['discord']); @@ -1112,6 +1156,10 @@ public function verifierStatusTimer(): TimerInterface { if (! isset($this->timers['verifier_status_timer'])) { $this->timers['verifier_status_timer'] = $this->discord->getLoop()->addPeriodicTimer(1800, function () { + if ($this->readOnly) { + $this->refreshHosted(); + return; + } if (! $status = $this->civ13->verifier_online) { $this->getVerified(false); // Check if the verifier is back online, but don't try to reload the verified list from the file cache if ($status !== $this->civ13->verifier_online) { @@ -1135,6 +1183,9 @@ public function verifierStatusTimer(): TimerInterface */ public function get(string $discrim, $key): mixed { + if (isset($this->readOnly) && $this->readOnly && !$this->hostedStatus()['available']) { + throw new VerifierException('Hosted verification unavailable'); + } if ($item = $this->verified->get($discrim, $key)) { return $item; } @@ -1152,8 +1203,28 @@ public function get(string $discrim, $key): mixed */ public function getIterator(): Traversable { + if (isset($this->readOnly) && $this->readOnly && !$this->hostedStatus()['available']) { throw new VerifierException('Hosted verification unavailable'); } return $this->verified->getIterator(); } + + public function hostedStatus(): array + { + if (!$this->hosted) { throw new \LogicException('Hosted read-only verification is not configured'); } + $status = $this->hosted->status(); + $this->civ13->verifier_online = $status['available']; + return $status; + } + + public function refreshHosted(): PromiseInterface + { + if (!$this->hosted) { throw new \LogicException('Hosted read-only verification is not configured'); } + return $this->hosted->refresh(fn () => HostedVerificationSource::fetch($this->civ13->browser))->then(function (array $status): array { + $this->verified = new Collection($this->hosted->records(), 'discord'); + $this->civ13->verifier_online = $status['available']; + $this->logger->info('Hosted verification refresh', $status); + return $status; + }); + } // Magic Methods /** * Cancels every timer this verifier registered. diff --git a/tests/windows/hosted-verifier.php b/tests/windows/hosted-verifier.php new file mode 100644 index 00000000..c38b0b6f --- /dev/null +++ b/tests/windows/hosted-verifier.php @@ -0,0 +1,83 @@ +addPsr4('Civ13\\', __DIR__.'/../../src/Civ13/', true); +function check(bool $ok): void { if (!$ok) { throw new RuntimeException('Hosted verification contract failed'); } } +function rejects(callable $call): void { try { $call(); } catch (Throwable) { return; } throw new RuntimeException('Expected unavailable/rejection'); } +final class HostedBrowser extends React\Http\Browser { + public int $calls = 0; public int $status = 200; public bool $timeout = false; + public string $body = '[{"ss13":"fixtureplayer","discord":"123456789012345678","ignored":"not retained"}]'; + public ?React\Promise\Deferred $pending = null; public array $settings = []; + public function __construct() {} + public function withTimeout($value) { $this->settings['timeout']=$value; return $this; } + public function withFollowRedirects($value) { $this->settings['redirects']=$value; return $this; } + public function withRejectErrorResponse($value) { $this->settings['reject_errors']=$value; return $this; } + public function withResponseBuffer($value) { $this->settings['buffer']=$value; return $this; } + public function get($url, array $headers = []) { + ++$this->calls; check($url===Civ13\HostedVerificationSource::URL && $headers===['Accept'=>'application/json']); + if($this->pending) { return $this->pending->promise(); } + return $this->timeout ? React\Promise\reject(new RuntimeException('Synthetic timeout')) + : React\Promise\resolve(new React\Http\Message\Response($this->status, [], $this->body)); + } +} +final class HostedDiscord extends Discord\Discord { + public function __construct(private React\EventLoop\LoopInterface $testLoop) {} + public function getLoop(): React\EventLoop\LoopInterface { return $this->testLoop; } +} +$bot=(new ReflectionClass(Civ13\Civ13::class))->newInstanceWithoutConstructor(); +$bot->loop=new React\EventLoop\StreamSelectLoop(); $bot->discord=new HostedDiscord($bot->loop); +$bot->logger=new Monolog\Logger('offline-hosted',[new Monolog\Handler\NullHandler()]); +$bot->browser=$browser=new HostedBrowser(); $bot->ready=true; +$verifier=new Civ13\Verifier($bot,['verify_url'=>Civ13\HostedVerificationSource::URL,'verification_read_only'=>true]); +$now=1000; $snapshot=new Civ13\HostedVerificationSnapshot(static function() use (&$now): int { return $now; }); +(new ReflectionProperty(Civ13\Verifier::class,'hosted'))->setValue($verifier,$snapshot); +$verifier->refreshHosted(); +check($verifier->ready && $bot->verifier_online && count($verifier->getVerified())===1); +check($verifier->get('ss13','fixtureplayer')===['ss13'=>'fixtureplayer','discord'=>'123456789012345678']); +foreach(['GUILD_MEMBER_ADD','GUILD_MEMBER_REMOVE','GUILD_MEMBER_UPDATE','THREAD_CREATE'] as $event) { check(count($bot->discord->listeners($event))===0); } +check(count($verifier->provisional)===0 && count($verifier->pending)===0); +$calls=$browser->calls; +foreach(['new'=>['123456789012345678'],'register'=>['x','123456789012345678'],'verify'=>['x','123456789012345678'],'unverify'=>['123456789012345678']] as $method=>$args) { check($verifier->$method(...$args)['success']===false); } +check(str_contains($verifier->process('x','123456789012345678'),'primary bot')); +check(!$verifier->provisionalRegistration('x','123456789012345678') && !$verifier->checkToken('123456789012345678')); +rejects(fn()=>$verifier->generateToken('x','123456789012345678')); +$denied=false;$verifier->provision('x','123456789012345678')->then(null,static function() use (&$denied):void{$denied=true;});check($denied); +check((new ReflectionMethod(Civ13\Verifier::class,'__verifyRequest'))->invoke($verifier,['method'=>'DELETE'])['http_status']===503); +check($browser->calls===$calls && count($verifier->verified)===1 && count($verifier->provisional)===0); +check($browser->settings===['timeout'=>10,'redirects'=>false,'reject_errors'=>false,'buffer'=>4194304]); +foreach([301,401,403,404,500] as $status) { + $now+=11;$browser->status=$status;$before=$browser->calls;$verifier->refreshHosted(); + check($browser->calls===$before+1 && !$bot->verifier_online && count($verifier->verified)===1); + check($verifier->hostedStatus()['stale'] && $verifier->hostedStatus()['last_success_utc']!==null); + rejects(fn()=>$verifier->getVerified());rejects(fn()=>$verifier->isVerified('fixtureplayer')); + rejects(fn()=>$verifier->getVerifiedMemberItems());rejects(fn()=>$verifier->getIterator()); +} +$now+=11;$browser->status=200;$browser->timeout=true;$before=$browser->calls;$verifier->refreshHosted(); +check($browser->calls===$before+2 && !$bot->verifier_online);$browser->timeout=false; +foreach(['null','{"error":"unknown"}','invalid','[{}]','[{"ss13":"x","discord":123}]','[{"ss13":"x\\ny","discord":"123456789012345678"}]', + '[{"ss13":"x","discord":"123456789012345678"},{"ss13":"x","discord":"234567890123456789"}]',str_repeat('x',4194305)] as $bad) { + $now+=11;$browser->body=$bad;$verifier->refreshHosted();check(!$bot->verifier_online && count($verifier->verified)===1); +} +check(Civ13\HostedVerificationSource::validate('{"123456789012345678":{"ss13":"Fixture_Player","discord":"123456789012345678"}}')===[['ss13'=>'fixtureplayer','discord'=>'123456789012345678']]); +rejects(static fn()=>Civ13\HostedVerificationSource::validate('[{"ss13":"Fixture_Player","discord":"123456789012345678"},{"ss13":"fixtureplayer","discord":"234567890123456789"}]')); +$now+=11;$browser->body='[]';$verifier->refreshHosted();check($bot->verifier_online && count($verifier->verified)===0); +$now+=11;$browser->body='[{"ss13":"newplayer","discord":"234567890123456789"}]';$verifier->refreshHosted(); +check($verifier->get('ss13','fixtureplayer')===null && $verifier->isVerified('newplayer')); +$now+=11;$browser->pending=$deferred=new React\Promise\Deferred();$before=$browser->calls;$completed=false; +$verifier->refreshHosted()->then(static function() use (&$completed):void{$completed=true;}); +$shared=null;$verifier->refreshHosted()->then(static function(array $status) use (&$shared):void{$shared=$status;}); +check(!$completed && $verifier->hostedStatus()['refresh_in_progress'] && $browser->calls===$before+1); +$unrelatedTimer=false; +$bot->loop->addTimer(0,static function() use (&$unrelatedTimer,$verifier,$deferred,$bot):void{ + check($verifier->hostedStatus()['refresh_in_progress']);$unrelatedTimer=true; + $deferred->resolve(new React\Http\Message\Response(200,[],'[]'));$bot->loop->stop(); +});$bot->loop->run(); +check($unrelatedTimer && $completed && $shared['coalesced'] && $browser->calls===$before+1); +$browser->pending=null;$now+=1801;check(!$verifier->hostedStatus()['available']);rejects(fn()=>$verifier->getVerifiedMemberItems()); +$bot->enabled_gameservers=['synthetic'=>(object)[]];check($bot->bancheckTimer()===false); +$empty=new Civ13\HostedVerificationSnapshot();$state=null;$empty->refresh(static fn()=>['http_status'=>0])->then(static function($value) use (&$state):void{$state=$value;});check(!$state['has_valid_list']); +$profile=['options'=>['role_ids'=>[],'channel_ids'=>[]],'gitdir'=>__DIR__,'state_dir'=>__DIR__,'verification_source'=>['url'=>Civ13\HostedVerificationSource::URL,'mode'=>'read-only']]; +check(Civ13\PortableProfile::runtimeOptions($profile)['verification_read_only']); +foreach(['http://localhost/verified','http://civ13.com:55555/verified?token=x','https://other.invalid/verified'] as $url) { $profile['verification_source']['url']=$url;rejects(fn()=>Civ13\PortableProfile::runtimeOptions($profile)); } +echo "PASS asynchronous hosted GET, real setup, legacy write/listener barriers, stale lookup/role-timer denial, concurrent coalescing and unrelated timer progress, bounded retry, schema/size/identity/removal/empty contracts\n"; diff --git a/tests/windows/native-lifecycle-writer.php b/tests/windows/native-lifecycle-writer.php new file mode 100644 index 00000000..2e4cd2a6 --- /dev/null +++ b/tests/windows/native-lifecycle-writer.php @@ -0,0 +1,23 @@ +addPsr4('Civ13\\', __DIR__.'/../../src/Civ13/', true); +$fixture = json_decode(file_get_contents($directory.'/writer-fixture.json'), true, 16, JSON_THROW_ON_ERROR); +$adapter = new Civ13\NativeLifecycle($fixture['settings'], $fixture['server'], $fixture['repository'], new React\EventLoop\StreamSelectLoop()); +file_put_contents($directory.'/ready-'.$argv[3], 'ready'); +$deadline = microtime(true) + 5; +while (!is_file($directory.'/writers-go')) { + if (microtime(true) > $deadline) { exit(98); } + usleep(1000); + clearstatcache(); +} +try { $adapter->request('start'); exit(0); } +catch (RuntimeException) { exit(75); } diff --git a/tests/windows/native-lifecycle.php b/tests/windows/native-lifecycle.php new file mode 100644 index 00000000..a7604056 --- /dev/null +++ b/tests/windows/native-lifecycle.php @@ -0,0 +1,192 @@ +addPsr4('Civ13\\', __DIR__.'/../../src/Civ13/', true); + use Civ13\NativeLifecycle; + use React\EventLoop\LoopInterface; + use React\EventLoop\Timer\Timer; + use React\EventLoop\TimerInterface; + + function check(bool $value, string $message): void { if (!$value) { throw new RuntimeException($message); } } + function refuses(callable $call): void { + try { $call(); } catch (RuntimeException|InvalidArgumentException|UnexpectedValueException|LogicException $error) { return; } + throw new LogicException('Expected fail-closed rejection'); + } + function writeRecord(string $path, array $data): void { file_put_contents($path, json_encode($data, JSON_THROW_ON_ERROR|JSON_UNESCAPED_SLASHES)); } + + final class NativeLoop implements LoopInterface + { + public array $timers = []; + public bool $failRegistration = false; + public function addReadStream($stream, $listener) { throw new LogicException('Unexpected IO'); } + public function addWriteStream($stream, $listener) { throw new LogicException('Unexpected IO'); } + public function removeReadStream($stream) {} + public function removeWriteStream($stream) {} + public function addTimer($interval, $callback) { throw new LogicException('Unexpected legacy timer'); } + public function addPeriodicTimer($interval, $callback) { + if ($this->failRegistration) { throw new RuntimeException('Simulated interruption after publication'); } + $timer = new Timer($interval, $callback, true); $this->timers[spl_object_id($timer)] = $timer; return $timer; + } + public function cancelTimer(TimerInterface $timer) { unset($this->timers[spl_object_id($timer)]); } + public function futureTick($listener) { throw new LogicException('Unexpected future tick'); } + public function addSignal($signal, $listener) { throw new LogicException('Unexpected signal'); } + public function removeSignal($signal, $listener) {} + public function run() { throw new LogicException('Offline loop must not run'); } + public function stop() {} + public function tick(): void { foreach (array_values($this->timers) as $timer) { ($timer->getCallback())($timer); } } + } + + final class NativeMessage extends Discord\Parts\Channel\Message + { + public array $reactions = []; + public function __construct(?Discord\Discord $discord = null, array $attributes = [], bool $created = false) {} + public function react($emoticon): React\Promise\PromiseInterface { $this->reactions[] = $emoticon; return React\Promise\resolve(null); } + } + + $root = sys_get_temp_dir().'/native lifecycle & fixture-'.bin2hex(random_bytes(8)); + mkdir($root); $caseNumber = 0; $cases = 0; + $fixture = static function () use ($root, &$caseNumber): array { + $dir = $root.'/case-'.++$caseNumber; + mkdir($dir); mkdir($dir.'/software'); mkdir($dir.'/repository'); mkdir($dir.'/runs'); mkdir($dir.'/runs/owned'); mkdir($dir.'/bot-control'); + $dir = realpath($dir); $runtime = realpath($dir.'/runs/owned'); $repository = realpath($dir.'/repository'); + foreach (['software/supervisor.py', 'software/master_guard.py', 'dd.exe', 'runs/owned/civ13.dmb', 'runs/owned/civ13.rsc'] as $name) { file_put_contents($dir.'/'.$name, 'inert fixture '.$name); } + $daemon = realpath($dir.'/dd.exe'); + $config = ['root'=>$dir, 'repository'=>$repository, 'branch'=>'master', 'bind'=>'127.0.0.1', 'port'=>1714, 'map'=>'NOMADS_GAIA', 'daemon'=>$daemon, 'bot_control_protocol'=>NativeLifecycle::PROTOCOL]; + writeRecord($dir.'/supervisor.json', $config); + $settings = ['enabled'=>true, 'supervisor_root'=>$dir, 'daemon'=>$daemon, 'daemon_sha256'=>hash_file('sha256',$daemon), + 'supervisor_sha256'=>hash_file('sha256',$dir.'/software/supervisor.py'), 'guard_sha256'=>hash_file('sha256',$dir.'/software/master_guard.py'), 'config_sha256'=>hash_file('sha256',$dir.'/supervisor.json'), + 'expected'=>['revision'=>str_repeat('a',40),'map'=>'NOMADS_GAIA','generation'=>'round-v2:'.str_repeat('b',32),'dmb_sha256'=>hash_file('sha256',$runtime.'/civ13.dmb'),'rsc_sha256'=>hash_file('sha256',$runtime.'/civ13.rsc')]]; + $server = ['enabled'=>true,'ip'=>'127.0.0.1','port'=>'1714','basedir'=>$runtime]; + $loop = new NativeLoop(); + $clock = (object)['now'=>0]; + $adapter = new NativeLifecycle($settings, $server, $repository, $loop, static fn ()=>$clock->now); + $capability = ['protocol'=>NativeLifecycle::PROTOCOL, 'binding_sha256'=>$adapter->description()['binding_sha256'], 'heartbeat_unix'=>time(), 'epoch'=>str_repeat('c',32),'sequence'=>1,'master_verified'=>true,'busy'=>false,'process'=>null]; + writeRecord($dir.'/bot-control/capability.json',$capability); + $process = ['pid'=>1234, 'creation_filetime'=>'134367709910115498','executable'=>$daemon,'working_directory'=>$runtime,'dmb_sha256'=>$settings['expected']['dmb_sha256'],'rsc_sha256'=>$settings['expected']['rsc_sha256']]; + return compact('dir','runtime','repository','settings','server','loop','clock','adapter','capability','process','config'); + }; + $complete = static function (array $f, string $action): array { + $request = json_decode(file_get_contents($f['dir'].'/bot-control/request.json'),true,16,JSON_THROW_ON_ERROR); + check($request['action']===$action,'Wrong fixed action'); + $result = array_intersect_key($request,array_flip(['protocol','operation','action','epoch','sequence','binding_sha256','before'])); + $after = $action==='save-stop' ? null : $f['process']; + if ($action==='restart') { $after['pid']=2345; $after['creation_filetime']='134367709910115499'; } + $result += ['outcome'=>'complete','master_verified'=>true,'ready'=>$action!=='save-stop','after'=>$after]; + if ($action!=='start') { $result += ['exit_acknowledged'=>true,'save_complete'=>true,'saved_generation'=>'round-v2:'.str_repeat('d',32),'save_receipt_sha256'=>str_repeat('e',64)]; } + if ($action!=='save-stop') { $result['restored_generation']=$action==='restart' ? $result['saved_generation'] : $f['settings']['expected']['generation']; } + return $result; + }; + $publishResult = static function (array $f, array $result, bool $consume = true): void { + if ($consume) { unlink($f['dir'].'/bot-control/request.json'); } + writeRecord($f['dir'].'/bot-control/result-'.$result['operation'].'.json',$result); + }; + $await = static function ($promise, array $f, ?array &$success, ?Throwable &$failure): void { + $promise->then(static function ($value) use (&$success): void {$success=$value;},static function ($error) use (&$failure): void {$failure=$error;}); + $f['loop']->tick(); + }; + try { + // Default rejection does not need a valid supervisor, credentials or dependencies. + $f=$fixture(); $disabled=$f['settings'];$disabled['enabled']=false; + refuses(fn()=>new NativeLifecycle($disabled,$f['server'],$f['repository'],$f['loop'])); ++$cases; + foreach (['ip'=>'civ13.com','port'=>1715,'enabled'=>false,'basedir'=>$root] as $field=>$bad) { + $server=$f['server'];$server[$field]=$bad;refuses(fn()=>new NativeLifecycle($f['settings'],$server,$f['repository'],$f['loop'])); ++$cases; + } + foreach (['map'=>'NOMADS_EUROPE','bind'=>'0.0.0.0','branch'=>'pr-test','bot_control_protocol'=>'old','repository'=>$root] as $field=>$bad) { + $f=$fixture();$config=$f['config'];$config[$field]=$bad;writeRecord($f['dir'].'/supervisor.json',$config);$settings=$f['settings'];$settings['config_sha256']=hash_file('sha256',$f['dir'].'/supervisor.json'); + refuses(fn()=>new NativeLifecycle($settings,$f['server'],$f['repository'],$f['loop'])); ++$cases; + } + foreach (['software/supervisor.py','software/master_guard.py','supervisor.json','dd.exe','runs/owned/civ13.dmb','runs/owned/civ13.rsc'] as $file) { + $f=$fixture();file_put_contents($f['dir'].'/'.$file,'changed');refuses(fn()=>$f['adapter']->request('start'));check(!file_exists($f['dir'].'/bot-control/pending.json'),'Changed source created a request');++$cases; + } + foreach (['protocol'=>'old','heartbeat_unix'=>time()-30,'busy'=>true,'master_verified'=>false,'binding_sha256'=>str_repeat('f',64)] as $field=>$bad) { + $f=$fixture();$cap=$f['capability'];$cap[$field]=$bad;writeRecord($f['dir'].'/bot-control/capability.json',$cap);refuses(fn()=>$f['adapter']->request('start'));++$cases; + } + $f=$fixture();file_put_contents($f['dir'].'/HALTED.json','preserve');refuses(fn()=>$f['adapter']->request('start'));check(file_get_contents($f['dir'].'/HALTED.json')==='preserve','Halt latch changed');++$cases; + $f=$fixture();unlink($f['dir'].'/bot-control/capability.json');refuses(fn()=>$f['adapter']->request('start'));++$cases; + $f=$fixture();refuses(fn()=>$f['adapter']->request('mapswap'));refuses(fn()=>$f['adapter']->request('start & arbitrary'));refuses(fn()=>$f['adapter']->request('save-stop'));++$cases; + + foreach (['start','save-stop','restart'] as $action) { + $f=$fixture();if($action!=='start'){$cap=$f['capability'];$cap['process']=$f['process'];writeRecord($f['dir'].'/bot-control/capability.json',$cap);} + $success=null;$failure=null;$promise=$f['adapter']->request($action);$promise->then(static function($v)use(&$success){$success=$v;},static function($e)use(&$failure){$failure=$e;}); + check($success===null&&$failure===null,'Queued request reported completion'); + $transition=fopen($f['dir'].'/bot-control/adapter.lock','c+b');check(flock($transition,LOCK_EX|LOCK_NB),'Publication lock remained held during polling');flock($transition,LOCK_UN);fclose($transition); + // Both an in-flight instance and a simulated bot restart must refuse duplicate submission. + refuses(fn()=>$f['adapter']->request($action)); + $other=new NativeLifecycle($f['settings'],$f['server'],$f['repository'],$f['loop']);refuses(fn()=>$other->request($action)); + $result=$complete($f,$action);$publishResult($f,$result);$f['loop']->tick(); + check($failure===null&&$success===$result,'Bound completion not accepted');check(!file_exists($f['dir'].'/bot-control/pending.json'),'Successful pending not acknowledged');check(file_exists($f['dir'].'/bot-control/result-'.$result['operation'].'.json'),'Result evidence discarded');++$cases; + // A fresh bot instance cannot replay a successful action against stale capability state. + refuses(fn()=>$other->request($action));++$cases; + if($action==='save-stop') { + $cap=$f['capability'];$cap['sequence']=2;writeRecord($f['dir'].'/bot-control/capability.json',$cap); + refuses(fn()=>$other->request('start'));check(!file_exists($f['dir'].'/bot-control/request.json'),'Save-stop allowed a stale-generation start');++$cases; + $updated=$f['settings'];$updated['expected']['generation']=$result['saved_generation']; + $reviewed=new NativeLifecycle($updated,$f['server'],$f['repository'],$f['loop']);$cap['binding_sha256']=$reviewed->description()['binding_sha256'];writeRecord($f['dir'].'/bot-control/capability.json',$cap); + $promise=$reviewed->request('start');$f['settings']=$updated;$result=$complete($f,'start');$publishResult($f,$result);$success=null;$failure=null;$await($promise,$f,$success,$failure); + check($failure===null&&$success['restored_generation']===$updated['expected']['generation'],'Reconciled start did not require newly saved generation');++$cases; + } + } + foreach (['operation'=>str_repeat('0',32),'epoch'=>str_repeat('0',32),'binding_sha256'=>str_repeat('0',64),'outcome'=>'unknown','master_verified'=>false,'restored_generation'=>'round-v2:'.str_repeat('0',32),'ready'=>false] as $field=>$bad) { + $f=$fixture();$promise=$f['adapter']->request('start');$result=$complete($f,'start');$original=$result['operation'];$result[$field]=$bad; + unlink($f['dir'].'/bot-control/request.json');writeRecord($f['dir'].'/bot-control/result-'.$original.'.json',$result);$success=null;$failure=null;$await($promise,$f,$success,$failure); + check($success===null&&$failure!==null,'Unbound/unknown result was accepted');check(is_file($f['dir'].'/bot-control/pending.json'),'Unknown outcome lost persistent latch');refuses(fn()=>$f['adapter']->request('start'));++$cases; + } + foreach (['pid'=>0,'creation_filetime'=>'reused','executable'=>__FILE__,'working_directory'=>$root,'dmb_sha256'=>str_repeat('f',64)] as $field=>$bad) { + $f=$fixture();$promise=$f['adapter']->request('start');$result=$complete($f,'start');$result['after'][$field]=$bad;$publishResult($f,$result);$success=null;$failure=null;$await($promise,$f,$success,$failure);check($success===null&&$failure!==null,'Wrong process identity accepted');++$cases; + } + foreach (['exit_acknowledged'=>false,'save_complete'=>false,'saved_generation'=>'old','save_receipt_sha256'=>'missing'] as $field=>$bad) { + $f=$fixture();$cap=$f['capability'];$cap['process']=$f['process'];writeRecord($f['dir'].'/bot-control/capability.json',$cap); + $promise=$f['adapter']->request('save-stop');$result=$complete($f,'save-stop');$result[$field]=$bad;$publishResult($f,$result);$success=null;$failure=null;$await($promise,$f,$success,$failure);check($success===null&&$failure!==null,'Unsafe stop accepted');++$cases; + } + $f=$fixture();$cap=$f['capability'];$cap['process']=$f['process'];writeRecord($f['dir'].'/bot-control/capability.json',$cap);$promise=$f['adapter']->request('restart');$result=$complete($f,'restart');$result['after']=$f['process'];$publishResult($f,$result);$success=null;$failure=null;$await($promise,$f,$success,$failure);check($failure!==null,'Same-process restart accepted');++$cases; + $f=$fixture();$promise=$f['adapter']->request('start');$result=$complete($f,'start');$publishResult($f,$result,false);$success=null;$failure=null;$await($promise,$f,$success,$failure);check($failure!==null,'Unconsumed request accepted');++$cases; + $f=$fixture();$promise=$f['adapter']->request('start');$f['clock']->now=601*1000000000;$success=null;$failure=null;$await($promise,$f,$success,$failure);check($failure!==null&&is_file($f['dir'].'/bot-control/pending.json')&&is_file($f['dir'].'/bot-control/request.json'),'Timeout retried, killed, or lost evidence');++$cases; + $f=$fixture();$promise=$f['adapter']->request('start');$result=$complete($f,'start');$publishResult($f,$result);file_put_contents($f['dir'].'/runs/owned/civ13.dmb','changed after request');$success=null;$failure=null;$await($promise,$f,$success,$failure);check($failure!==null,'Build replacement during operation accepted');++$cases; + $f=$fixture();$promise=$f['adapter']->request('start');$request=json_decode(file_get_contents($f['dir'].'/bot-control/request.json'),true);unlink($f['dir'].'/bot-control/request.json');file_put_contents($f['dir'].'/bot-control/result-'.$request['operation'].'.json','malformed');$success=null;$failure=null;$await($promise,$f,$success,$failure);check($failure!==null,'Malformed result accepted');++$cases; + $f=$fixture();file_put_contents($f['dir'].'/bot-control/pending.json','interrupted-before-publication');refuses(fn()=>$f['adapter']->request('start'));check(!file_exists($f['dir'].'/bot-control/request.json'),'Interrupted publication was replayed');++$cases; + $f=$fixture();$f['loop']->failRegistration=true;refuses(fn()=>$f['adapter']->request('start'));check(is_file($f['dir'].'/bot-control/pending.json')&&is_file($f['dir'].'/bot-control/request.json'),'Post-publication interruption lost evidence');$other=new NativeLifecycle($f['settings'],$f['server'],$f['repository'],new NativeLoop());refuses(fn()=>$other->request('start'));++$cases; + // Genuine concurrent PHP writers: exactly one durable packet, the loser cannot replace it. + $f=$fixture();writeRecord($f['dir'].'/writer-fixture.json',array_intersect_key($f,array_flip(['settings','server','repository'])));$children=[]; + try { + foreach(['a','b'] as $id) { + $environment=array_filter(['SystemRoot'=>getenv('SystemRoot'),'LD_LIBRARY_PATH'=>getenv('LD_LIBRARY_PATH'),'PHPRC'=>getenv('PHPRC'),'PHP_INI_SCAN_DIR'=>getenv('PHP_INI_SCAN_DIR'),'TEMP'=>sys_get_temp_dir(),'TMP'=>sys_get_temp_dir()],static fn($value)=>$value!==false); + $child=proc_open([PHP_BINARY,__DIR__.'/native-lifecycle-writer.php',realpath($argv[1]??__DIR__.'/../../vendor/autoload.php'),$f['dir'],$id], + [0=>['file',PHP_OS_FAMILY==='Windows'?'NUL':'/dev/null','r'],1=>['file',$f['dir'].'/writer-'.$id.'.out','w'],2=>['file',$f['dir'].'/writer-'.$id.'.err','w']],$pipes,$f['dir'],$environment,['bypass_shell'=>true,'create_new_console'=>false]); + check(is_resource($child),'Inert writer child unavailable');$children[]=$child; + } + $deadline=microtime(true)+5;while(!is_file($f['dir'].'/ready-a')||!is_file($f['dir'].'/ready-b')){if(microtime(true)>$deadline){throw new RuntimeException('Inert writers not ready');}usleep(1000);clearstatcache();} + file_put_contents($f['dir'].'/writers-go','go');$codes=[];foreach($children as $i=>$child){$codes[]=proc_close($child);unset($children[$i]);}sort($codes);check($codes===[0,75],'Concurrent publication did not admit exactly one writer'); + $pending=file_get_contents($f['dir'].'/bot-control/pending.json');check($pending===file_get_contents($f['dir'].'/bot-control/request.json'),'Concurrent packet overwritten or corrupted'); + $transition=fopen($f['dir'].'/bot-control/adapter.lock','c+b');check(flock($transition,LOCK_EX|LOCK_NB),'Exited writer retained transition lock');flock($transition,LOCK_UN);fclose($transition);++$cases; + } finally {foreach($children as $child){if(is_resource($child)){proc_terminate($child);proc_close($child);}}} + + // Exercise actual GameServer entry points before any legacy file, shell, timer or OOC access. + $old=getenv('CIVILIZATIONBOT_PROFILE');putenv('CIVILIZATIONBOT_PROFILE=offline-fixture'); + try { + foreach (['Host'=>'start','Kill'=>'save-stop','Restart'=>'restart'] as $method=>$action) { + $f=$fixture();if($action!=='start'){$cap=$f['capability'];$cap['process']=$f['process'];writeRecord($f['dir'].'/bot-control/capability.json',$cap);} + $server=(new ReflectionClass(Civ13\GameServer::class))->newInstanceWithoutConstructor();(new ReflectionProperty($server,'nativeLifecycle'))->setValue($server,$f['adapter']); + $message=new NativeMessage();$promise=$server->$method($message);check($message->reactions===[],'GameServer announced queued success');$result=$complete($f,$action);$publishResult($f,$result);$success=null;$failure=null;$await($promise,$f,$success,$failure);check($failure===null&&$success===$result&&$message->reactions===['👍'],'GameServer failed bound native route');++$cases; + } + $server=(new ReflectionClass(Civ13\GameServer::class))->newInstanceWithoutConstructor(); + foreach(['Host','Kill','Restart'] as $method){refuses(fn()=>$server->$method());}++$cases; + } finally {$old===false?putenv('CIVILIZATIONBOT_PROFILE'):putenv('CIVILIZATIONBOT_PROFILE='.$old);} + echo 'PASS '.$cases." native lifecycle controls: fixed spool, source/profile/build pins, fail-closed routing, serialization, owned identity, save-safe receipts and persistent unknown outcomes; no native process\n"; + } finally { + // Only this test's resolved temporary root is eligible for recursive cleanup. + $resolved=realpath($root);$temporary=realpath(sys_get_temp_dir()); + if ($resolved!==false&&str_starts_with($resolved,$temporary.DIRECTORY_SEPARATOR)&&basename($resolved)===basename($root)) { + $remove=static function(string $directory)use(&$remove):void{foreach(new DirectoryIterator($directory)as$file){if($file->isDot()){continue;}$file->isDir()&&!$file->isLink()?$remove($file->getPathname()):unlink($file->getPathname());}rmdir($directory);};$remove($resolved); + } + } +} diff --git a/tests/windows/ready.php b/tests/windows/ready.php index bf872bc6..c3709362 100644 --- a/tests/windows/ready.php +++ b/tests/windows/ready.php @@ -23,15 +23,44 @@ final class ReadyConnector implements React\Socket\ConnectorInterface { public function connect($uri) { throw new RuntimeException('Offline test attempted a connection: '.$uri); } } + final class ReadyHostedBrowser extends React\Http\Browser { + public function __construct() {} + public function withTimeout($value) { check($value===10, 'Unbounded hosted timeout'); return $this; } + public function withFollowRedirects($value) { check($value===false, 'Hosted redirect enabled'); return $this; } + public function withRejectErrorResponse($value) { return $this; } + public function withResponseBuffer($value) { check($value===4194304, 'Unbounded hosted body'); return $this; } + public function get($url, array $headers = []) { + check($url===Civ13\HostedVerificationSource::URL && $headers===['Accept'=>'application/json'], 'Unexpected hosted request'); + $GLOBALS['readyHostedCalls']=($GLOBALS['readyHostedCalls']??0)+1; + return React\Promise\resolve(new React\Http\Message\Response(200,[],'[]')); + } + } final class ReadyInteraction extends Discord\Parts\Interactions\Interaction { public array $responses = []; - public function __construct(Discord\Discord $discord, array $attributes = [], bool $created = false) {} + public bool $revokeOnAck = false; + public function __construct(Discord\Discord $discord, private array $fixtureAttrs = [], bool $created = false) {} + public function __get(string $name) { return $this->fixtureAttrs[$name] ?? null; } + public function acknowledgeWithResponse(bool $ephemeral = false): React\Promise\PromiseInterface { + check($ephemeral, 'Refresh acknowledgement must be private'); + if ($this->revokeOnAck) { $this->fixtureAttrs['member']->allowed = false; } + return React\Promise\resolve(null); + } + public function updateOriginalResponse(Discord\Builders\MessageBuilder $builder): React\Promise\PromiseInterface { + return $this->respondWithMessage($builder, true); + } public function respondWithMessage(Discord\Builders\MessageBuilder|string $builder, bool $ephemeral = false): React\Promise\PromiseInterface { $this->responses[] = [is_string($builder) ? $builder : $builder->getContent(), $ephemeral]; return React\Promise\resolve(null); } } + final class ReadyMember extends Discord\Parts\User\Member { + public bool $allowed = false; + public function __construct(private Discord\Discord $testDiscord, array $attributes = [], bool $created = false) {} + public function getPermissions($channel = null): ?Discord\Parts\Permissions\RolePermission { + return new Discord\Parts\Permissions\RolePermission($this->testDiscord, ['moderate_members' => $this->allowed]); + } + } final class ReadyLoop implements React\EventLoop\LoopInterface { public array $timers = []; @@ -115,6 +144,7 @@ public function commands(): array { return $this->application_commands ?? []; } function check(bool $condition, string $message): void { if (!$condition) { throw new RuntimeException($message); } } $unconfigured = ($argv[2] ?? null) === '--unconfigured'; + $hosted = ($argv[2] ?? null) === '--hosted'; $dir = sys_get_temp_dir().'/portable-ready-'.bin2hex(random_bytes(8)); mkdir($dir); $previous = getcwd(); @@ -152,6 +182,11 @@ function check(bool $condition, string $message): void { if (!$condition) { thro $options['civ13_guild_id'] = '100000000000000001'; $options['owner_id'] = '100000000000000003'; $options['technician_id'] = '100000000000000004'; + if ($hosted) { + $options['verification_read_only'] = true; + $options['verify_url'] = Civ13\HostedVerificationSource::URL; + $options['browser'] = new ReadyHostedBrowser(); + } if ($unconfigured) { $options['channel_ids'] = []; $options['civ13_guild_id'] = $options['owner_id'] = $options['technician_id'] = ''; @@ -182,6 +217,34 @@ function check(bool $condition, string $message): void { if (!$condition) { thro check(count($loop->timers) === $timerCount, 'Maintenance command scheduled background work: '.$name); } (new ReflectionMethod(Civ13\HttpServiceManager::class, '__generateEndpoints'))->invoke($bot->httpServiceManager); + if ($hosted) { + check(isset($discord->globalCommands->saved['refreshverification'], $discord->commands()['refreshverification']), 'Missing refresh command/listener'); + $callback = (new ReflectionProperty(Discord\Helpers\RegisteredCommand::class, 'callback'))->getValue($discord->commands()['refreshverification']); + $member = new ReadyMember($discord); $calls = $GLOBALS['readyHostedCalls']; + $i = new ReadyInteraction($discord, ['guild_id' => $bot->civ13_guild_id, 'member' => $member]); + $callback($i); check(count($i->responses) === 1 && $GLOBALS['readyHostedCalls'] === $calls, 'Nonstaff refresh attempted transport'); + $member->allowed = true; + $i = new ReadyInteraction($discord, ['guild_id' => 'wrong', 'member' => $member]); + $callback($i); check(count($i->responses) === 1 && $GLOBALS['readyHostedCalls'] === $calls, 'Cross-guild refresh attempted transport'); + $i = new ReadyInteraction($discord, ['guild_id' => $bot->civ13_guild_id, 'member' => $member]); $i->revokeOnAck = true; + $callback($i); check(count($i->responses) === 1 && $GLOBALS['readyHostedCalls'] === $calls, 'Revoked staff refresh attempted transport'); + $member->allowed = true; + (new ReflectionProperty(Civ13\Verifier::class, 'hosted'))->setValue($bot->verifier, new Civ13\HostedVerificationSnapshot()); + $i = new ReadyInteraction($discord, ['guild_id' => $bot->civ13_guild_id, 'member' => $member]); + $callback($i); check(count($i->responses) === 1 && str_contains($i->responses[0][0], 'Last successful refresh:') && $GLOBALS['readyHostedCalls'] === $calls + 1, 'Authorized refresh did not report result'); + (new ReflectionProperty(Civ13\Verifier::class, 'hosted'))->setValue($bot->verifier, new Civ13\HostedVerificationSnapshot()); + $read = $bot->httpServiceManager->httpHandler->offsetGet('/verified', 'handlers'); + $response = $read(new React\Http\Message\ServerRequest('GET', 'http://127.0.0.1/verified'), '/verified', false); + check($response->getStatusCode() === 503 && str_contains((string)$response->getBody(), 'last_success_utc'), 'Unavailable source returned successful empty list'); + // An uninitialized request would reach transport/error if the readonly early guard regresses. + $request = (new ReflectionClass(Discord\Parts\Guild\GuildJoinRequest::class))->newInstanceWithoutConstructor(); + $discord->emit('GUILD_JOIN_REQUEST_CREATE', [$request]); + $discord->emit('GUILD_JOIN_REQUEST_UPDATE', [$request]); + $bot->enabled_gameservers = ['synthetic' => (object)[]]; + check($bot->bancheckTimer() === false, 'Read-only verifier ran role/ban mutation timer'); + $bot->enabled_gameservers = []; + echo "HOSTED PASS staff-only refresh registration/action/recheck, source GET and unavailable HTTP503 metadata\n"; + } // Synthetic local sentinel only: no environment file is read or loaded. $sentinel = "OFFLINE_TEST_SENTINEL=keep-me\n"; file_put_contents($dir.'/.env', $sentinel);