diff --git a/DEVELOPERS.md b/DEVELOPERS.md index c58fe037..ee940cc1 100644 --- a/DEVELOPERS.md +++ b/DEVELOPERS.md @@ -47,6 +47,42 @@ AlphaGSM is a Python CLI that normalises game-server lifecycle management across - a runtime layer that can launch either local screen-backed processes or Docker containers - dedicated test layers for unit, integration, and streamed smoke coverage +## First-run guidance + +`core.main` routes only bare invocation, `--welcome` and `--skip-welcome` to +`core.welcome`. Automatic prompting requires both stdin and stdout to be TTYs +and no acknowledgement marker. All named server/agent commands, internal workers, +help and version follow their usual paths. Keep this boundary so automation +never blocks on first-run questions. + +The tour passes argv arrays back through ordinary dispatch after previews and +explicit approvals. It resolves game aliases through `MODULE_CATALOG`, restricts +local names, and rejects `core.server_names.RESERVED_SERVER_NAMES`, numeric selectors +and `all` before dispatch. Never let wildcard/count interpretation bypass the +existing-record check. Creation checks `lexists` before the preview and again +under the ordinary per-record state lock, refusing existing files or links. +Setup has separate approval; games never start from the tour. Optional agent +setup has a separate network confirmation and keeps its own enrollment/binding +permissions; the tour does not call capability-enable commands. + +`core.welcome_state` owns the non-secret, versioned `welcome-v1.done` marker under +configured `core.alphagsm_path`. Creation is exclusive, mode 0600, with no-follow +where available, ownership checks and no overwrite of existing markers. It +preserves existing directory permissions and keeps acknowledgement separate from +server/agent configuration and all consent. EOF/Ctrl+C stops further commands; +completed approved actions are not rolled back. + +Run the isolated first-run smoke checks with: + +```sh +PYTHON_BIN=python3 bash tests/smoke_tests/run_first_run_tour.sh +``` + +This covers unit boundaries plus actual CLI subprocesses and POSIX PTYs against +disposable config/home paths, including the `all` overwrite regression. PTY cases +require POSIX and skip on platforms without PTYs. It neither downloads game +assets nor starts servers, and does not establish game lifecycle support. + ## Documentation Split Keep repository docs split by audience and preferred operator path: @@ -1183,6 +1219,144 @@ When upgrading an existing source environment that installed the obsolete AlphaGSM uses the `python-crontab` distribution's `CronTab` API. A fresh binary or fresh environment does not need this cleanup. +## Sector Alpha outbound agent and shared public queries + +`agent.contracts` is the dependency-free closed version-one wire schema. +`server.public_query` contains the immutable public A2S_INFO/Java SLP registry, +uses bounded parsers in `utils.query`, and never constructs Server or falls back +to console/RCON/HTTP/TCP health. Central consumers must pin a reviewed commit +and exclude their own infrastructure before numeric connect. + +The optional `agent` package keeps scoped credentials and consent under a +private POSIX directory outside game datastores. Policy and report credentials +are independent. The foreground manager opens one outbound WebSocket for +presence and job wakes, and falls back to HTTPS long polling when the socket +cannot open. It rechecks exact local bindings and policy under the normal CLI +lock, obtains final online authorization and persists executing before lifecycle +effects. A socket frame never skips those checks. Results are posted over HTTPS. +Outbound HTTPS and the agent WebSocket use a verified client context with a +minimum of TLS 1.2. +`alphagsm agent run` starts the foreground agent and does not install a +service. `alphagsm agent install-service` writes `alphagsm.service` for the +current user, enables it, and starts it. `install-service --system` writes +the same unit under `/etc/systemd/system`, enables it for boot, and starts +it as one operator account, never as root. Both execute the invoked program +with `agent run`. There is no separate alphagsm-agent program. The Linux +account lookup is imported only for a system install, so Windows commands do +not load `grp` or `pwd`. Neither command changes reporting, management, +publication or credentials. `uninstall-service` disables, stops and removes +the selected unit and leaves the enrollment in place. The tour and game +setup do not install it. +`Journal` stores durable results until acknowledgement; interrupted execution +reconciles unknown and never repeats effects. A global foreground-service lock +prevents competing pollers. No secrets are accepted as CLI arguments. + +The official provider is `https://alphagsm-api.sector-alpha.net` for every +agent operation: enrollment redemption, binding registration, ownership-challenge +minting, POST stats, and the outbound execution protocol (policy acknowledgement, +the agent session socket, command polling, final authorization and results). +The agent does not ask which host to use. Sector Alpha verifies a challenge by +querying the server. Owners queue commands and view data on the website. +`Transport` sends every approved route to the one configured origin. It retains +one canonical API provider origin in local configuration, without redirects or +endpoint discovery. No new persisted secret/origin fields are introduced. + +`canonical_agent_origin()` still maps exact official HTTPS website/API authorities +(default/443 port, case and root-slash variants) to the canonical API provider +origin. `Config.load()` and the next ordinary write retain this normalization, +preserving IDs, keys, bindings and permissions. Explicit custom origins, lookalikes +and non-default ports keep the exact configured single origin for every agent path. +Restart foreground/user services after upgrading. The website still accepts +enrollment and binding registration from older clients. +The generic origin syntax validator still requires HTTPS roots and normalizes an +optional trailing slash. `agent.inventory` reads +only owned current-user JSON datastores without constructing Server (which can +migrate secrets or save runtime metadata). It exports an allowlist of module, +query port, primary player port, name, capacity and networking hints. `agent.network` accepts only globally +routable numerical suggestions, uses bounded TLS for its fixed address lookup, +and sends no agent credentials. Explicit `--host` bypasses discovery. + +`agent bind TAG`, `agent bind '*'` and `agent setup` preview and confirm exact +bindings before registration or config writes. The portal's authenticated +`POST /v1/agent/bindings` accepts only a selected enrolled scope and SHA256 of the +local tag; it derives installation ownership from that scoped credential and +returns an idempotent binding ID/epoch. Registration sends only the tag reference; +later reports contain approved display metadata, whose name may match the tag. +Datastore secrets stay local. Registration grants no actions or publication. Existing +bindings retain their approved values on repeat runs; capabilities require +separate local enablement. Games outside the public query registry remain private. + +Version-one report metadata has optional `connect_port`: a strict integer from +1 through 65535 or null. Existing required `port` remains the query port; unknown +metadata fields still fail validation. Missing `connect_port` remains valid for +legacy reports/configurations. Omission preserves the receiver's existing player +port; explicit null clears it. +Both presence and value participate in the final publication approval check. +Inventory infers it from the primary local `port`, independently of `queryport`; +guided and explicit binding approve it for every +canonical game. `--connect-port` overrides the advertised player port, while a +Minecraft `--port` override sets both unless the player override is explicit. +The final guided preview shows host/IP and both ports before saving. Existing +bindings acquire the field only through rebinding; runtime metadata refresh +never infers or changes either approved port. Registration remains scope/ref +only, and endpoint metadata uses the opted-in report channel. This does not add +public query profiles, client launch schemes or Steam client IDs. +Deploy the receiver's updated shared contract before clients create/rebind +servers with this field; old closed-schema receivers reject it. Existing saved +bindings keep omitting it until a confirmed rebind, preserving their wire format. + +Hostnames are scoped to bindings, not installations or numerical machine IPs. +Different game tags may advertise different subdomains that resolve to the same +IP; each retains its own game profile, player port, query port, expected-IP policy, +publication consent and central ownership challenge. Single-server rebinding +does not mutate siblings, and wildcard endpoint prompts preserve per-server +hostnames on repeat runs. The agent neither provisions DNS nor routes traffic +between processes; a hostname alone cannot multiplex a shared numerical port. + +`agent.preflight` applies hostname checks to copied public report items immediately +before transport. Optional per-binding `expected_public_ips` is local policy only; +it never enters metadata, registration or wire schemas. Automatic expectations +read allowlisted local network hints and use one route/IP lookup per batch. Fresh +complete A/AAAA answers must all be global and match expected machine addresses. +`agent.dns_lookup` uses pinned dnspython async resolution with absolute lifetimes, +an eight-worker bound and a 30-second batch ceiling. Timeout or mismatch changes +only the outgoing publication flag; later repair can restore publication without +rewriting consent. Numeric/private reports keep their existing behavior. + +After DNS, the service rechecks current publication, endpoint, expected IPs and +provider relationship under the config lock, then sends with the current reporting +key before releasing the lock. Report POSTs have a hard ten-second HTTPS budget, +including DNS, TLS, headers and body. Expiry closes active sockets; a stalled system +resolver cannot later connect or accumulate additional workers. A service instance +retains its initial origin/installation relationship and must restart after local +re-enrollment before sending any new relationship's credentials. + +The explicit `check-endpoint` diagnostic additionally probes only pinned matched +addresses through the immutable native registry, retaining the original SLP +virtual host and sharing a five-second protocol budget. It does not run as a +publication renewal hook, avoiding NAT-loopback failures turning into publication +flapping. Neither local check grants central endpoint verification. Standalone +builds collect `dns` dynamic protocol modules as well as the agent package. + +Trusted local runtime helpers run in separate process groups with bounded +state/operation deadlines. `held_descriptor`/`inherited_state_lock` hand the +existing datastore sidecar lock to the child after an inode check. The helper +marks the descriptor non-inheritable for further command children and never +unlocks the shared open-file description; the parent retains exclusion until +it receives a result or kills the timed-out helper group. Unknown may reflect +an effect already applied by an independent game/Docker daemon. + +Run `tests/smoke_tests/run_sector_alpha_agent.sh`, the agent/public-query unit +sets, and `ALPHAGSM_VALIDATE_AGENT=1` with Minecraft integration for each runtime. +Windows credential storage deliberately fails closed pending an ACL adapter. +Binary package collection, lint and coverage include agent. The service lives +in the separate alpha-platform-lite repository; it supplies PostgreSQL roles, +TLS transport round trips and worker isolation validation. + +Credential generation is the management dispatch fence. Reporting authentication +uses its independent active key, so management key rotation cannot interrupt +reports. Local key replacement requires the previously enrolled capability and +never changes bindings/opt-ins; management generations must strictly advance. ## Game-specific BYO file contracts diff --git a/Makefile b/Makefile index c87411f4..d1c2c36a 100644 --- a/Makefile +++ b/Makefile @@ -189,6 +189,7 @@ local-test: coverage: PYTHONPATH=.:src $(PYTHON_BIN) -m pytest tests/unit_tests \ + --cov=agent \ --cov=core \ --cov=downloader \ --cov=downloadermodules \ diff --git a/README.md b/README.md index 1d976511..f8168ec7 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,13 @@ The everyday flow is always the same: Then `stop`, `backup`, or `update` as needed. Pick a game from [the server guides](docs/README.md) if you want the exact module name. +After installation, run `./alphagsm` in a terminal for an optional setup tour. +Press Enter to skip, use `./alphagsm --welcome` to replay, or +`./alphagsm --skip-welcome` to skip from a script. The tour can help create a +local server and show everyday commands. Downloads need a separate confirmation. +Sector Alpha enrollment is optional and reporting/management stay off unless +you enable them separately. See [First-run setup](docs/first-run-setup.md). + For supported Steam-owned files, log in with `./alphagsm steam-auth login owned `, then run `./alphagsm myserver setup --auth-profile owned`. Setup remembers that profile name. `--steam-dir /path/to/SteamLibrary/steamapps` uses installed files @@ -553,3 +560,63 @@ Use: - License: GPL v3.0, see [LICENSE](LICENSE) - Credits: see [CREDITS](CREDITS) - Changelog: [changelog.txt](changelog.txt) + +## Optional Sector Alpha connection + +AlphaGSM can connect to a compatible Sector Alpha service over outbound HTTPS. +Reporting and remote management are separate opt-ins and stay disabled until +you enable them locally. You choose each published server and which of status, +start, stop and restart it permits. Local AlphaGSM works without a connection. + +```sh +alphagsm agent enroll +alphagsm agent bind '*' +``` + +The agent uses `https://alphagsm-api.sector-alpha.net` for every operation. +Another provider is used only with `--origin`. `alphagsm agent enroll` turns +reporting on and leaves management off. `alphagsm agent run` runs the agent +in the foreground. On Linux, `alphagsm agent install-service` enables and +starts a user service for that command so it keeps running after you close +the terminal. `install-service --system` enables and starts the same command +as a system service for your account, not as root. Neither changes reporting +or management. `alphagsm agent uninstall-service` disables and removes the +selected service and leaves the local enrollment in place. Custom providers +keep their configured origin. + +The quoted `'*'` binds all +eligible local servers, inferring their settings and registering website IDs +automatically. Use `agent bind my-minecraft` for one server. Review the suggested +public address, player port, query port, publication and remote actions before saving. +The player port comes from the local game's main port; `--port` overrides the +query port and `--connect-port` overrides the player port for NAT. For Minecraft, +`--port` sets both unless `--connect-port` is also supplied. Existing bindings +keep their saved metadata until you rebind and approve a player port. Binding +does not enable reporting; approved endpoints are sent only in reports. +Update the receiving service before creating or rebinding servers: older +receivers reject the new player-port field. Existing saved bindings remain +compatible until you approve the field through rebinding. + +Use `--host game.mygameserver.com` for an existing hostname. Public reports check +that all its DNS addresses match this machine's expected public IPs; `--public-ip` +provides an explicit override. `agent check-endpoint my-minecraft` tests the +advertised query port separately. + +Multiple games can share one machine and public IP with separate subdomains: + +```sh +alphagsm agent bind my-minecraft --host minecraft.mygameserver.com +alphagsm agent bind my-tf2 --host tf2.mygameserver.com +``` + +Each keeps its own player/query ports and permissions. Bulk binding also accepts +a different hostname at each server's endpoint prompt. +Follow the [step-by-step subdomain setup](docs/sector-alpha-agent.md#multiple-game-subdomains-on-one-machine) +for DNS records, NAT overrides, port checks and publication. + +See [the connection guide](docs/sector-alpha-agent.md). The open source project’s +home is https://alphagsm.sector-alpha.net/; the gaming community and server +directory belong on https://sector-alpha.net/. + +Use the owner dashboard and `alphagsm agent replace-credential reporting` or +`management` for independent key rotation; enter secrets only at hidden prompts. diff --git a/alphagsm b/alphagsm index dfe70e8e..71eb928e 100755 --- a/alphagsm +++ b/alphagsm @@ -16,6 +16,10 @@ if sys.argv[1:2] == ["--_download"]: from downloader import run_download_helper sys.exit(run_download_helper(sys.argv[2:])) +if sys.argv[1:2] == ["--_agent-runtime"]: + from agent.runtime_helper import main + sys.exit(main(sys.argv[2:])) + import core as core # pylint: disable=wrong-import-position if len(sys.argv) >= 5 and sys.argv[1] == "--_run-command": diff --git a/changelog.txt b/changelog.txt index 3d809adc..736bba55 100644 --- a/changelog.txt +++ b/changelog.txt @@ -7,6 +7,25 @@ server support, CI, packaging, or workflow defaults. Historical detail lives in git history. Keep this file focused on release-facing notes and high-level milestones. +2026-11-05 +- ET: Legacy: setup finds the Linux x86_64 tar.gz on the current etlegacy.com + download page. Release numbers are read from the version label. +- Sector Alpha: `alphagsm agent run` runs the agent in the foreground. + `alphagsm agent install-service` enables and starts a user service for that + command. `install-service --system` enables and starts a system service for + one operator account, not as root. Ordinary Windows commands do not import + the Linux account modules. + +2026-11-04 +- Sector Alpha: `alphagsm agent install-service` installs a Linux systemd + user service that runs `alphagsm agent run`. `install-service --system` + installs that same command as a system service for one operator account. + There is no separate alphagsm-agent program. Neither command changes + reporting or management. `uninstall-service` removes the selected service + and leaves the local enrollment in place. Enrollment, binding, the first-run + tour and game setup do not install it. +- Sector Alpha: outbound HTTPS and the agent WebSocket require TLS 1.2 or newer. + 2026-10-23 - CI: runtime and integration image builds reuse GitHub Actions layer cache. Each family has its own cache scope. This does not cover the @@ -34,6 +53,58 @@ Historical detail lives in git history. Keep this file focused on release-facing Source servers can install pinned SteamWorks 1.2.3c, which also installs SourceMod. Replaced game files are restored by `mod cleanup`. +2026-10-20 +- Sector Alpha: the agent uses https://alphagsm-api.sector-alpha.net for + every operation, including enrollment. It does not ask which host to use. + Another provider is used only when `--origin` or the saved origin names one. + `alphagsm agent enroll` redeems the code there, turns reporting on, and + registers each managed server that already has a global public address. + Management stays off. A server + without that address is left unbound. After the first report, TeamSpeak 3 + receives an ownership check in the virtual server name. Sector Alpha + verifies it by querying the server, and the previous name is restored. + Other games print the SA- challenge for the public name. `alphagsm agent + setup` and the first-run tour still ask before saving a binding and still + leave reporting off. + +2026-10-19 +- Sector Alpha: the agent keeps one outbound WebSocket to alphagsm-api for + presence and job wakes, with heartbeats about every 30 seconds. If the socket + cannot open, it uses the existing HTTPS job poll. Authorize and result posts + stay on HTTPS. An account can have ten live agents. Remote management stays + off until it is enabled. +- TeamSpeak 3: client and player counts exclude ServerQuery connections, + including the connection opened by the status check. An empty server reports 0. +- Sector Alpha: the website and `alphagsm agent query TAG` read any game that + answers an anonymous status protocol (A2S, Minecraft server-list ping, Quake + status, Bedrock ping, TeamSpeak ServerQuery). The local command does not + upload the result. Agent publication remains Minecraft Java, Minecraft Paper, + and Team Fortress 2. Other games are listed from the website after the name + check. TCP, UDP, and RCON checks are not status queries. +- Sector Alpha: `alphagsm agent prove TAG` places a website SA- challenge in the + live TeamSpeak 3 server name, asks alphagsm-api to query it, and restores the + previous name. The token is prompted, not taken as a shell argument. An + in-progress player-count query is waited out with the same challenge. + This does not list the server. +- Sector Alpha: TeamSpeak directory listing is a website choice after the name + check. alphagsm-api queries the TeamSpeak server and does not treat the local + publication switch, or a running agent, as proof the server is online. + +2026-10-18 +- TeamSpeak 3: add MariaDB mode for Docker. An empty db_host starts a private + MariaDB 11.5 container and keeps the password in ts3db_mariadb.ini. The + container finds TeamSpeak's bundled MariaDB library through LD_LIBRARY_PATH. + SQLite remains the default. The service-console image installs libmariadb3. +- Sector Alpha: use https://alphagsm-api.sector-alpha.net for stats and outbound + remote execution. Enrollment and binding stay on https://sector-alpha.net. + The unreleased gameserver-api.sector-alpha.net name is not an official alias. +- Sector Alpha: send enrollment and binding registration to sector-alpha.net; + reserve gameserver-api.sector-alpha.net for stats and outbound remote execution. + Select the two official hosts locally without redirects, re-enrollment, credential + changes or additional consent. Explicit custom providers keep their exact origin. +- Deployment: enable website onboarding routes before upgrading clients, then + restrict the API gateway to stats and execution routes. Restart existing agents. + 2026-10-17 - SteamCMD: detect the default Steam directory from the host operating system. Linux keeps `~/.local/share/Steam` and the `~/.steam` links, @@ -76,6 +147,13 @@ Historical detail lives in git history. Keep this file focused on release-facing 47.02s. Docker was not rerun. 2026-10-16 +- First run: offer a skippable setup tour on bare interactive launches, with + --welcome to replay and --skip-welcome for noninteractive opt-out. +- Guided setup: preview local creation, preserve existing server records, and + confirm downloads separately. Sector Alpha enrollment/binding remains optional; + the tour never starts games or enables reporting/remote management. +- Tests: add isolated real-terminal CLI integration/smoke coverage for first-run + skip/replay, local creation and selector/reserved-name overwrite prevention. - Nuclear Dawn lifecycle: an entitled profile installs Steam app 111710, including nucleardawn/maps/hydro.bsp. Anonymous install does not. NTFS omitted the execute bit on srcds_linux, and prestart now restores it. @@ -207,6 +285,41 @@ Historical detail lives in git history. Keep this file focused on release-facing Smoke and integration still require the selected map and zero players/bots. 2026-10-15 +- Sector Alpha: never infer a player port from Trackmania XML-RPC, Enshrouded + query-only settings or Arma headless client roles. Use --connect-port only + after confirming an actual public player listener. Existing approvals remain. +- Sector Alpha: approve player connection ports separately from query ports in + binding previews. Infer the primary game port and add --connect-port for NAT; + Minecraft --port follows both ports unless --connect-port overrides it. +- Agent metadata: accept optional strict connect_port for every game, preserving + old reports and consent. Existing bindings gain it only after rebinding; endpoint + metadata reaches the API through reports after reporting is enabled. Update the + receiver before new bindings/rebinding, since old closed schemas reject the field. +- Sector Alpha: use https://gameserver-api.sector-alpha.net as the single origin + for enrollment, binding registration, reports and management. Public accounts + and the directory remain on https://sector-alpha.net. +- Agent upgrades: normalize the old official website origin locally without + re-enrollment or changing IDs, credentials or consent. Restart the agent after + upgrading; explicitly configured custom providers keep their origins. +- Documentation: add a step-by-step same-machine subdomain setup with DNS records, + individual and bulk binding, public-IP overrides, endpoint checks and reporting. +- Sector Alpha: document and verify separate per-game subdomains on one machine, + including wildcard endpoint prompts, preserved bindings and independent DNS + publication checks for games sharing a public IP. +- Sector Alpha: validate existing server hostnames against this machine's public + IPs before public report renewals. Withhold mismatched/unavailable names without + changing local consent. Add repeatable --public-ip overrides and an explicit + check-endpoint command for bounded native game-protocol diagnostics. +- DNS validation: use pinned dnspython with complete A/AAAA checks, hard lifetimes + and bounded concurrent work; include its dynamic protocol modules in binaries. +- Sector Alpha: default enrollment to https://gameserver-api.sector-alpha.net, register bindings + automatically, and support guided single-server binding and `agent bind '*'` + for all eligible local servers. Infer each server's game, port, name and capacity; + suggest its public IP with explicit host/port overrides and operator confirmation. +- Existing games without a public query profile can use private bindings. Bulk + binding preserves existing permissions and never enables capabilities itself. +- Sector Alpha: retry reporting after temporary configuration-lock contention + during long lifecycle operations, keeping the reporting thread alive. - CoDWaW: expose explicit LAN/Internet dedicated modes while preserving legacy launch arguments. Disposable CI uses LAN mode with native map/status and shutdown checks, avoiding external DemonWare DNS/authentication dependencies. @@ -315,6 +428,21 @@ Historical detail lives in git history. Keep this file focused on release-facing scheduled coverage and normal PR routing; keep regression validation on CI. 2026-10-13 +- Sector Alpha: add an optional outbound HTTPS agent with separate reporting and + management opt-ins, exact local server bindings, private scoped credentials, + and durable results that prevent replay of lifecycle effects after a crash. +- Remote actions are limited to status/start/stop/restart. Normal stop can fall + back to forceful termination; restart requires a running server. Both + capabilities remain disabled until explicitly enabled by the operator. +- Public query interface: reviewed A2S_INFO and Minecraft Java SLP profiles with + pinned numerical destinations, total deadlines, bounded fragments/decompression + and JSON framing, and scalar observations without player names or credentials. +- Credential rotation: independently replace reporting or management keys through + hidden local prompts, retaining existing bindings and capability opt-ins. +- Lifecycle: POSIX port checks ignore closed TCP connections in TIME_WAIT while + continuing to reject active listeners; Windows keeps exclusive port probes. +- Packaging/lint/coverage include the new agent package. Agent credential storage + currently supports POSIX; Windows enrollment fails closed pending an ACL adapter. - tModLoader: add public-release dedicated-server installation, contained world configuration and native Workshop package activation, with Census command execution verified through process and Docker lifecycle tests. @@ -1323,17 +1451,31 @@ Historical detail lives in git history. Keep this file focused on release-facing - Packaging: exclude `gamemodules.factorio` from PyInstaller collection so standalone binary builds succeed on all three platforms; the module still raises `NotImplementedError` on import as required by its unit test, but is never needed by the binary since factorio support is unimplemented. 2026-04-28 -- Server support: add the TF2 curated mod-support foundation with package-backed module wiring, curated registry metadata for SourceMod and MetaMod, and the first shared desired-state groundwork for server-side mod management. -- Server support: make TF2 curated mod apply persist mod-state errors and expose broken apply state through `status` so failed reconciliations are operationally visible. -- Module loading: treat package-backed canonical modules such as `teamfortress2` as first-class catalog and parity entries so one canonical module id still maps to one import surface. -- Server support: sync readyornotserver queryport and maxplayers into ReadyOrNot/Config/ServerConfig.ini during setup, update, and set. -- Runtime: sanitize `TMPDIR`, `TMP`, and `TEMP` in the shared Wine/Proton wrapper so Windows-binary server launches do not inherit inaccessible temp paths, and remove the duplicated Medieval Engineers-only workaround in favor of the shared wrapper. -- Server support: make `readyornotserver` and `rs2server` explicitly prefer Proton on Linux so their process-runtime launches use the runtime the integration tests already require instead of falling back to plain Wine. -- Server support: make the current Windows-on-Linux Proton-required integration modules (`darkandlightserver`, `empyrionserver`, `heatserver`, `primalcarnageextinctionserver`, `remnantsserver`, `sonsoftheforestserver`, `subsistenceserver`, and `terratechworldsserver`) explicitly prefer Proton on Linux instead of silently falling back to plain Wine. -- Server support: make `astroneerserver`, `ducksideserver`, `groundbranchserver`, and `reignofkingsserver` explicitly prefer Proton on Linux so the process runtime matches the integration-test contract instead of silently falling back to plain Wine. -- Server support: make `askaserver`, `fearthenightserver`, `saleblazersserver`, `sniperelite4server`, and `sunkenlandserver` explicitly prefer Proton on Linux so the process runtime matches the integration-test contract instead of silently falling back to plain Wine. -- Server support: make the remaining Proton-required Windows-on-Linux integration modules explicitly prefer Proton on Linux so the process runtime matches the integration-test contract instead of silently falling back to plain Wine. -- CI: treat the repeated bare SteamCMD `state is 0x202` setup failures seen for `kf2server` and `rs2server` as the same known transient flake class as the existing missing-configuration `0x202` state, including the longer retry delay and integration skip helper. +- Server support: add schema-backed Rising Storm 2 config sync so `set servername` rewrites `ServerName` in `ROGame/Config/PCServer-ROGame.ini`. +- Server support: add schema-backed Quake 2 config sync so `set servername` and `set map` rewrite `/server.cfg`. +- Server support: add schema-backed RTCW config sync so `set servername`, `set fs_game`, and `set map` rewrite `/server.cfg`. +- Server support: add schema-backed Quake 3 config sync so `set servername`, `set fs_game`, and `set map` rewrite `/server.cfg`. +- Server support: add schema-backed `server.cfg` sync for `codserver`, `cod2server`, `cod4server`, `coduoserver`, and `codwawserver` so `set servername`, `set moddir`, and `set map` rewrite `/server.cfg`. +- Server support: add schema-backed Quake Live config sync so `set servername` and `set map` rewrite `baseq3/server.cfg`. +- Server support: add schema-backed Quake 4 config sync so `set servername`, `set fs_game`, and `set map` rewrite `/server.cfg`. +- Server support: add schema-backed `etl_server.cfg` sync for `etlegacyserver` so `set servername` and `set port` rewrite the native ET: Legacy config. +- Core: replace alias-wrapper module routing with a canonical module catalog and central alias metadata; alias wrapper files such as `tf2.py`, `cs2server.py`, and namespace `DEFAULT.py` shims are removed. +- Tools: add a generated canonical module parity report and a CI enforcement step that fails on stale parity artifacts. +- Server support: add schema-backed `serverDZ.cfg` sync for `dayzserver` so `set servername`, `set serverpassword`, `set adminpassword`, `set maxplayers`, and `set queryport` rewrite the native DayZ config. +- Server support: expand `sevendaystodie` schema-backed config sync so `set port`, `set servername`, `set maxplayers`, and `set serverpassword` rewrite `serverconfig.xml`. +- Server support: move `wreckfestserver` port syncing onto schema-backed `SettingSpec` metadata so `set port` rewrites `server_config.cfg` through the shared equals-config helper. +- Server support: move `stnserver` port syncing onto schema-backed `SettingSpec` metadata so `set port` rewrites `Config/ServerConfig.txt` through the shared config helper. +- Server support: make `twserver` sync schema-backed `set port` and `set servername` changes into `autoexec.cfg`, and harden space-delimited config rewrites so quoted multi-word values update in place. +- Module loading: replace game-module alias wrapper files and namespace `DEFAULT.py` shims with a shared canonical catalog in `src/server/module_aliases.json`, and persist canonical module ids in server data even when users create servers through aliases. +- Release prep: add generated `docs/module_parity_report.md` and `docs/module_parity_report.json` artifacts plus test/script enforcement so canonical module parity gaps and alias coverage stay visible in CI. +- Tests: add an opt-in backend integration test that exercises AlphaGSM CLI + commands (`create`, `set image`, `setup`, `start`, `status`, `info`, `query`, `stop`) against + the Docker runtime to validate CLI/runtime integration in CI environments. +- Dev: normalize `get_start_command(server)` across gamemodules so static + validators and container builders can reliably introspect start commands. +- Tools: add static validators and helper scripts to audit `get_start_command` + and `get_container_spec` implementations, plus docs summarising audit + results and remediation guidance. 2026-04-26 - CLI: add a built-in `doctor` command so each server can print its effective runtime decision plus Docker image, container, and local runtime health details. diff --git a/docker/service-console/Dockerfile b/docker/service-console/Dockerfile index 8dc5529d..3826a527 100644 --- a/docker/service-console/Dockerfile +++ b/docker/service-console/Dockerfile @@ -7,6 +7,7 @@ RUN apt-get update -qq \ bash \ ca-certificates \ libcurl4 \ + libmariadb3 \ python3 \ && rm -rf /var/lib/apt/lists/* diff --git a/docs/README.md b/docs/README.md index 9e984389..d62d13eb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,6 +22,7 @@ with `update`. | I want to… | Read | | --- | --- | | Get AlphaGSM installed | [Getting Started](../README.md) | +| Take or skip the guided setup tour | [First-run setup](first-run-setup.md) | | Run or maintain AlphaGSM with an AI agent | [Agent Operations](agent-operations.md) | | See every command (`restore`, `kill`, `activate`, `send`, …) | [Everyday Commands](commands.md) | | Run AlphaGSM itself in Docker | [Run AlphaGSM In Docker](docker-manager.md) | diff --git a/docs/commands.md b/docs/commands.md index 08aa7680..959a9093 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -7,6 +7,21 @@ boot, and discovery. Every server also has `alphagsm --help` for the exact flags of that game. +## Optional setup tour + +Run `alphagsm` with no command in a terminal for the first-run offer. +`alphagsm --welcome` replays the tour; `alphagsm --skip-welcome` remembers a skip +and works without a terminal. Press Enter to skip. The tour offers local server +setup, common commands and optional Sector Alpha enrollment/binding, with +separate confirmations before creation, downloads or remote enrollment. +It never starts a game or enables reporting/management. Ordinary commands and +scripts run directly. `alphagsm agent run` runs the Sector Alpha agent in +the foreground. On Linux, `alphagsm agent install-service` enables and starts +a user service for that command. `install-service --system` enables and starts +it as a system service for your account. Neither changes reporting or +management. +See [First-run setup](first-run-setup.md). + ## Lifecycle | Command | What it does | diff --git a/docs/first-run-setup.md b/docs/first-run-setup.md new file mode 100644 index 00000000..01d6a666 --- /dev/null +++ b/docs/first-run-setup.md @@ -0,0 +1,70 @@ +# Optional first-run setup + +After installing AlphaGSM, run it in a terminal without a command: + +```sh +./alphagsm +``` + +On the first such launch, AlphaGSM offers a guided tour. Press Enter to skip it, +or type `y` to begin. Skipping or finishing the tour is remembered for your +configured AlphaGSM operator home. Existing installations also see this offer on +their next bare interactive launch after upgrading. + +Replay or skip explicitly at any time: + +```sh +./alphagsm --welcome +./alphagsm --skip-welcome +``` + +The skip command also works in installation scripts. Normal server commands, +agent commands, help and version output run directly. Redirected input or output +does not trigger the first-run offer; an explicit `--welcome` requires terminal +input and output. Bare noninteractive launches retain the normal help/usage exit +status of 2. + +## What the tour offers + +1. **Create/configure a game server.** Choose a game module (`?` lists the available + modules), then a new local name such as `my-minecraft`. Review the exact command + and confirm creation. This only creates its local record. A separate question + offers to run the usual `setup`, which can download files or install a runtime + and asks for the game's settings. Decline to do that later. The tour never + starts the game. Existing names, command names, wildcards and all-number names + cannot be used to create a server through the tour. +2. **Everyday commands.** See how to set up, start, check and stop a server without + running any commands. See [Everyday Commands](commands.md) for more details. +3. **Optional Sector Alpha connection.** Confirm before opening the existing + enrollment/binding wizard. Obtain the enrollment code from your account at + `https://sector-alpha.net`. The tour's wizard previews the servers and asks + before it saves a binding, and it leaves reporting off. The agent uses + `https://alphagsm-api.sector-alpha.net` for every operation and does not ask + which host to use. `alphagsm agent enroll` redeems the code there, registers + bindings, and turns reporting on. Another provider is used only with `--origin`. + See [Optional Sector Alpha connection](sector-alpha-agent.md). + +Press Enter or `s` at the menu to finish. Blank game/name answers cancel that +step. Ctrl+C or end-of-input cancels without running further commands; anything +you already approved and completed remains in place. Cancelling before finishing +leaves a first-run offer pending unless you already skipped or completed it. +A failed create/setup stops the tour and reports the failure so you can retry +the command shown. + +## Reporting and permissions + +The tour never enables remote reporting or management, and it does not install +a background service. `alphagsm agent run` runs the agent in the foreground. +On Linux, `alphagsm agent install-service` enables and starts a user service +for that command, and `install-service --system` enables and starts a system +service for your account. Neither changes reporting or management. New +installations keep both off. Public listings require +separate per-server approval, and reporting +still needs an explicit `agent enable-reporting` command. Existing remote +permissions remain in force when you replay the tour. Choosing local setup, +viewing commands or skipping does not contact Sector Alpha. + +The remembered tour choice is a small local `welcome-v1.done` file under your +configured AlphaGSM home (normally `~/.alphagsm`). It contains only `skipped` or +`completed`; it holds no account information, credentials or permission choices. +Use `--welcome` to replay without deleting it. diff --git a/docs/sector-alpha-agent.md b/docs/sector-alpha-agent.md new file mode 100644 index 00000000..3c180266 --- /dev/null +++ b/docs/sector-alpha-agent.md @@ -0,0 +1,321 @@ +# Optional Sector Alpha connection + +AlphaGSM works locally without this connection. The optional agent initiates +outbound HTTPS; it does not open a management listener. Reporting and remote +management are independent opt-ins, and publication is chosen for each server. +Public player-stat queries support Minecraft Java vanilla/Paper and Team Fortress 2. +Other managed games can use private reporting and permitted remote actions. + +For a general walkthrough first, run `alphagsm --welcome` in a terminal and +choose the optional Sector Alpha connection step. It asks before opening +`agent setup` and never enables reporting or management. You can skip the tour +and run the commands below directly. See [First-run setup](first-run-setup.md). + +On the Sector Alpha website at `https://sector-alpha.net`, or a compatible +provider website, sign into the verified operator account +with MFA, choose capabilities and create a ten-minute enrollment code. Locally: + +```sh +alphagsm agent enroll +alphagsm agent run +``` + +Enter the enrollment code at its hidden prompt. The agent does not ask which +host to use. Every operation uses `https://alphagsm-api.sector-alpha.net`: +enrollment, binding registration, ownership challenges, POSTed stats, and the +outbound remote-execution channel. `alphagsm agent enroll` registers each +managed server that already has a global public address, turns reporting on, +and leaves management off. A server without that address is skipped. Bind it +later with `alphagsm agent bind TAG --host HOST`. After the first report, +TeamSpeak 3 receives an ownership check in the virtual server name. Sector +Alpha verifies that check by querying the server, and the previous name is +restored. Other games print the SA- challenge to place in the public name. +Owners request actions on the website. The agent opens one outbound WebSocket +to the API for presence and job requests, and posts results back to the API. +If that socket cannot open, the agent uses the HTTPS job poll instead. No +inbound management listener is opened on your machine. The website also serves +public accounts and the directory. + +Use `agent enroll --origin https://your-site.example` only when you want +another compatible service. That saved origin is then used for every +operation. Never put codes or credentials in shell arguments. Official +website-host aliases normalize locally to the API, without redirects or a +fallback to another host. + +After upgrading AlphaGSM, restart the foreground agent (or its user service). +Existing official connections keep their stored API provider origin, IDs, +credentials, bindings and local permissions. Old official website-origin aliases +still normalize locally to the API. No re-enrollment is needed. The website +still accepts enrollment and binding registration from older clients. +See the provider's deployment instructions. + +`agent bind '*'` discovers all eligible servers owned by your local AlphaGSM user, +registers their bindings at the stored API origin, and saves the returned IDs +automatically. Quote +`'*'` so your shell passes it literally instead of expanding local filenames. +For one server use `alphagsm agent bind my-minecraft`. `alphagsm agent setup` +combines bulk binding with enrollment if needed. Existing bindings are reused. + +The guided preview reads each server's configured game, player connection port, +query port, name and capacity. The player port comes from the primary local +`port`; the query port uses `queryport` when valid, otherwise the primary port. +It suggests a public numerical IP from configured networking details, +the outbound interface, or a bounded HTTPS address lookup. Confirm the advertised +host/IP and both ports: NAT, VPNs and port forwarding can make the suggested endpoint wrong. +Use `--host game.example.net` to override the host and avoid address discovery during binding; +use `--port` for a different public query port and `--connect-port` for a different +public player port. For Minecraft, `--port 25566` sets both ports unless you also +provide `--connect-port`. For example, a TF2 server with separate NAT mappings: + +```sh +alphagsm agent bind my-tf2 --host tf2.mygameserver.com --port 30025 --connect-port 30015 +``` + +The host and query port identify its statistics endpoint; players connect to +`tf2.mygameserver.com:30015`. Every game can carry a player port, including private +bindings without native public statistics. An unavailable primary port is shown +as unknown; use `--connect-port` if you know the public player port. +Trackmania's primary `port` is XML-RPC, Enshrouded's configured port is query-only, +and Arma headless roles do not accept player joins. These never infer a player +port. For Trackmania, approve the actual game listener explicitly, for example +`agent bind my-trackmania --host track.example.com --connect-port 2350` after +checking the dedicated server configuration and NAT mapping. Do not use XML-RPC +port 5000 as the player port. Existing approvals are preserved; rebind with an +explicit corrected player port if an earlier preview approved the admin port. +Bulk binding keeps individual server ports. Unreadable or unconfigured servers +are reported with a reason and can be retried after fixing their local settings. +Registration sends only a scoped binding reference. Approved host and ports go +to the API through reports after you enable reporting. Existing saved bindings +keep their old metadata on upgrade; bind them again and approve the preview to +add the player port. Rebinding does not enable reporting or management. +Deploy the updated Sector Alpha receiver (or confirm your custom provider accepts +optional `connect_port`) before creating or rebinding with this client. Older +receivers use a closed schema and reject reports containing the new field. +Existing saved bindings remain compatible until a confirmed rebind adds it. + +For an existing domain/subdomain pointing to this machine: + +```sh +alphagsm agent bind my-minecraft --host game.mygameserver.com +alphagsm agent check-endpoint my-minecraft +``` + +Before every public report, the agent checks fresh A and AAAA records. Every +advertised address must be globally routable and match this machine's configured +or detected public IP. For NAT or multiple public addresses, supply `--public-ip +YOUR_PUBLIC_IP` when binding; repeat the option for each expected address. These +expectations stay in local configuration. DNS checks still run when `--host` is +explicit. An unavailable lookup or mismatch withholds only that server's outgoing +publication; reporting continues for other bindings. Stored consent is retained, +so a later matching DNS check can restore publication. Private bindings remain +usable while DNS is being corrected. No DNS records are created or modified. + +`check-endpoint` additionally tests the advertised query port through the supported +anonymous status protocol, using only matched numerical addresses. It does not change +publication or permissions. A failure may mean a stopped game, wrong forwarded +port, firewall, or missing NAT loopback support; an external test is still needed +when the router cannot reach its own public address. The website's separate +ownership challenge and isolated query checks remain required. + +`alphagsm agent query TAG` runs that same anonymous query against the local +server and prints the name, player count, and capacity. It does not send the +result to Sector Alpha and it does not list the server. TeamSpeak uses anonymous +ServerQuery. Games with only a TCP or UDP reachability check are refused. The +website still makes its own query before a directory row is marked active. + +## Multiple game subdomains on one machine + +This example connects existing AlphaGSM servers tagged `my-minecraft` and +`my-tf2` to separate subdomains on the same public IP. Run the commands as the +AlphaGSM user who owns those servers. If this machine is not enrolled yet, follow +the enrollment instructions above and run `alphagsm agent enroll` first. + +1. **Point both subdomains at the game host.** In your DNS provider, create these + records, replacing `YOUR_PUBLIC_IPV4` with the machine's actual public IPv4: + + | Type | Name | Value | + | --- | --- | --- | + | A | `minecraft.mygameserver.com` | `YOUR_PUBLIC_IPV4` | + | A | `tf2.mygameserver.com` | `YOUR_PUBLIC_IPV4` | + + Use your own domain in place of `mygameserver.com`. Add AAAA records only when + the machine has a reachable public IPv6 address supported by that game's query + profile. The TF2/A2S public profile currently requires IPv4-only DNS answers. + Every A/AAAA answer must match the host's expected public addresses. Wait until + the new records resolve correctly before checking the endpoints. + +2. **Bind each local game to its subdomain.** Each command infers that server's + configured player and query ports, name and capacity. Replace the example tags with + your existing local server tags: + + ```sh + alphagsm agent bind my-minecraft --host minecraft.mygameserver.com --publish + alphagsm agent bind my-tf2 --host tf2.mygameserver.com --publish + ``` + + Review the host/IP, player port, query port, publication and allowed remote + actions in each preview before saving. Use `--port` for a forwarded query port + and `--connect-port` for a forwarded player port. Minecraft's `--port 25566` + also sets its player port unless `--connect-port` is supplied. + For NAT or multiple public addresses, add `--public-ip YOUR_PUBLIC_IPV4` to each + binding; repeat `--public-ip` for additional expected machine addresses. + +3. **Check both advertised endpoints.** Start the games through your normal + AlphaGSM workflow and allow/forward their configured ports through the firewall + and router. Then run: + + ```sh + alphagsm agent check-endpoint my-minecraft + alphagsm agent check-endpoint my-tf2 + ``` + + Correct DNS or port errors before continuing. A router without NAT loopback + may require a separate external connectivity test. The website still requires + its own ownership challenge and isolated query verification for each endpoint. + +4. **Enable and keep reporting running.** Once the previews and checks are correct: + + ```sh + alphagsm agent enable-reporting + alphagsm agent report-once + alphagsm agent run + ``` + + `alphagsm agent run` starts the agent in that terminal. It keeps running + until you stop it or close the terminal. On Linux, `alphagsm agent install-service` + writes a user unit for that same command, enables it, and starts it, so it + continues after the terminal closes and comes back at login. + `alphagsm agent install-service --system` does the same as a system service + for your account and enables it for boot. It does not run as root. Neither + command changes reporting or management. A second `agent run` stops while + that service is already running. Remove the selected unit with + `alphagsm agent uninstall-service`. + Complete each endpoint's ownership challenge in the website to + obtain verified public statistics. Reporting does not enable remote management. + + For a TeamSpeak 3 binding, create the challenge on the website and then run: + + ```sh + alphagsm agent prove my-teamspeak + ``` + + Paste the `SA-` token at the hidden prompt. AlphaGSM puts it in the live + server name, asks the API to query that name, and restores the previous name + whether or not the check passes. If a player-count query is already in + flight, the command waits and retries with the same challenge. This does not + list the server. After the check, the website's List in directory control + publishes it. alphagsm-api keeps that listing by querying the TeamSpeak + server on the same address, including while this agent is not running. A + query that cannot reach the server marks the listing offline. The local + publication switch is not used for TeamSpeak. The server name visitors see + changes until the check finishes, which is usually under a minute. + +You can also configure all local games in one guided pass: + +```sh +alphagsm agent bind '*' +``` + +Enter a different `hostname:port` at each server's endpoint prompt, such as +`minecraft.mygameserver.com:25565` and `tf2.mygameserver.com:27025`, replacing the +ports with your public query ports. Player ports are inferred independently; +rebind one server with `--connect-port` when its NAT mapping differs. Choose publication and actions for +each game and review every mapping in the final preview. Repeating the command +preserves existing hostnames, ports and approvals. +When editing Minecraft's query endpoint at the prompt, a shared player port +follows it. A previously approved distinct player port stays unchanged; use +`--connect-port` to change that separate mapping explicitly. + +Every hostname is checked against the same machine independently. A DNS mismatch, +endpoint edit or publication opt-out for one binding does not change the other +games' approvals. Complete the website's ownership challenge for each endpoint. +DNS names do not select a local process or translate ports: each game needs its +configured port reachable through the firewall/NAT. Sharing the same IP and port +requires a game-compatible proxy; configuring one is outside agent binding. + +## Reporting and management permissions + +New bindings default to private with no allowed remote actions. The prompts let +you choose publication and actions; repeat runs preserve your previous choices. +`alphagsm agent enroll` turns reporting on and leaves management off. +`alphagsm agent setup` and `alphagsm agent bind` still leave both off until +you enable the capability you want: + +```sh +alphagsm agent enable-reporting +alphagsm agent report-once +alphagsm agent enable-management +alphagsm agent run +``` + +The full explicit form remains available for an existing website binding: + +```sh +alphagsm agent bind BINDING_ID my-minecraft --epoch 1 \ + --game minecraft.vanilla --host game.example.net --port 25565 --connect-port 25565 \ + --name 'Our Minecraft world' --capacity 20 --publish \ + --actions status start stop restart +``` + +Use only the actions you want to grant; an empty action list grants no management +operations. `--publish` is optional and independent of actions. A private server +can be managed without a directory listing. `alphagsm agent run` is enough +to run the agent in the foreground. `alphagsm agent install-service` enables +and starts a user service for that command. `install-service --system` enables +and starts a system service that runs the same `alphagsm agent run` command as +one operator, not as root. There is no separate alphagsm-agent program. +`uninstall-service` disables and removes the selected unit. Enrollment, +binding, the first-run tour and game setup do not install it, and the unit +does not change reporting or management. Reporting reconciles approved +local name/capacity after successful local changes and every roughly 60 seconds. +Advertised host, player port and query port remain explicit local choices; local +port changes never replace them during background metadata refresh. The compatible service +requires a separate public-name challenge before granting endpoint verification. +Reporting retries temporary configuration-lock timeouts during long lifecycle +operations and resumes when the lock is released; a busy lifecycle does not +permanently terminate reporting. Publication still expires if renewal is delayed +beyond the service lease. + +Normal AlphaGSM stop may force termination after graceful attempts. Restart is +stop then start and requires a running server. Status reports only running, +stopped or unknown. No remote set, console, logs, files, RCON, installs, updates, +backups, deletion, runtime/module changes, selectors or arguments are accepted. +If an operation times out or execution cannot be confirmed, check the server +locally before issuing another operation; the same job is never replayed. + +```sh +alphagsm agent publication BINDING_ID off +alphagsm agent disable-management +alphagsm agent disable-reporting +alphagsm agent unbind BINDING_ID +``` + +Disabling reporting attempts one withdrawal report. If offline, publication +expires within ten minutes of the last accepted renewal. Disabling management +rejects queued jobs locally. Revoke the installation in the owner website to +invalidate all scoped keys and queued jobs centrally. Once both capabilities +are disabled and the old installation is revoked, `alphagsm agent forget` removes +its local credentials; the durable processed-job journal remains for recovery. + +Local configuration and the journal are outside game datastores, under the +operator's AlphaGSM `agent` directory, with 0700 directory and 0600 files. Never +commit them or copy them into a game directory. Only one provider relationship +is supported. Windows enrollment fails closed until user-only credential ACLs +are implemented; this does not affect ordinary local AlphaGSM commands. + +Validated gates: the offline `run_sector_alpha_agent.sh` runner covers consent, +contracts, private storage, hostile parsers, crash reconciliation and duplicates. +The Minecraft integration test additionally runs the real bounded lifecycle +helper when `ALPHAGSM_VALIDATE_AGENT=1`, using its selected process/Docker runtime. +The service repository supplies the verified HTTPS and PostgreSQL boundary tests. +Production deployment still needs real TLS, secrets and the isolated query worker +firewall described by the compatible service. + +Rotate one key in the website owner dashboard, then run either +`alphagsm agent replace-credential reporting` or +`alphagsm agent replace-credential management`. Enter the key ID and secret at +the interactive prompts; management also asks for the new generation shown by +the website. Reporting rotation leaves management/jobs unchanged. Management +rotation cancels pending jobs under the old generation and leaves reporting +credentials valid. Existing local opt-ins and selected server permissions stay +unchanged; replacement cannot add a capability that was not enrolled. diff --git a/docs/servers/ts3server.md b/docs/servers/ts3server.md index be635606..239bc608 100644 --- a/docs/servers/ts3server.md +++ b/docs/servers/ts3server.md @@ -85,6 +85,18 @@ create is still the live source of truth. - **Location**: `/ts3server` - **Engine**: Custom +The default database is the SQLite file `ts3server.sqlitedb` in the install directory. Set `db_mode` to `mariadb` to use MariaDB instead. With `db_host` left empty, a Docker runtime server starts a private `mariadb:11.5` container on a network that is not published to the host. TeamSpeak reaches it as `database`. An existing MariaDB data directory can be placed at `/.alphagsm/mariadb-data` before the first start; AlphaGSM initializes a new database only when that directory has no `mysql` folder. Set `db_host` to a hostname when MariaDB is already running somewhere else. `db_password` is written to `ts3db_mariadb.ini` and is not placed on the server command line. + +```bash +alphagsm myts3 set db_mode mariadb +alphagsm myts3 set db_name ts3 +alphagsm myts3 set db_user ts3 +alphagsm myts3 set db_password "the database password" +alphagsm myts3 set runtime docker +``` + +TeamSpeak loads `libmariadb.so.2` from the extracted `redist` directory. MariaDB mode adds that directory to `LD_LIBRARY_PATH` inside the container. The service-console image also installs `libmariadb3`. A previously published runtime image does not gain that package until it is rebuilt. + Smoke and integration validation require an authenticated ServerQuery response: `alphagsm info --json` must report protocol `ts3`. diff --git a/lint.sh b/lint.sh index a72cdff2..a73d300d 100755 --- a/lint.sh +++ b/lint.sh @@ -5,6 +5,7 @@ set -euo pipefail PYTHON_BIN="${PYTHON_BIN:-python3}" DIRS=( src/core + src/agent src/downloader src/downloadermodules src/gamemodules diff --git a/pyproject.toml b/pyproject.toml index 126026a7..85941c78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,7 +77,7 @@ disable = [ [tool.coverage.run] branch = true -source = ["core", "downloader", "downloadermodules", "gamemodules", "screen", "server", "utils"] +source = ["agent", "core", "downloader", "downloadermodules", "gamemodules", "screen", "server", "utils"] omit = ["*/__init__.py", "tests/*"] [tool.coverage.report] diff --git a/requirements-runtime.txt b/requirements-runtime.txt index abe361bb..385d9f86 100644 --- a/requirements-runtime.txt +++ b/requirements-runtime.txt @@ -1,2 +1,3 @@ python-crontab==3.3.0 html5lib==1.1 +dnspython==2.7.0 diff --git a/scripts/build_binary.py b/scripts/build_binary.py index e4253208..83ca78a4 100644 --- a/scripts/build_binary.py +++ b/scripts/build_binary.py @@ -13,7 +13,7 @@ DIST_DIR = REPO_ROOT / "dist-binary" BUILD_DIR = REPO_ROOT / "build" / "pyinstaller" BUILD_VERSION_FILE = REPO_ROOT / "src" / "core" / "_build_version.py" -PACKAGES = ("core", "downloader", "downloadermodules", "gamemodules", "screen", "server", "utils") +PACKAGES = ("agent", "core", "downloader", "downloadermodules", "gamemodules", "screen", "server", "utils") def collect_runtime_data(): @@ -53,7 +53,7 @@ def pyinstaller_arguments(): "--workpath", str(BUILD_DIR), "--specpath", str(BUILD_DIR), "--paths", str(REPO_ROOT / "src"), ] - for package in PACKAGES: + for package in (*PACKAGES, "dns"): args.extend(["--collect-submodules", package]) for source, destination in collect_runtime_data(): args.extend(["--add-data", f"{source}{os.pathsep}{destination}"]) diff --git a/scripts/ci_game_test_routing.py b/scripts/ci_game_test_routing.py index 0ea6b980..1736262e 100644 --- a/scripts/ci_game_test_routing.py +++ b/scripts/ci_game_test_routing.py @@ -274,6 +274,8 @@ ) SOURCE_FAMILY_BACKLOG = set() DOCKER_DEFAULT_RUNTIME_TESTS = { + # Runtime-independent CLI coverage belongs in the default lane, not the game backlog. + "tests/integration_tests/test_first_run_tour.py", "tests/integration_tests/test_archive_backed_installs.py", "tests/integration_tests/test_arksurvivalascended.py", "tests/integration_tests/test_astroneerserver.py", diff --git a/skills/server-info-gathering/SKILL.md b/skills/server-info-gathering/SKILL.md index b7bb89e6..6d123318 100644 --- a/skills/server-info-gathering/SKILL.md +++ b/skills/server-info-gathering/SKILL.md @@ -399,7 +399,7 @@ session to the TS3 ServerQuery port (default 10011), sends `serverinfo` and | Key | Type | Meaning | |---|---|---| | `name` | str | Virtual server name | -| `clients_online` | int | Connected client count | +| `clients_online` | int | Voice clients, excluding ServerQuery connections | | `max_clients` | int | Maximum client capacity | | `uptime` | int | Server uptime in seconds | | `platform` | str | Host OS platform string | diff --git a/src/agent/__init__.py b/src/agent/__init__.py new file mode 100644 index 00000000..5599ff06 --- /dev/null +++ b/src/agent/__init__.py @@ -0,0 +1 @@ +"""Optional outbound reporting and lifecycle agent; imports never start a service.""" diff --git a/src/agent/binding_ports.py b/src/agent/binding_ports.py new file mode 100644 index 00000000..0bb86885 --- /dev/null +++ b/src/agent/binding_ports.py @@ -0,0 +1,18 @@ +"""Select approved public player/query ports without changing live local settings.""" + +from .contracts import integer + + +def selected_ports(local, approved, options, *, query_port=None): + """Infer only while binding; keep existing approvals unless explicitly overridden.""" + query = options.port if options.port is not None else approved.get('port', local['port']) + connect = approved.get('connect_port', local['connect_port']) + if options.connect_port is not None: + connect = options.connect_port + elif local['game_id'].startswith('minecraft.') and ( + options.port is not None or (query_port is not None and query_port != query + and approved.get('connect_port') in (None, approved.get('port')))): + connect = query if query_port is None else query_port + if connect is not None: + integer(connect, 1, 65535) + return {'port': query if query_port is None else query_port, 'connect_port': connect} diff --git a/src/agent/choices.py b/src/agent/choices.py new file mode 100644 index 00000000..41be2716 --- /dev/null +++ b/src/agent/choices.py @@ -0,0 +1,68 @@ +"""Capability-aware permission prompts for exact local binding previews.""" + +from server.public_query import PROFILES +from .config import TEAMSPEAK_DIRECTORY, ConfigError +from .contracts import ACTIONS + + +def publication_choice(game, approved, explicit): + if game == "ts3server": + print(TEAMSPEAK_DIRECTORY) + if explicit is True: + raise ConfigError(TEAMSPEAK_DIRECTORY) + return False + if game not in PROFILES: + print("Public listing unavailable for " + game + "; private binding and management are supported.") + if explicit is True: + raise ConfigError("This game has no supported public query profile") + return False + if explicit is not None: + return explicit + response = input("Publish this server in the public directory? [" + ("keep yes" if approved else "no") + "]: ").strip().lower() + if not response: + return approved + if response not in ("y", "yes", "n", "no"): + raise ConfigError("Publication requires yes or no") + return response in ("y", "yes") + + +def actions_choice(approved, explicit): + if explicit is not None: + return explicit + response = input("Allowed remote actions (status start stop restart) [" + (" ".join(approved) or "none") + "]: ").strip() + if not response: + return list(approved) + actions = [] if response == "none" else response.split() + if len(set(actions)) != len(actions) or any(action not in ACTIONS for action in actions): + raise ConfigError("Only status, start, stop and restart are allowed") + return actions + + +def require_capabilities(value, publication, actions): + """Known enrollments cannot approve a capability they did not receive.""" + if value['installation_id'] is None: + return + if publication and not value['reporting']: + raise ConfigError("Publication requires an enrolled reporting credential") + if actions and not value['management']: + raise ConfigError("Management actions require an enrolled management credential") + + +def permissions(game, previous, value, options): + publication = previous['publication'] if previous else False + actions = previous['actions'] if previous else [] + require_capabilities(value, options.publish if options.publish is not None else publication, + options.actions if options.actions is not None else actions) + unknown = value['installation_id'] is None + publication = publication_choice(game, publication, options.publish) if ( + unknown or value['reporting'] or game not in PROFILES) else False + actions = actions_choice(actions, options.actions) if unknown or value['management'] else [] + return publication, actions + + +def next_steps(value): + available = ['agent enable-' + scope for scope in ('reporting', 'management') if value[scope]] + if available: + print("Next: " + " and/or ".join(available) + " when ready; then agent run.") + else: + print("This enrollment has no reporting or management credential available.") diff --git a/src/agent/cli.py b/src/agent/cli.py new file mode 100644 index 00000000..0a421600 --- /dev/null +++ b/src/agent/cli.py @@ -0,0 +1,261 @@ +"""Local interactive enrollment and explicit opt-in; no secret CLI arguments.""" + +import argparse +import getpass +import json +from .config import TEAMSPEAK_DIRECTORY, Config, ConfigError, local_tag +from .contracts import ACTIONS +from .service import Service +from .transport import Transport, TransportError +from . import user_service +from . import onboarding +from .publication_config import PublicationConfigError, local_publication +from .dns_lookup import PreflightError +from . import endpoint_check +from . import prove +from . import local_query +from . import name_proof +from .binding_ports import selected_ports + + +def run(args, *, root=None, executable=None): + parser = argparse.ArgumentParser(prog="alphagsm agent") + commands = parser.add_subparsers(dest="command", required=True) + for command in ( + "enroll", + "status", + "enable-reporting", + "disable-reporting", + "enable-management", + "disable-management", + "run", + "report-once", + "forget", + "install-service", + "uninstall-service", + ): + command_parser = commands.add_parser(command) + if command == "enroll": + command_parser.add_argument("--origin") + if command in ("install-service", "uninstall-service"): + command_parser.add_argument("--system", action="store_true") + if command == "install-service": + command_parser.add_argument("--service-user") + replacement = commands.add_parser("replace-credential") + replacement.add_argument("scope", choices=("reporting", "management")) + for command in ("bind", "setup"): + bind = commands.add_parser(command) + if command == "bind": + bind.add_argument("target", help="One local tag, '*' for all, or a legacy binding ID") + bind.add_argument("local_tag", nargs="?", help="Legacy exact local tag") + else: + bind.set_defaults(target="*", local_tag=None) + bind.add_argument("--binding-id") + bind.add_argument("--origin") + bind.add_argument("--epoch", type=int, default=1) + bind.add_argument("--actions", nargs="*", choices=sorted(ACTIONS)) + publication = bind.add_mutually_exclusive_group() + publication.add_argument("--publish", action="store_true", default=None) + publication.add_argument("--private", action="store_false", dest="publish") + bind.add_argument("--game") + bind.add_argument("--host") + bind.add_argument("--public-ip", action="append", help="Expected public machine IP; repeat for multiple IPs") + bind.add_argument("--port", type=int, help="Advertised query port; Minecraft also uses it for players") + bind.add_argument("--connect-port", type=int, help="Advertised player connection port (for example NAT)") + bind.add_argument("--name") + bind.add_argument("--capacity", type=int) + check = commands.add_parser("check-endpoint") + check.add_argument("local_tag") + check.add_argument("--public-ip", action="append") + unbind = commands.add_parser("unbind") + unbind.add_argument("binding_id") + publication = commands.add_parser("publication") + publication.add_argument("binding_id") + publication.add_argument("selection", choices=("on", "off")) + proof = commands.add_parser("prove") + proof.add_argument("local_tag") + queried = commands.add_parser("query") + queried.add_argument("local_tag") + try: + options = parser.parse_args(args) + except SystemExit as exc: + return exc.code + if options.command in ("install-service", "uninstall-service"): + return _service_command(options, executable) + try: + cfg = Config(root) + if options.command == "enroll": + onboarding.enroll(cfg, options.origin, Transport) + elif options.command == "replace-credential": + key_id = input("Rotated key ID: ").strip() + secret = getpass.getpass("Rotated credential secret: ").strip() + generation = int(input("New management generation: ")) if options.scope == "management" else None + cfg.replace_credential(options.scope, key_id, secret, generation=generation) + print("Selected credential replaced. Existing local permissions are unchanged.") + elif options.command in ("bind", "setup"): + if options.local_tag is None: + return onboarding.guided(cfg, options, Transport) + if options.binding_id is not None or any( + getattr(options, key) is None for key in ("game", "host", "port", "name") + ): + raise ConfigError("Legacy bind requires exact game, host, port and name") + if options.publish is True and options.game == "ts3server": + raise ConfigError(TEAMSPEAK_DIRECTORY) + if options.publish is True and options.game not in onboarding.PROFILES: + raise ConfigError("This game has no supported public query profile") + # Verify that the local target exists under this user's normal AlphaGSM namespace. + from .lifecycle import AlphaRuntime + + with AlphaRuntime().lock(local_tag(options.local_tag)): + local = onboarding.read_local(options.local_tag) + if local['game_id'] != options.game: + raise ConfigError("Selected game does not match the local server module") + ports = selected_ports(local, {}, options) + cfg.bind( + options.target, + options.local_tag, + options.epoch, + actions=options.actions or [], + publication=options.publish is True, + metadata={ + "game_id": options.game, + "host": options.host, + **ports, + "name": options.name, + "capacity": options.capacity or 0, + }, + **local_publication(options.public_ip), + ) + print("Exact local binding saved. Only the selected operations are permitted.") + print("Advertised host=" + options.host + " | player port=" + str(ports['connect_port'] or 'unknown') + + " | query port=" + str(ports['port'])) + if "stop" in (options.actions or []) or "restart" in (options.actions or []): + print("Normal AlphaGSM stop may force termination after graceful shutdown attempts.") + elif options.command == "check-endpoint": + tag = local_tag(options.local_tag) + matches = [binding for binding in cfg.load()['bindings'].values() if binding['tag'] == tag] + if len(matches) != 1: + raise ConfigError("Bind this exact local server tag before checking its public endpoint") + endpoint_check.check_endpoint(matches[0], options.public_ip) + print("Public endpoint check passed for " + tag + ". The central ownership challenge is still required.") + elif options.command == "unbind": + cfg.unbind(options.binding_id) + elif options.command == "publication": + cfg.publication(options.binding_id, options.selection == "on") + elif options.command == "prove": + return _prove(cfg, options.local_tag) + elif options.command == "query": + from utils.query import QueryError + + try: + local_query.query_tag(cfg, options.local_tag) + except QueryError as exc: + print("Query failed: " + str(exc)) + return 1 + elif options.command in ("enable-reporting", "disable-reporting", "enable-management", "disable-management"): + scope = options.command.split("-")[1] + cfg.enable(**{scope: options.command.startswith("enable")}) + if options.command == "disable-reporting": + # Explicit best-effort withdrawal; an offline publication lease expires. + if cfg.load()["reporting"]: + try: + Service(cfg).report_once() + except TransportError: + print("Offline: publication will expire within ten minutes of the last accepted renewal.") + if options.command == "enable-management": + print( + "Remote operations use normal local stop/restart semantics, including possible force termination." + ) + elif options.command == "status": + value = cfg.load() + print( + json.dumps( + { + k: value[k] + for k in ("installation_id", "reporting_enabled", "management_enabled", "policy_version") + }, + sort_keys=True, + ) + ) + elif options.command == "run": + Service(cfg).run() + elif options.command == "report-once": + value = cfg.load() + if not value["reporting_enabled"]: + raise ConfigError("Reporting is disabled; use enable-reporting first") + Service(cfg).report_once() + elif options.command == "forget": + cfg.forget() + return 0 + except (ConfigError, PreflightError, PublicationConfigError) as exc: + print("Agent operation rejected: " + str(exc)) + return 1 + except (ValueError, OSError, RuntimeError, EOFError): + print("Agent operation rejected; check local policy, permissions and provider availability.") + return 1 + except KeyboardInterrupt: + return 130 + + +def _service_command(options, executable): + """Install or remove the optional service without changing consent.""" + try: + if options.command == "install-service": + if options.service_user and not options.system: + raise user_service.ServiceInstallError("--service-user is only used with --system") + user_service.install(executable, system=options.system, user=options.service_user) + else: + user_service.uninstall(system=options.system) + except user_service.ServiceInstallError as exc: + print("Agent operation rejected: " + str(exc)) + return 1 + return 0 + + +def _prove(cfg, tag): + """Place the account's ownership token on TeamSpeak and wait for the site query.""" + tag = local_tag(tag) + matches = [(key, item) for key, item in cfg.load()["bindings"].items() if item["tag"] == tag] + if len(matches) != 1: + raise ConfigError("Bind this exact local server tag before proving its name") + binding_id, binding = matches[0] + value = cfg.load() + token = name_proof.fetch_challenge(Transport, value, binding_id) + if token is None: + token = getpass.getpass("Ownership challenge: ").strip() + from utils.query import QueryError + + try: + result = name_proof.prove_binding(cfg, tag, binding_id, binding, token) + except prove.QueryBusy: + print("Sector Alpha is still querying this server. The previous TeamSpeak name has been restored. Try again.") + return 1 + except ValueError as exc: + print(str(exc)) + return 1 + except QueryError as exc: + print("TeamSpeak name proof failed: " + str(exc).split(":", 1)[0]) + return 1 + except TransportError: + print("Sector Alpha did not accept the check. Create a fresh website challenge and try again.") + return 1 + if result.get("verified") is True: + print("Control verified. The previous TeamSpeak name has been restored.") + else: + print("The name check did not verify. The previous TeamSpeak name has been restored.") + return 0 + + +def notify_success(tag): + """Durable reconciliation hint only; outages never affect local command success.""" + try: + from .config import default_root + + if not (default_root() / "config.json").exists(): + return + cfg = Config() + value = cfg.load() + if value["reporting_enabled"] and any(b["tag"] == tag for b in value["bindings"].values()): + cfg.changed() + except (ValueError, OSError): + pass diff --git a/src/agent/config.py b/src/agent/config.py new file mode 100644 index 00000000..2ae2fa08 --- /dev/null +++ b/src/agent/config.py @@ -0,0 +1,272 @@ +# pylint: disable=unidiomatic-typecheck +"""Local opt-ins and exact binding policy outside the server datastore namespace.""" + +from pathlib import Path +import json +import os +import re +from urllib.parse import urlsplit +from utils.state_io import atomic_write_text, state_lock +from .contracts import ACTIONS, identifier, integer, validate_metadata, exact + +DEFAULT_ORIGIN = "https://alphagsm-api.sector-alpha.net" +TEAMSPEAK_DIRECTORY = ( + "TeamSpeak directory listing is a website choice after the name check. " + "The API queries the TeamSpeak server and does not use this local switch." +) + + +class ConfigError(ValueError): + """Invalid local configuration or unsupported permission boundary.""" + + +def origin(value): + if type(value) is not str or any(ord(c) <= 32 for c in value) or "?" in value or "#" in value: + raise ConfigError("Configure one fixed HTTPS origin without credentials or paths") + parsed = urlsplit(value) + invalid = ( + parsed.scheme != "https", + not parsed.hostname, + parsed.username, + parsed.password, + parsed.path not in ("", "/"), + parsed.query, + parsed.fragment, + ) + if any(invalid): + raise ConfigError("Configure one fixed HTTPS origin without credentials or paths") + if parsed.port is not None and not 1 <= parsed.port <= 65535: + raise ConfigError("Invalid HTTPS port") + return value[:-1] if parsed.path == "/" else value + + +def canonical_agent_origin(value): + """Map only trusted official HTTPS authorities locally, without redirects.""" + selected = origin(value) + parsed = urlsplit(selected) + if parsed.hostname in ('sector-alpha.net', 'alphagsm-api.sector-alpha.net') and parsed.port in (None, 443): + return DEFAULT_ORIGIN + return selected + + +def local_tag(value): + if type(value) is not str or value in (".", "..") or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", value): + raise ConfigError("Select one exact local server tag") + return value + + +def default_root(): + from utils.settings import settings + + return Path(os.path.expanduser(settings.user.getsection("core").get("alphagsm_path", "~/.alphagsm"))) / "agent" + + +class Config: + def __init__(self, root=None): + self.root = Path(root) if root is not None else default_root() + if os.name == "nt": + # POSIX chmod does not establish Windows user-only ACLs. Fail closed + # until an audited Windows credential-store adapter is supplied. + raise ConfigError("Remote agent credential storage currently requires POSIX user-only permissions") + if self.root.is_symlink(): + raise ConfigError("Agent directory must not be a symlink") + self.root.mkdir(parents=True, exist_ok=True, mode=0o700) + if self.root.stat().st_uid != os.getuid(): + raise ConfigError("Agent directory must belong to the current operator") + os.chmod(self.root, 0o700) + self.path = self.root / "config.json" + if self.path.is_symlink(): + raise ConfigError("Agent configuration must not be a symlink") + + def __repr__(self): + return "Config()" + + @staticmethod + def defaults(): + return { + "schema_version": 1, + "origin": None, + "installation_id": None, + "credential_generation": 0, + "reporting": None, + "management": None, + "reporting_enabled": False, + "management_enabled": False, + "policy_version": 1, + "sequence": 0, + "bindings": {}, + "dirty_at": 0, + } + + def load(self): + if not self.path.exists(): + return self.defaults() + if self.path.is_symlink() or self.path.stat().st_uid != os.getuid() or self.path.stat().st_mode & 0o077: + raise ConfigError("Agent configuration must be owned by the operator with mode 0600") + if self.path.stat().st_size > 256 * 1024: + raise ConfigError("Agent configuration exceeds size limit") + value = json.loads(self.path.read_text()) + exact(value, self.defaults()) + if value["schema_version"] != 1: + raise ConfigError("Unsupported local configuration") + if value["origin"] is not None: + value["origin"] = canonical_agent_origin(value["origin"]) + return value + + def _write(self, value): + atomic_write_text(self.path, json.dumps(value, sort_keys=True), mode=0o600) + + def enroll(self, api_origin, response): + api_origin = canonical_agent_origin(api_origin) + exact(response, ("schema_version", "installation_id", "credential_generation", "reporting", "management")) + integer(response["schema_version"], 1, 1) + identifier(response["installation_id"]) + integer(response["credential_generation"]) + for scope in ("reporting", "management"): + key = response[scope] + if key is not None: + exact(key, ("key_id", "secret")) + identifier(key["key_id"]) + if type(key["secret"]) is not str or not re.fullmatch(r"[A-Za-z0-9_-]{43}", key["secret"]): + raise ConfigError("Invalid credential response") + with state_lock(self.path): + old = self.load() + if old["installation_id"] is not None: + raise ConfigError("Disable and revoke the old relationship before enrolling another provider") + value = self.defaults() + value.update( + {k: response[k] for k in ("installation_id", "credential_generation", "reporting", "management")} + ) + value["origin"] = api_origin + self._write(value) + + def replace_credential(self, scope, key_id, secret, *, generation=None): + """Import one rotated key without granting a capability or changing policy.""" + if scope not in ("reporting", "management"): + raise ConfigError("Invalid capability") + identifier(key_id) + if type(secret) is not str or not re.fullmatch(r"[A-Za-z0-9_-]{43}", secret): + raise ConfigError("Invalid credential") + with state_lock(self.path): + value = self.load() + if value[scope] is None: + raise ConfigError("This capability was not enrolled") + if scope == "management": + integer(generation) + if generation <= value["credential_generation"]: + raise ConfigError("Management generation must advance") + value["credential_generation"] = generation + elif generation is not None: + raise ConfigError("Reporting rotation does not change management generation") + value[scope] = {"key_id": key_id, "secret": secret} + self._write(value) + + def bind(self, binding_id, tag, epoch, *, actions, publication, metadata, expected_public_ips=None): + from server.public_query import PROFILES + from .publication_config import local_publication + + if publication is True and metadata.get("game_id") == "ts3server": + raise ConfigError(TEAMSPEAK_DIRECTORY) + if publication is True and metadata.get("game_id") not in PROFILES: + raise ConfigError("This game has no supported public query profile") + identifier(binding_id) + local_tag(tag) + integer(epoch) + if ( + type(actions) is not list + or any(type(a) is not str or a not in ACTIONS for a in actions) + or len(set(actions)) != len(actions) + ): + raise ConfigError("Only status, start, stop and restart are allowed") + item = { + "binding_id": binding_id, + "binding_epoch": epoch, + "publication": publication, + "allowed_actions": actions, + "runtime_state": "unknown", + **metadata, + } + validate_metadata(item) + with state_lock(self.path): + value = self.load() + if len(value["bindings"]) >= 100 and binding_id not in value["bindings"]: + raise ConfigError("Too many selected bindings") + if any(b["tag"] == tag and key != binding_id for key, b in value["bindings"].items()): + raise ConfigError("Only one management binding per local server") + binding = { + "tag": tag, + "epoch": epoch, + "actions": actions, + "publication": publication, + "metadata": metadata, + **local_publication(expected_public_ips, value["bindings"].get(binding_id)), + } + if value["bindings"].get(binding_id) == binding: + return + value["bindings"][binding_id] = binding + value["policy_version"] += 1 + value["dirty_at"] = __import__("time").time() + self._write(value) + + def enable(self, *, reporting=None, management=None): + with state_lock(self.path): + value = self.load() + for scope, selection in (("reporting", reporting), ("management", management)): + if selection is not None: + if type(selection) is not bool or (selection and not value[scope]): + raise ConfigError("Capability was not enrolled") + value[scope + "_enabled"] = selection + if management is not None: + value["policy_version"] += 1 + value["dirty_at"] = __import__("time").time() + self._write(value) + + def unbind(self, binding_id): + identifier(binding_id) + with state_lock(self.path): + value = self.load() + if binding_id not in value["bindings"]: + raise ConfigError("Unknown local binding") + del value["bindings"][binding_id] + value["policy_version"] += 1 + value["dirty_at"] = __import__("time").time() + self._write(value) + + def publication(self, binding_id, enabled): + from server.public_query import PROFILES + + identifier(binding_id) + if type(enabled) is not bool: + raise ConfigError("Publication selection must be boolean") + with state_lock(self.path): + value = self.load() + if binding_id not in value["bindings"]: + raise ConfigError("Unknown local binding") + game_id = value["bindings"][binding_id]["metadata"]["game_id"] + if enabled and game_id == "ts3server": + raise ConfigError(TEAMSPEAK_DIRECTORY) + if enabled and game_id not in PROFILES: + raise ConfigError("This game has no supported public query profile") + value["bindings"][binding_id]["publication"] = enabled + value["dirty_at"] = __import__("time").time() + self._write(value) + + def changed(self): + with state_lock(self.path): + value = self.load() + value["dirty_at"] = __import__("time").time() + self._write(value) + + def sequence(self): + with state_lock(self.path): + value = self.load() + value["sequence"] += 1 + self._write(value) + return value + + def forget(self): + with state_lock(self.path): + value = self.load() + if value["management_enabled"] or value["reporting_enabled"]: + raise ConfigError("Disable both capabilities and revoke centrally before forgetting credentials") + self._write(self.defaults()) diff --git a/src/agent/contracts.py b/src/agent/contracts.py new file mode 100644 index 00000000..cf4712b7 --- /dev/null +++ b/src/agent/contracts.py @@ -0,0 +1,178 @@ +# pylint: disable=unidiomatic-typecheck +"""Dependency-free version-one contracts shared with compatible control services.""" + +from datetime import datetime, timezone +import re + +ACTIONS = frozenset(("status", "start", "stop", "restart")) +STATES = frozenset(("running", "stopped", "unknown")) +OUTCOMES = frozenset(("succeeded", "failed", "unknown")) +CATEGORIES = frozenset( + ("ok", "precondition", "denied", "expired", "execution_failed", "interrupted", "budget_exceeded") +) +IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$") + + +class ContractError(ValueError): + """Untrusted JSON does not satisfy the closed protocol.""" + + +def exact(value, fields): + if type(value) is not dict or set(value) != set(fields): + raise ContractError("Unexpected or missing fields") + return value + + +def identifier(value): + if type(value) is not str or not IDENTIFIER.fullmatch(value): + raise ContractError("Invalid identifier") + return value + + +def integer(value, minimum=1, maximum=2**63 - 1): + if type(value) is not int or not minimum <= value <= maximum: + raise ContractError("Invalid integer") + return value + + +def text(value, maximum=256): + if type(value) is not str or len(value.encode("utf-8")) > maximum or any(ord(c) < 32 for c in value): + raise ContractError("Invalid bounded text") + return value + + +def enum(value, choices): + if type(value) is not str or value not in choices: + raise ContractError("Invalid enum") + return value + + +def timestamp(value): + if type(value) is not str or len(value) > 40: + raise ContractError("Invalid timestamp") + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise ContractError("Invalid timestamp") from exc + if parsed.tzinfo is None or parsed.utcoffset().total_seconds() != 0: + raise ContractError("Timestamp must be UTC") + return parsed.timestamp() + + +def utc_now(): + return datetime.now(timezone.utc).isoformat() + + +def validate_job(value): + exact( + value, + ( + "schema_version", + "job_id", + "installation_id", + "binding_id", + "binding_epoch", + "action", + "policy_version", + "credential_generation", + "requested_by", + "issued_at", + "expires_at", + ), + ) + integer(value["schema_version"], 1, 1) + for key in ("job_id", "installation_id", "binding_id", "requested_by"): + identifier(value[key]) + for key in ("binding_epoch", "policy_version", "credential_generation"): + integer(value[key]) + enum(value["action"], ACTIONS) + issued, expires = timestamp(value["issued_at"]), timestamp(value["expires_at"]) + if not 0 < expires - issued <= 120: + raise ContractError("Invalid job lifetime") + return value + + +def validate_metadata(value): + optional = ("connect_port",) if type(value) is dict and "connect_port" in value else () + exact( + value, + ( + "binding_id", + "binding_epoch", + "publication", + "name", + "game_id", + "host", + "port", + "capacity", + "runtime_state", + "allowed_actions", + ) + optional, + ) + identifier(value["binding_id"]) + integer(value["binding_epoch"]) + if type(value["publication"]) is not bool: + raise ContractError("Publication must be boolean") + for key in ("name", "game_id", "host"): + text(value[key]) + integer(value["port"], 1, 65535) + if value.get("connect_port") is not None: + integer(value["connect_port"], 1, 65535) + integer(value["capacity"], 0, 100000) + enum(value["runtime_state"], STATES) + actions = value["allowed_actions"] + if type(actions) is not list or len(actions) > 4 or len(set(map(str, actions))) != len(actions): + raise ContractError("Invalid actions") + for action in actions: + enum(action, ACTIONS) + return value + + +def validate_report(value): + exact(value, ("schema_version", "installation_id", "sequence", "policy_version", "bindings")) + integer(value["schema_version"], 1, 1) + identifier(value["installation_id"]) + integer(value["sequence"]) + integer(value["policy_version"]) + bindings = value["bindings"] + if type(bindings) is not list or len(bindings) > 100: + raise ContractError("Invalid binding collection") + for binding in bindings: + validate_metadata(binding) + if len({b["binding_id"] for b in bindings}) != len(bindings): + raise ContractError("Duplicate binding") + return value + + +def validate_result(value): + exact(value, ("schema_version", "job_id", "installation_id", "outcome", "runtime_state", "category", "observed_at")) + integer(value["schema_version"], 1, 1) + identifier(value["job_id"]) + identifier(value["installation_id"]) + enum(value["outcome"], OUTCOMES) + enum(value["runtime_state"], STATES) + enum(value["category"], CATEGORIES) + timestamp(value["observed_at"]) + return value + + +def validate_policy(value): + exact(value, ("schema_version", "installation_id", "policy_version", "bindings")) + integer(value["schema_version"], 1, 1) + identifier(value["installation_id"]) + integer(value["policy_version"]) + if type(value["bindings"]) is not list or len(value["bindings"]) > 100: + raise ContractError("Invalid binding collection") + seen = set() + for item in value["bindings"]: + exact(item, ("binding_id", "binding_epoch", "allowed_actions")) + identifier(item["binding_id"]) + integer(item["binding_epoch"]) + if item["binding_id"] in seen: + raise ContractError("Duplicate binding") + seen.add(item["binding_id"]) + if type(item["allowed_actions"]) is not list or len(item["allowed_actions"]) > 4: + raise ContractError("Invalid actions") + for action in item["allowed_actions"]: + enum(action, ACTIONS) + return value diff --git a/src/agent/dns_lookup.py b/src/agent/dns_lookup.py new file mode 100644 index 00000000..6e11bc30 --- /dev/null +++ b/src/agent/dns_lookup.py @@ -0,0 +1,51 @@ +"""Fresh complete A and AAAA resolution with explicit DNS protocol lifetimes.""" + +import asyncio +import re +import time +import dns.exception +import dns.asyncresolver + +DNS_TIMEOUT = 2.0 +MAX_DNS_ADDRESSES = 32 + + +class PreflightError(ValueError): + """A safe operator-facing publication check failure.""" + + +def resolve_host(host, timeout=DNS_TIMEOUT): + """Never return partial success if either address-family lookup fails.""" + if not isinstance(host, str) or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9.-]{0,251}[A-Za-z0-9]', host): + raise PreflightError("Use an existing public DNS hostname") + timeout = min(timeout, DNS_TIMEOUT) + if timeout <= 0: + raise PreflightError("DNS lookup timed out; check both A and AAAA records") + + async def complete(): + resolver = dns.asyncresolver.Resolver() + resolver.cache = None + deadline = time.monotonic() + timeout + addresses = set() + for family in ('A', 'AAAA'): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise asyncio.TimeoutError() + answer = await resolver.resolve(host, family, lifetime=remaining, search=False, raise_on_no_answer=False) + for record in answer: + addresses.add(record.address) + if len(addresses) > MAX_DNS_ADDRESSES: + raise PreflightError("DNS returned too many addresses; check the hostname records") + return sorted(addresses) + + async def bounded(): + # The outer cancellation also bounds resolver retry backoff sleeps. + # dnspython closes its async sockets when this task is cancelled. + return await asyncio.wait_for(complete(), timeout=timeout) + + try: + return asyncio.run(bounded()) + except (asyncio.TimeoutError, dns.exception.Timeout): + raise PreflightError("DNS lookup timed out; check both A and AAAA records") from None + except dns.exception.DNSException: + raise PreflightError("DNS lookup failed; check the existing A and AAAA records") from None diff --git a/src/agent/endpoint_check.py b/src/agent/endpoint_check.py new file mode 100644 index 00000000..53d11e80 --- /dev/null +++ b/src/agent/endpoint_check.py @@ -0,0 +1,56 @@ +"""Explicit native endpoint diagnostics; never run as a publication renewal hook.""" + +import ipaddress +import time + +from server.public_query import IPV4_ONLY, protocol_for, query_public +from .dns_lookup import DNS_TIMEOUT, PreflightError, resolve_host +from .inventory import read_local +from .network import suggest_public_host +from .preflight import expected_addresses, matched_addresses, numerical +from .publication_config import normalize_public_ips + +PROBE_TIMEOUT = 5.0 + + +def check_endpoint(binding, expected_public_ips=None, *, resolve=None, probe=None, read=None, discover=None): + """Probe only validated pinned machine IPs, retaining SLP's original host.""" + metadata = binding['metadata'] + game, host, port = metadata['game_id'], metadata['host'], metadata['port'] + protocol = protocol_for(game) + if protocol is None: + raise PreflightError("This game has no supported public query profile; private binding remains available") + if protocol == "ts3": + if type(port) is not int or port != 10011: # pylint: disable=unidiomatic-typecheck + raise PreflightError("TeamSpeak checks use query port 10011") + elif type(port) is not int or not 1024 <= port <= 65535: # pylint: disable=unidiomatic-typecheck + raise PreflightError("Public endpoint checks require an advertised port from 1024 to 65535") + selected = dict(binding) + if expected_public_ips is not None: + selected['expected_public_ips'] = normalize_public_ips(expected_public_ips) + expected = expected_addresses(selected, [], read=read or read_local, discover=discover or suggest_public_host) + if numerical(host): + answers = [host] + else: + dns_deadline = time.monotonic() + DNS_TIMEOUT + try: + answers = (resolve or resolve_host)(host, DNS_TIMEOUT) + except (OSError, ValueError, RuntimeError): + raise PreflightError("DNS unavailable or timed out; no endpoint connection was attempted") from None + if time.monotonic() >= dns_deadline: + raise PreflightError("DNS lookup timed out; no endpoint connection was attempted") + addresses = matched_addresses(answers, expected) + if protocol in IPV4_ONLY and any(ipaddress.ip_address(address).version != 4 for address in addresses): + raise PreflightError("This public query supports IPv4 only; check the hostname AAAA record") + deadline = time.monotonic() + PROBE_TIMEOUT + for address in addresses: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise PreflightError("Public endpoint check timed out; check the advertised port and NAT loopback support") + try: + (probe or query_public)(game, address, port, virtual_host=host, timeout=remaining) + except (OSError, ValueError, RuntimeError): + raise PreflightError("Public endpoint unavailable; check the server, advertised port, firewall and NAT loopback support") from None + if time.monotonic() > deadline: + raise PreflightError("Public endpoint check timed out; check the advertised port and NAT loopback support") + return addresses diff --git a/src/agent/http_deadline.py b/src/agent/http_deadline.py new file mode 100644 index 00000000..2c4f7a44 --- /dev/null +++ b/src/agent/http_deadline.py @@ -0,0 +1,120 @@ +"""Reusable total HTTPS deadlines with active socket cancellation and no late connect.""" + +import http.client +import queue +import socket +import threading +import time +import urllib.request + +from .tls import client_context + + +class DeadlineBudget: + """One deadline owns every socket, including connect, handshake and headers.""" + + def __init__(self, timeout, *, family=socket.AF_UNSPEC): + self.deadline = time.monotonic() + timeout + self.family = family + self.lock = threading.Lock() + self.cancelled = False + self.connection = None + + def remaining(self): + remaining = self.deadline - time.monotonic() + if self.cancelled or remaining <= 0: + raise TimeoutError("HTTPS total deadline exceeded") + return remaining + + def track(self, connection): + with self.lock: + try: + self.remaining() + except TimeoutError: + connection.close() + raise + self.connection = connection + return connection + + def connect(self, address, timeout=None, source_address=None): + # System DNS can outlive a socket timeout. The caller remains bounded; + # once cancelled, this worker cannot open any connection after DNS returns. + self.remaining() + choices = socket.getaddrinfo(*address, family=self.family, type=socket.SOCK_STREAM) + failure = OSError("Public-address endpoint is unavailable") + for family, kind, protocol, _, destination in choices: + connection = self.track(socket.socket(family, kind, protocol)) + try: + connection.settimeout(self.remaining()) + if source_address: + connection.bind(source_address) + connection.connect(destination) + return connection + except OSError as exc: + connection.close() + failure = exc + raise failure + + def cancel(self): + with self.lock: + self.cancelled = True + connection, self.connection = self.connection, None + if connection is not None: + try: + connection.shutdown(socket.SHUT_RDWR) + except OSError: + pass + connection.close() + + +class DeadlineHTTPSConnection(http.client.HTTPSConnection): + def __init__(self, budget, host, **kwargs): + super().__init__(host, **kwargs) + self.budget = budget + self._create_connection = budget.connect + + def connect(self): + http.client.HTTPConnection.connect(self) + # Register the TLS socket before handshaking so cancellation also closes + # an active TLS handshake, rather than only a detached raw socket. + self.sock = self.budget.track(self._context.wrap_socket( + self.sock, server_hostname=self.host, do_handshake_on_connect=False)) + self.sock.settimeout(self.budget.remaining()) + self.sock.do_handshake() + + +class DeadlineHTTPSHandler(urllib.request.HTTPSHandler): + def __init__(self, budget=None, *, context=None): + super().__init__(context=context or client_context()) + self.budget = budget + + def https_open(self, req): + budget = self.budget or getattr(req, "agent_deadline", None) + if budget is None: + return super().https_open(req) + def connection(host, **kwargs): + return DeadlineHTTPSConnection(budget, host, **kwargs) + return self.do_open(connection, req, context=self._context) + + +def bounded_exchange(operation, budget, *, name): + result = queue.Queue(maxsize=1) + + def exchange(): + try: + result.put((True, operation())) + except Exception as exc: + result.put((False, exc)) + + worker = threading.Thread(target=exchange, daemon=True, name=name) + worker.start() + try: + accepted, value = result.get(timeout=budget.remaining()) + if not accepted: + raise value + return value + except queue.Empty: + raise TimeoutError("HTTPS total deadline exceeded") from None + finally: + budget.cancel() + worker.join(timeout=max(0, budget.deadline - time.monotonic())) diff --git a/src/agent/inventory.py b/src/agent/inventory.py new file mode 100644 index 00000000..3bb89af5 --- /dev/null +++ b/src/agent/inventory.py @@ -0,0 +1,90 @@ +"""Read-only current-operator inventory; no Server construction or game hooks.""" + +import json +import os +from pathlib import Path +import stat + +from .config import ConfigError, local_tag +from .contracts import text + +MAX_SERVERS = 100 +MAX_DATA_BYTES = 1024 * 1024 +# These modules' primary setting is an admin/query endpoint or a client role, +# not a known player listener. Explicit --connect-port remains an operator choice. +NON_PLAYER_PRIMARY_PORTS = frozenset({ + "trackmaniaserver", "enshrouded", "arma3.headless", "arma3headlessserver", +}) + + +def number(value, minimum, maximum): + """Coerce local numeric settings without accepting booleans or fractions.""" + if isinstance(value, bool) or not isinstance(value, (str, int)): + return None + try: + value = int(value) + return value if minimum <= value <= maximum else None + except ValueError: + return None + + +def read_local(tag): + """Extract an allowlist from one owned regular non-symlink datastore.""" + from server.server import DATAPATH, MODULE_CATALOG + + local_tag(tag) + path = Path(DATAPATH) / (tag + ".json") + with os.fdopen(os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK), "rb") as handle: + info = os.fstat(handle.fileno()) + if info.st_uid != os.getuid() or not stat.S_ISREG(info.st_mode): + raise ConfigError("Datastore must be a regular file owned by the current operator") + if info.st_size > MAX_DATA_BYTES: + raise ConfigError("Local datastore exceeds size limit") + raw = handle.read(MAX_DATA_BYTES + 1) + if len(raw) > MAX_DATA_BYTES: + raise ConfigError("Local datastore exceeds size limit") + data = json.loads(raw) + if not isinstance(data, dict) or not isinstance(data.get("module"), str): + raise ConfigError("Local server has no game module") + game = MODULE_CATALOG.resolve(data["module"]) + if game not in MODULE_CATALOG.canonical_modules: + raise ConfigError("Local server game module is not in the canonical module catalog") + name = data.get("servername") or tag + text(name) + return { + "tag": tag, "game_id": game, "name": name, + "port": number(data.get("queryport"), 1, 65535) or number(data.get("port"), 1, 65535), + "connect_port": (None if game in NON_PLAYER_PRIMARY_PORTS + else number(data.get("port"), 1, 65535)), + "capacity": number(data.get("maxplayers"), 0, 100000) or 0, + **{key: data.get(key) if isinstance(data.get(key), str) else None + for key in ("publicip", "externalip", "hostip")}, + } + + +def inventory(): + """Enumerate the normal current-user namespace, excluding secret sidecars.""" + from server.server import DATAPATH + + selected, failures = [], [] + path = Path(DATAPATH) + if not path.exists(): + return selected, failures + for item in sorted(path.iterdir()): + if not item.name.endswith(".json") or item.name.endswith(".secrets.json"): + continue + tag = item.name[:-5] + try: + local_tag(tag) + except ConfigError: + failures.append(("", "Invalid local server tag")) + continue + try: + if len(selected) >= MAX_SERVERS: + raise ConfigError("At most 100 local servers can be selected") + selected.append(read_local(tag)) + except ConfigError as exc: + failures.append((tag, str(exc))) + except (OSError, ValueError, RuntimeError): + failures.append((tag, "Unreadable, unowned, symlinked or invalid local datastore")) + return selected, failures diff --git a/src/agent/journal.py b/src/agent/journal.py new file mode 100644 index 00000000..10c8ba35 --- /dev/null +++ b/src/agent/journal.py @@ -0,0 +1,73 @@ +"""Durable processed IDs: persist intent and result before network acknowledgement.""" + +import json +import time +from pathlib import Path +from utils.state_io import atomic_write_text, state_lock +from .contracts import validate_job, validate_result + + +class Journal: + def __init__(self, path): + self.path = Path(path) + + def _load(self): + if not self.path.exists(): + return {} + if self.path.is_symlink() or self.path.stat().st_size > 32 * 1024 * 1024: + raise ValueError("Unsafe agent journal") + return json.loads(self.path.read_text()) + + def _write(self, value): + # Retain processed IDs for 30 days; never expire executing/accepted intents. + now = time.time() + value = { + k: v for k, v in value.items() if v["state"] in ("accepted", "executing") or v["at"] > now - 30 * 86400 + } + if len(value) > 10000: + raise ValueError("Journal capacity exhausted; no new actions may begin") + atomic_write_text(self.path, json.dumps(value, sort_keys=True), mode=0o600) + + def get(self, job_id): + with state_lock(self.path): + return self._load().get(job_id) + + def accept(self, job): + validate_job(job) + with state_lock(self.path): + value = self._load() + previous = value.get(job["job_id"]) + if previous is not None: + if previous["job"] != job: + raise ValueError("Job identity reused with different contract") + return previous + record = {"job": job, "state": "accepted", "result": None, "at": time.time(), "acknowledged": False} + value[job["job_id"]] = record + self._write(value) + return record + + def executing(self, job_id): + with state_lock(self.path): + value = self._load() + if value[job_id]["state"] != "accepted": + raise ValueError("Invalid local execution transition") + value[job_id]["state"] = "executing" + self._write(value) + + def finish(self, result): + validate_result(result) + with state_lock(self.path): + value = self._load() + value[result["job_id"]].update(state="result", result=result, at=time.time()) + self._write(value) + return result + + def pending_results(self): + with state_lock(self.path): + return [r["result"] for r in self._load().values() if r["state"] == "result" and not r["acknowledged"]] + + def acknowledge(self, job_id): + with state_lock(self.path): + value = self._load() + value[job_id]["acknowledged"] = True + self._write(value) diff --git a/src/agent/lifecycle.py b/src/agent/lifecycle.py new file mode 100644 index 00000000..2ba6797c --- /dev/null +++ b/src/agent/lifecycle.py @@ -0,0 +1,184 @@ +"""Fixed local lifecycle adapter: never remote selectors, command strings or hooks.""" + +import os +import time +import json +from pathlib import Path +import signal +import subprocess +import sys +from utils.state_io import state_lock, held_descriptor +from .contracts import validate_job, timestamp, utc_now +from .config import local_tag + + +class RuntimeDeadline(RuntimeError): + """The bounded local helper timed out; its effects cannot be confirmed.""" + + +class AlphaRuntime: + """Existing local runtime and Server methods under the existing CLI lock.""" + + operation_budget = 540 + + @staticmethod + def helper_command(tag, action): + command = [sys.executable] + if not getattr(sys, "frozen", False): + command.append(str(Path(__file__).resolve().parents[2] / "alphagsm")) + return command + ["--_agent-runtime", local_tag(tag), action] + + def _operation(self, tag, action, budget): + # A separate session contains the helper and blocking command children. + # Existing screen sessions and Docker containers are independently managed. + # A timeout reports unknown; it never retries the lifecycle operation. + descriptor = held_descriptor(self.lock_path(tag)) + command = self.helper_command(tag, action) + if descriptor is not None: + command += [str(descriptor)] + with subprocess.Popen(command, pass_fds=() if descriptor is None else (descriptor,), stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, start_new_session=True) as process: + try: + output, _ = process.communicate(timeout=budget) + except subprocess.TimeoutExpired as exc: + os.killpg(process.pid, signal.SIGKILL) + process.communicate(timeout=5) + raise RuntimeDeadline("Local runtime deadline") from exc + if process.returncode or len(output) > 1024: + raise RuntimeError("Local runtime helper failed") + return json.loads(output) + + @staticmethod + def lock_path(tag): + from server.server import DATAPATH + + return os.path.join(DATAPATH, local_tag(tag) + ".json") + + def lock(self, tag): + return state_lock(self.lock_path(tag)) + + def state(self, tag): + value = self._operation(tag, "status", 20) + if value not in ("running", "stopped"): + raise ValueError("Invalid runtime state") + return value + + @staticmethod + def metadata(tag, approved): + from server.server import Server + + server = Server(local_tag(tag)) + selected = dict(approved) + name = server.data.get("servername") + capacity = server.data.get("maxplayers") + if isinstance(name, str): + selected["name"] = name + if capacity is not None: + try: + selected["capacity"] = int(capacity) + except (ValueError, TypeError): + pass + # Advertised host/port remain an explicit local publication choice. + # Internal bind addresses and module-local query hooks cannot replace it. + return selected + + def execute(self, tag, action): + if action not in ("start", "stop", "restart"): + raise ValueError("Disallowed lifecycle action") + self._operation(tag, action, self.operation_budget) + + +class Executor: + def __init__(self, config, journal, runtime, authorize, *, lock=None, clock=time.time): + self.config, self.journal, self.runtime, self.authorize = config, journal, runtime, authorize + self.lock, self.clock = lock or runtime.lock, clock + + def _result(self, job, outcome, state, category): + return self.journal.finish( + { + "schema_version": 1, + "job_id": job["job_id"], + "installation_id": job["installation_id"], + "outcome": outcome, + "runtime_state": state, + "category": category, + "observed_at": utc_now(), + } + ) + + def _approved(self, job, cfg): + binding = cfg["bindings"].get(job["binding_id"]) + if not cfg["management_enabled"] or not cfg["management"] or binding is None: + return None + matches = ( + cfg["installation_id"] == job["installation_id"], + cfg["credential_generation"] == job["credential_generation"], + cfg["policy_version"] == job["policy_version"], + binding["epoch"] == job["binding_epoch"], + job["action"] in binding["actions"], + ) + if not all(matches): + return None + local_tag(binding["tag"]) + return binding + + def run(self, job): + validate_job(job) + cfg = self.config.load() + # Foreign jobs must never contaminate this installation's journal/results. + if job["installation_id"] != cfg["installation_id"]: + raise ValueError("Wrong installation") + record = self.journal.accept(job) + if record["result"] is not None: + return record["result"] + binding = self._approved(job, cfg) + if record["state"] == "executing": + state = "unknown" + if binding: + try: + with self.lock(binding["tag"]): + state = self.runtime.state(binding["tag"]) + except (OSError, ValueError, RuntimeError): + pass + return self._result(job, "unknown", state, "interrupted") + if binding is None: + return self._result(job, "failed", "unknown", "denied") + if timestamp(job["issued_at"]) > self.clock() + 30 or timestamp(job["expires_at"]) <= self.clock(): + return self._result(job, "failed", "unknown", "expired") + try: + # Server lock precedes state loading. The config lock serializes local + # policy changes with the final authorization and lifecycle operation. + with self.lock(binding["tag"]), state_lock(self.config.path): + cfg = self.config.load() + binding = self._approved(job, cfg) + if binding is None or timestamp(job["expires_at"]) <= self.clock(): + return self._result(job, "failed", "unknown", "denied") + try: + allowed = self.authorize(job) + except (OSError, ValueError): + allowed = False + if allowed is not True: + return self._result(job, "failed", "unknown", "denied") + self.journal.executing(job["job_id"]) + state = self.runtime.state(binding["tag"]) + action = job["action"] + if action == "restart" and state != "running": + return self._result(job, "failed", state, "precondition") + if action != "status" and not ( + (action == "start" and state == "running") or (action == "stop" and state == "stopped") + ): + self.runtime.execute(binding["tag"], action) + state = self.runtime.state(binding["tag"]) + expected = "stopped" if action == "stop" else "running" + if action != "status" and state != expected: + return self._result(job, "failed", state, "execution_failed") + return self._result(job, "succeeded", state, "ok") + except RuntimeDeadline: + return self._result(job, "unknown", "unknown", "budget_exceeded") + except Exception: # trusted runtime may raise module-specific exceptions + return self._result( + job, + "unknown" if self.journal.get(job["job_id"])["state"] == "executing" else "failed", + "unknown", + "execution_failed", + ) diff --git a/src/agent/local_query.py b/src/agent/local_query.py new file mode 100644 index 00000000..849810d9 --- /dev/null +++ b/src/agent/local_query.py @@ -0,0 +1,70 @@ +"""Local anonymous status query. The result is not sent to Sector Alpha.""" + +from server.public_query import protocol_for, query_public +from utils.query import QueryError, ts3_serverinfo + +from .config import ConfigError, local_tag + + +def observe_local(server, game_id, *, timeout=5.0): + """Read one local server with the same anonymous protocol the website uses.""" + protocol = protocol_for(game_id) + if protocol is None: + raise QueryError("This game has no anonymous status query") + host, port = _endpoint(server, protocol) + if protocol == "ts3": + info = ts3_serverinfo(host, port, timeout=timeout) + players, capacity = info["clients_online"], info["max_clients"] + if not _player_count(players) or not _player_count(capacity): + raise QueryError("TeamSpeak returned invalid player counts") + return { + "name": info["name"], + "players": players, + "capacity": max(capacity, players), + "version": info["version"], + } + return query_public(game_id, host, port, timeout=timeout) + + +def _player_count(value): + return isinstance(value, int) and not isinstance(value, bool) and value >= 0 + + +def query_tag(cfg, tag, *, timeout=5.0, open_server=None): + """Print a local status query for one bound tag. Nothing is uploaded.""" + tag = local_tag(tag) + matches = [(key, item) for key, item in cfg.load()["bindings"].items() if item["tag"] == tag] + if len(matches) != 1: + raise ConfigError("Bind this exact local server tag before querying it") + _binding_id, binding = matches[0] + game_id = binding["metadata"]["game_id"] + if open_server is None: + from server.server import Server + + open_server = Server + observation = observe_local(open_server(tag), game_id, timeout=timeout) + print("Local query for " + tag + " (" + game_id + "). This result is not sent to Sector Alpha.") + print("name=" + observation.get("name", "")) + print("players=" + str(observation["players"]) + "/" + str(observation["capacity"])) + if observation.get("map"): + print("map=" + observation["map"]) + if observation.get("version"): + print("version=" + observation["version"]) + return observation + + +def _endpoint(server, protocol): + module = server.module + for name in ("get_info_address", "get_query_address"): + hook = getattr(module, name, None) + if not callable(hook): + continue + host, port, hooked = hook(server) + if hooked == protocol: + return str(host), int(port) + if protocol == "a2s": + import server.runtime as runtime_module + + port = server.data.get("queryport", server.data["port"]) + return str(runtime_module.resolve_query_host(server)), int(port) + raise QueryError("This game has no anonymous status query") diff --git a/src/agent/name_proof.py b/src/agent/name_proof.py new file mode 100644 index 00000000..49664c77 --- /dev/null +++ b/src/agent/name_proof.py @@ -0,0 +1,81 @@ +"""Show an ownership token on TeamSpeak, then ask Sector Alpha to query it.""" + +from .config import ConfigError +from .transport import QueryBusy, Transport, TransportError +from . import prove + + +def prove_binding(cfg, tag, binding_id, binding, token, transport=None): + """Place one token, wait for the site query, and restore the previous name.""" + if binding["metadata"]["game_id"] != "ts3server": + raise ConfigError("Name proof is available for TeamSpeak 3") + value = cfg.load() + if not value["reporting_enabled"] or not value["reporting"]: + raise ConfigError("Reporting is disabled; use enable-reporting first") + from server.server import Server + from utils.query import ts3_serverinfo, ts3_set_name + + server = Server(tag) + credentials = server.module.get_query_credentials(server) + if not credentials: + raise ConfigError("TeamSpeak ServerQuery login is not available on this server") + query_port = int(server.data.get("queryport") or binding["metadata"]["port"]) + address = ("127.0.0.1", query_port) + + def read_name(): + return ts3_serverinfo(address[0], address[1], login=credentials)["name"] + + def write_name(name): + ts3_set_name(address[0], address[1], name, login=credentials) + + client = (transport or Transport)(value["origin"]) + + def request(): + try: + return client.request( + "POST", + "/v1/agent/verify", + credential=value["reporting"], + payload={"binding_id": binding_id}, + timeout=20, + ) + except QueryBusy as exc: + raise prove.QueryBusy(str(exc)) from None + + def poll(job_id): + return client.request( + "GET", + "/v1/agent/verify/" + job_id, + credential=value["reporting"], + timeout=20, + ) + + return prove.prove_name( + tag, + token, + pending_path=cfg.root / "prove-name.json", + read_name=read_name, + write_name=write_name, + request=request, + poll=poll, + ) + + +def fetch_challenge(transport, value, binding_id): + """Ask the associated agent API for one ownership token. A miss returns None.""" + if not value.get("reporting"): + return None + try: + response = transport(value["origin"]).request( + "POST", + "/v1/agent/challenge", + credential=value["reporting"], + payload={"binding_id": binding_id}, + timeout=20, + ) + except (TransportError, OSError, ValueError, RuntimeError): + return None + token = response.get("challenge") if isinstance(response, dict) else None + if isinstance(token, str) and prove.TOKEN.fullmatch(token): + return token + return None diff --git a/src/agent/network.py b/src/agent/network.py new file mode 100644 index 00000000..505acdb4 --- /dev/null +++ b/src/agent/network.py @@ -0,0 +1,106 @@ +"""Bounded public-address suggestions without credentials, proxies or redirects.""" + +import ipaddress +import queue +import socket +import ssl +import threading +import time +import urllib.request + +from .transport import NoRedirect +from .http_deadline import DeadlineBudget, DeadlineHTTPSHandler + +LOOKUP_URL = "https://api.ipify.org" +LOOKUP_TIMEOUT = 5 +LOOKUP_BYTES = 64 +_LOOKUP_LOCK = threading.Lock() + + +def global_ip(value): + """Accept a numerical, globally routable unicast address only.""" + if not isinstance(value, str) or "%" in value: + return None + try: + address = ipaddress.ip_address(value) + if address.is_global and not address.is_multicast and not address.is_reserved: + return str(address) + except ValueError: + pass + return None + + +def routing_ip(): + """Inspect the outbound route without sending application data.""" + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as route: + route.settimeout(1) + route.connect(("1.1.1.1", 80)) + return global_ip(route.getsockname()[0]) + except OSError: + return None + + +class _LookupBudget(DeadlineBudget): + def __init__(self): + super().__init__(LOOKUP_TIMEOUT, family=socket.AF_INET) + + +def _lookup_exchange(budget): + opener = urllib.request.build_opener( + urllib.request.ProxyHandler({}), NoRedirect(), DeadlineHTTPSHandler(budget), + ) + request = urllib.request.Request(LOOKUP_URL, headers={"Accept": "text/plain"}, method="GET") + with opener.open(request, timeout=budget.remaining()) as response: + if response.geturl() != LOOKUP_URL: + return None + parts = bytearray() + while response.fp is not None: + response.fp.raw._sock.settimeout(budget.remaining()) # pylint: disable=protected-access + chunk = response.read1(LOOKUP_BYTES + 1 - len(parts)) + parts.extend(chunk) + if len(parts) > LOOKUP_BYTES: + return None + if not chunk: + break + value = global_ip(parts.decode("ascii").strip()) + return value if value is not None and ipaddress.ip_address(value).version == 4 else None + + +def lookup_ip(): + """Bound all phases, close active I/O at expiry, and return no offline hint.""" + # At most one fallback worker may exist, even if system DNS is stuck across + # repeated report renewals. Explicit IP expectations remain usable. + if not _LOOKUP_LOCK.acquire(blocking=False): + return None + budget = _LookupBudget() + result = queue.Queue(maxsize=1) + + def exchange(): + try: + result.put(_lookup_exchange(budget)) + except Exception: # Cancellation may interrupt any urllib/TLS operation. + result.put(None) + finally: + _LOOKUP_LOCK.release() + + worker = threading.Thread(target=exchange, daemon=True, name="alphagsm-public-ip") + worker.start() + try: + return result.get(timeout=budget.remaining()) + except (queue.Empty, TimeoutError): + return None + finally: + budget.cancel() + # Active network I/O is now closed. A stalled system DNS resolver is the + # only worker that may linger, without sockets or permission to connect. + worker.join(timeout=max(0, budget.deadline - time.monotonic())) + + +def suggest_public_host(metadata): + """Suggestions never establish reachability or endpoint ownership.""" + for key in ("publicip", "externalip", "hostip"): + configured = global_ip(metadata.get(key)) + if configured: + return configured + return global_ip(routing_ip()) or lookup_ip() diff --git a/src/agent/onboarding.py b/src/agent/onboarding.py new file mode 100644 index 00000000..8547c3d8 --- /dev/null +++ b/src/agent/onboarding.py @@ -0,0 +1,356 @@ +"""Preview and approve exact local bindings before provider registration or saves.""" + +import getpass +import hashlib +import ipaddress +import re +import warnings + +from server.public_query import PROFILES +from utils.state_io import state_lock +from .lifecycle import AlphaRuntime +from .config import DEFAULT_ORIGIN, ConfigError, canonical_agent_origin +from .contracts import exact, identifier, integer, validate_metadata +from .choices import next_steps, permissions, require_capabilities +from .inventory import inventory, read_local +from .network import global_ip, suggest_public_host +from .publication_config import local_publication +from .binding_ports import selected_ports +from .transport import TransportError +from . import user_service + + +def enroll(cfg, api_origin, transport, *, bind_local=True): + api_origin = canonical_agent_origin(api_origin or DEFAULT_ORIGIN) + try: + with warnings.catch_warnings(): + warnings.simplefilter("error", getpass.GetPassWarning) + code = getpass.getpass("Single-use enrollment code: ").strip() + except getpass.GetPassWarning: + raise ConfigError("Enrollment requires a terminal that can hide the enrollment code") + if not code: + raise ConfigError("An enrollment code is required") + response = transport(api_origin).request("POST", "/v1/agent/enroll", payload={"code": code}) + cfg.enroll(api_origin, response) + if not bind_local: + print("Enrolled. Reporting and management remain disabled until you enable each locally.") + return + bind_enrolled(cfg, transport) + challenge_enrolled(cfg, transport) + print(user_service.install_hint()) + + +def bind_enrolled(cfg, transport): + """Register managed servers that already advertise a global public address.""" + value = cfg.load() + if value["reporting"]: + cfg.enable(reporting=True) + value = cfg.load() + print("Enrolled. Reporting is on. Management stays off.") + else: + print("Enrolled. This code did not include reporting. Management stays off.") + try: + locals_, failures = inventory() + except (OSError, ValueError, RuntimeError): + print("Local servers were not read. Enrollment is saved.") + return + for tag, reason in failures: + print("Skipped " + tag + ": " + reason) + if not locals_ and not failures: + print("No local servers to bind.") + return + for local in locals_: + tag = local["tag"] + try: + address = next( + (global_ip(local[key]) for key in ("publicip", "externalip", "hostip") if global_ip(local[key])), + None, + ) + if address is None or local["port"] is None: + print("Skipped " + tag + ": alphagsm agent bind " + tag + " --host HOST") + continue + if len(value["bindings"]) >= 100 and not any( + item["tag"] == tag for item in value["bindings"].values() + ): + print("Skipped " + tag + ": At most 100 local bindings are allowed") + continue + metadata = { + "game_id": local["game_id"], + "host": address, + "port": local["port"], + "name": local["name"], + "capacity": local["capacity"], + } + if local["connect_port"] is not None: + metadata["connect_port"] = local["connect_port"] + validate_metadata({ + "binding_id": "pending", + "binding_epoch": 1, + "publication": False, + "allowed_actions": [], + "runtime_state": "unknown", + **metadata, + }) + if not value["reporting"] and not value["management"]: + print("Skipped " + tag + ": Enrollment has no registration credential") + continue + binding_id, epoch = register(transport, value, tag) + cfg.bind(binding_id, tag, epoch, actions=[], publication=False, metadata=metadata) + value = cfg.load() + print("Bound " + tag + " (" + binding_id + ").") + except (TransportError, ConfigError, OSError, ValueError, RuntimeError): + print("Failed " + tag + ": alphagsm agent bind " + tag + " --host HOST") + + +def challenge_enrolled(cfg, transport): + """Send the first report, then start ownership checks the site query must confirm.""" + from contextlib import nullcontext + + from . import prove + from .name_proof import fetch_challenge, prove_binding + from .preflight import preflight_report + from .reporting import report_snapshot + + value = cfg.load() + if not value["bindings"] or not value["reporting"]: + return + + class Quiet: + def lock(self, tag): + return nullcontext() + + def state(self, tag): + return "unknown" + + def metadata(self, tag, approved): + return approved + + try: + payload = report_snapshot(cfg, Quiet()) + payload, _failures = preflight_report(payload, cfg.load()) + transport(value["origin"]).request( + "POST", + "/v1/agent/reports", + credential=value["reporting"], + payload=payload, + ) + except (TransportError, ConfigError, OSError, ValueError, RuntimeError): + print("The first report did not finish. Keep alphagsm agent run in the foreground, then retry the ownership check.") + return + value = cfg.load() + for binding_id, item in value["bindings"].items(): + tag = item["tag"] + try: + token = fetch_challenge(transport, value, binding_id) + if token is None: + print("Ownership check for " + tag + " did not start. Sector Alpha verifies control by querying the public name.") + continue + if item["metadata"]["game_id"] == "ts3server": + state = prove_binding(cfg, tag, binding_id, item, token, transport) + if state.get("verified") is True: + print("Control verified for " + tag + ". Sector Alpha queried the server and the previous name was restored.") + else: + print("The name check for " + tag + " did not verify. Sector Alpha queried the server and the previous name was restored.") + continue + print("Ownership challenge for " + tag + ": " + token) + print("Put that text in the public name the status query returns. Sector Alpha verifies it by querying the server.") + except (TransportError, ConfigError, OSError, ValueError, RuntimeError, prove.QueryBusy): + print("Ownership check for " + tag + " did not finish. Sector Alpha verifies control by querying the public name.") + + +def host(value): + """Validate an explicit advertised numerical address or DNS name.""" + if not isinstance(value, str) or not value or len(value) > 253 or "%" in value: + raise ConfigError("Enter a public host or use --host") + try: + address = ipaddress.ip_address(value) + except ValueError: + if not re.fullmatch(r"(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?", value): + raise ConfigError("Enter a public host or use --host") + if any(not label or len(label) > 63 or label.startswith('-') or label.endswith('-') + for label in value.split('.')) or '.' not in value: + raise ConfigError("Enter a public DNS name or numerical address") + return value + if not address.is_global or address.is_multicast or address.is_reserved: + raise ConfigError("Advertised address must be globally routable") + return str(address) + + +def endpoint(value, default_host, default_port): + if value: + if value.startswith("["): + address, separator, port = value[1:].partition("]:") + if not separator: + raise ConfigError("Use [IPv6]:port for an IPv6 endpoint") + else: + address, separator, port = value.rpartition(":") + if not separator: + raise ConfigError("Use host:port for the advertised endpoint") + default_host, default_port = address, int(port) + integer(default_port, 1, 65535) + return host(default_host), default_port + + +def candidate(local, value, options, suggest=suggest_public_host): + matches = [(key, item) for key, item in value['bindings'].items() if item['tag'] == local['tag']] + binding_id, previous = matches[0] if matches else (None, None) + if options.binding_id is not None and binding_id is not None and options.binding_id != binding_id: + raise ConfigError("Reuse the existing binding ID for this local server") + binding_id = binding_id or options.binding_id + approved = previous['metadata'] if previous else {} + if options.game is not None and options.game != local['game_id']: + raise ConfigError("Selected game does not match the local server module") + game = local['game_id'] + address = options.host if options.host is not None else approved.get('host') + if address is None: + address = suggest(local) + ports = selected_ports(local, approved, options) + port = ports['port'] + if port is None: + raise ConfigError("No valid configured port; set a local port or use --port") + label = ("[" + address + "]" if address and ':' in address else address) if address else "" + response = input("Confirm public host:port for " + local['tag'] + " [" + label + ":" + str(port) + + "] (query endpoint): ").strip() + address, port = endpoint(response, address, port) + ports = selected_ports(local, approved, options, query_port=port) + publication, actions = permissions(game, previous, value, options) + metadata = {"game_id": game, "host": address, **ports, + "name": options.name if options.name is not None else approved.get('name', local['name']), + "capacity": options.capacity if options.capacity is not None else approved.get('capacity', local['capacity'])} + epoch = previous['epoch'] if previous else options.epoch + validate_metadata({'binding_id': binding_id or 'pending', 'binding_epoch': epoch, 'publication': publication, + 'allowed_actions': actions, 'runtime_state': 'unknown', **metadata}) + return {'tag': local['tag'], 'binding_id': binding_id, 'epoch': epoch, 'actions': actions, + 'publication': publication, 'metadata': metadata, **local_publication(options.public_ip, previous)} + + +def register(transport, value, tag): + scope = 'reporting' if value['reporting'] else 'management' + if not value[scope]: + raise ConfigError("Enrollment has no registration credential") + response = transport(value['origin']).request('POST', '/v1/agent/bindings', credential=value[scope], + payload={'scope': scope, 'binding_ref': hashlib.sha256(tag.encode()).hexdigest()}) + exact(response, ('schema_version', 'binding_id', 'binding_epoch')) + integer(response['schema_version'], 1, 1) + identifier(response['binding_id']) + integer(response['binding_epoch']) + return response['binding_id'], response['binding_epoch'] + + +def guided(cfg, options, transport): + value = cfg.load() + local_publication(options.public_ip) + require_capabilities(value, options.publish is True, options.actions or []) + if options.connect_port is not None: + integer(options.connect_port, 1, 65535) + if options.host is not None: + host(options.host) + if options.binding_id is not None: + identifier(options.binding_id) + if options.origin is not None: + selected_origin = canonical_agent_origin(options.origin) + if value['origin'] is not None and selected_origin != value['origin']: + raise ConfigError("Existing enrollment uses a different origin") + bulk = options.command == 'setup' or options.target == '*' + if bulk and options.binding_id is not None: + raise ConfigError("Select one local tag when supplying a binding ID") + if bulk: + locals_, failures = inventory() + else: + locals_, failures = [read_local(options.target)], [] + if bulk and (options.port is not None or options.connect_port is not None) and len(locals_) > 1: + raise ConfigError("Please bind one tag when overriding --port or --connect-port") + for tag, reason in failures: + print("Skipped " + tag + ": " + reason) + if not locals_: + raise ConfigError("No eligible local servers found") + if len(locals_) + len(value['bindings']) - sum( + any(item['tag'] == local['tag'] for item in value['bindings'].values()) for local in locals_) > 100: + raise ConfigError("At most 100 local bindings are allowed") + print("Network addresses are suggestions; confirm NAT/public ports. Ownership still requires the central challenge.") + cached_host = [] + def suggest(local): + configured = next((global_ip(local[key]) for key in ('publicip', 'externalip', 'hostip') + if global_ip(local[key])), None) + if configured: + return configured + if not cached_host: + cached_host.append(suggest_public_host(local)) + return cached_host[0] + candidates = [] + for local in locals_: + print("Local server: " + local['tag'] + " | " + local['name'] + " | " + local['game_id'] + + " | player port=" + str(local['connect_port'] or 'unknown') + " | query port=" + str(local['port']) + + " | capacity=" + str(local['capacity'])) + existing = any(item['tag'] == local['tag'] for item in value['bindings'].values()) + if bulk and options.port is None and local['port'] is None and not existing: + failures.append((local['tag'], 'No valid configured port; set a local port or use --port')) + print("Skipped " + local['tag'] + ": " + failures[-1][1]) + continue + candidates.append(candidate(local, value, options, suggest)) + if not candidates: + raise ConfigError("No configured local servers selected") + for item in candidates: + metadata = item['metadata'] + print("Preview: " + item['tag'] + " -> " + metadata['host'] + ":" + str(metadata['port']) + + " | player port=" + str(metadata['connect_port'] or 'unknown') + " | query port=" + str(metadata['port']) + + " | game=" + metadata["game_id"] + " | name=" + metadata["name"] + + " | capacity=" + str(metadata["capacity"]) + " | publication=" + ('yes' if item['publication'] else 'no') + + " | actions=" + (','.join(item['actions']) or 'none') + + " | binding=" + (item['binding_id'] or 'pending provider registration') + + " | epoch=" + (str(item['epoch']) if item['binding_id'] else 'pending') + + " | machine IPs=" + (','.join(item.get('expected_public_ips', [])) or 'automatic')) + if input("Save these exact bindings? [no]: ").strip().lower() not in ('y', 'yes'): + print("Cancelled. Local bindings are unchanged.") + return 1 + for item in candidates: + if read_local(item['tag'])['game_id'] != item['metadata']['game_id']: + raise ConfigError("Local game changed during preview; run the command again") + with state_lock(cfg.path): + unchanged(cfg, value) + if value['installation_id'] is None: + enroll(cfg, options.origin, transport, bind_local=False) + value = cfg.load() + for item in candidates: + require_capabilities(value, item['publication'], item['actions']) + failed = save_candidates(cfg, value, candidates, transport) or bool(failures) + next_steps(value) + print(user_service.install_hint()) + return 1 if failed else 0 + + +def unchanged(cfg, snapshot): + current = cfg.load() + fields = ('origin', 'installation_id', 'credential_generation', 'reporting', 'management', + 'reporting_enabled', 'management_enabled', 'policy_version', 'bindings') + if any(current[key] != snapshot[key] for key in fields): + raise ConfigError("Local policy changed during preview; run the command again") + + +def save_candidates(cfg, value, candidates, transport): + failed = False + for item in candidates: + try: + # Match the lifecycle lock order: exact local target, then agent policy. + with AlphaRuntime().lock(item['tag']), state_lock(cfg.path): + unchanged(cfg, value) + if read_local(item['tag'])['game_id'] != item['metadata']['game_id']: + raise ConfigError("Local game changed after preview") + binding_id, epoch = item['binding_id'], item['epoch'] + if binding_id is None: + binding_id, epoch = register(transport, value, item['tag']) + previous = value['bindings'].get(binding_id) + if previous is not None and previous['tag'] != item['tag']: + raise ConfigError('Provider binding ID is already assigned to another local server') + planned = {'tag': item['tag'], 'epoch': epoch, 'actions': item['actions'], + 'publication': item['publication'], 'metadata': item['metadata'], + **local_publication(item.get('expected_public_ips'), previous)} + if previous != planned: + cfg.bind(binding_id, item['tag'], epoch, actions=item['actions'], publication=item['publication'], + metadata=item['metadata'], **local_publication(item.get('expected_public_ips'), previous)) + value = cfg.load() + print("Bound " + item['tag'] + " (" + binding_id + ").") + except (OSError, ValueError, RuntimeError): + failed = True + print("Failed " + item['tag'] + ": provider registration, local policy or local game changed; retry safely.") + return failed diff --git a/src/agent/preflight.py b/src/agent/preflight.py new file mode 100644 index 00000000..3bdb5cae --- /dev/null +++ b/src/agent/preflight.py @@ -0,0 +1,141 @@ +"""Withhold public hostnames whose complete DNS answers do not match this machine.""" + +from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError as FutureTimeout +from server.public_query import PROFILES +import ipaddress +import time + +from .dns_lookup import DNS_TIMEOUT, MAX_DNS_ADDRESSES, PreflightError, resolve_host +from .inventory import read_local +from .network import suggest_public_host +from .publication_config import normalize_public_ips, public_ip + +BATCH_TIMEOUT = 30.0 +DNS_WORKERS = 8 + + +def numerical(host): + try: + ipaddress.ip_address(host) + return True + except ValueError: + return False + + +def expected_addresses(binding, cache, *, read, discover): + if 'expected_public_ips' in binding: + return set(normalize_public_ips(binding['expected_public_ips'])) + try: + local = read(binding['tag']) + except (OSError, ValueError, RuntimeError): + local = {} + selected = {public_ip(local.get(key)) for key in ('publicip', 'externalip', 'hostip')} - {None} + if selected: + return selected + if not cache: + cache.append(public_ip(discover({}))) + if cache[0] is None: + raise PreflightError("Public machine IP unavailable; retry or bind with --public-ip for NAT/multiple IPs") + return {cache[0]} + + +def matched_addresses(answers, expected): + if not isinstance(answers, (list, tuple, set, frozenset)) or not 1 <= len(answers) <= MAX_DNS_ADDRESSES: + raise PreflightError("DNS returned no usable complete address set; check A and AAAA records") + selected = {public_ip(address) for address in answers} + if None in selected: + raise PreflightError("DNS includes a non-global address; only public A and AAAA addresses are allowed") + if not selected <= expected: + raise PreflightError("DNS does not match this machine; fix A/AAAA records or set --public-ip for its public IPs") + return sorted(selected) + + +def current_binding(item, value): + binding = value['bindings'].get(item['binding_id']) + if (not value['reporting_enabled'] or binding is None or not binding['publication'] + or binding['epoch'] != item['binding_epoch'] + or any((key in binding['metadata'], binding['metadata'].get(key)) != (key in item, item.get(key)) + for key in ('game_id', 'host', 'port', 'connect_port'))): + raise PreflightError("Local publication approval or endpoint changed; retry with the next report") + if item['game_id'] not in PROFILES: + raise PreflightError("This game has no supported public query profile; private binding remains available") + return binding + + +def preflight_report(payload, value, *, resolve=None, discover=None, read=None): + """Check fresh DNS each renewal, changing only outgoing publication flags.""" + resolve = resolve or resolve_host + discover = discover or suggest_public_host + read = read or read_local + outgoing = {**payload, 'bindings': [dict(item) for item in payload['bindings']]} + failures, candidates, cache = {}, {}, [] + deadline = time.monotonic() + BATCH_TIMEOUT + for item in outgoing['bindings']: + if not item['publication'] or numerical(item['host']): + continue + binding_id = item['binding_id'] + try: + binding = current_binding(item, value) + expected = expected_addresses(binding, cache, read=read, discover=discover) + candidates[binding_id] = (item, expected) + except PreflightError as exc: + item['publication'] = False + failures[binding_id] = str(exc) + except (OSError, ValueError, RuntimeError): + item['publication'] = False + failures[binding_id] = 'Public machine IP unavailable or invalid; use --public-ip with its global IPs' + hosts = sorted({item['host'] for item, expected in candidates.values()}) + offset = ((payload['sequence'] - 1) * DNS_WORKERS) % max(1, len(hosts)) + hosts = hosts[offset:] + hosts[:offset] + answers = {} + + def fresh(host): + remaining = min(DNS_TIMEOUT, deadline - time.monotonic()) + if remaining <= 0: + raise PreflightError("DNS batch budget exceeded; retry publication") + return resolve(host, remaining) + + if hosts: + # dnspython lifetimes bound running tasks, so executor shutdown cannot + # retain system getaddrinfo calls or accumulate uncancellable workers. + with ThreadPoolExecutor(max_workers=DNS_WORKERS, thread_name_prefix='alphagsm-dns') as pool: + pending = {pool.submit(fresh, host): host for host in hosts} + try: + for future in as_completed(pending, timeout=max(0, deadline - time.monotonic())): + host = pending[future] + try: + answers[host] = future.result() + except (OSError, ValueError, RuntimeError): + answers[host] = None + except FutureTimeout: + for future in pending: + future.cancel() + for binding_id, (item, expected) in candidates.items(): + try: + addresses = matched_addresses(answers.get(item['host']), expected) + if PROFILES.get(item['game_id']) == 'a2s' and any( + ipaddress.ip_address(address).version != 4 for address in addresses): + raise PreflightError('The A2S public profile supports IPv4 only; check the hostname AAAA record') + except (OSError, ValueError, RuntimeError) as exc: + item['publication'] = False + failures[binding_id] = str(exc) + return outgoing, failures + + +def final_report(payload, checked, current): + """No network: withdraw stale approvals immediately before the locked POST.""" + outgoing = {**payload, 'bindings': [dict(item) for item in payload['bindings']]} + failures = {} + for item in outgoing['bindings']: + if not item['publication']: + continue + binding_id = item['binding_id'] + try: + binding = current_binding(item, current) + previous = checked['bindings'].get(binding_id) + if previous is None or binding.get('expected_public_ips') != previous.get('expected_public_ips'): + raise PreflightError("Expected machine IPs changed during DNS validation; retry publication") + except (OSError, ValueError, RuntimeError) as exc: + item['publication'] = False + failures[binding_id] = str(exc) + return outgoing, failures diff --git a/src/agent/prove.py b/src/agent/prove.py new file mode 100644 index 00000000..d7f40ea4 --- /dev/null +++ b/src/agent/prove.py @@ -0,0 +1,84 @@ +# pylint: disable=unidiomatic-typecheck +"""Place a website ownership token in the live TeamSpeak name, then restore it.""" + +import json +import os +import re +import time + +TOKEN = re.compile(r"SA-[A-Za-z0-9_-]{43}\Z") +NAME_LIMIT = 64 + + +class QueryBusy(Exception): + """Sector Alpha is still finishing another query for this server.""" + + +def temporary_name(current, token): + """Keep the current name when the token still fits; otherwise show only the token.""" + if type(current) is not str: + raise ValueError("TeamSpeak did not return a server name") + combined = (current + " " + token).strip() + if len(combined.encode()) <= NAME_LIMIT: + return combined + if len(token.encode()) <= NAME_LIMIT: + return token + raise ValueError("Ownership challenge does not fit in the TeamSpeak server name") + + +def prove_name( + tag, token, *, pending_path, read_name, write_name, request, poll, sleep=time.sleep, attempts=20, request_attempts=8 +): + """Swap the live name for one check. The previous name is restored on every exit.""" + if type(tag) is not str or not tag: + raise ValueError("Choose one local TeamSpeak server") + if not TOKEN.fullmatch(token or ""): + raise ValueError("Ownership challenge is not a current SA- token") + _recover(pending_path, tag, write_name) + current = read_name() + updated = temporary_name(current, token) + _write_pending(pending_path, tag, current) + write_name(updated) + try: + job = _request_when_idle(request, sleep, request_attempts) + state = {"state": "queued", "verified": False} + for _ in range(attempts): + state = poll(job["job_id"]) + if state.get("state") not in ("queued", "claimed"): + return state + sleep(3) + return state + finally: + write_name(current) + if pending_path.exists(): + pending_path.unlink() + + +def _request_when_idle(request, sleep, attempts): + error = QueryBusy("Sector Alpha is still querying this server") + for attempt in range(attempts): + try: + return request() + except QueryBusy as exc: + error = exc + if attempt + 1 == attempts: + break + sleep(2) + raise error + + +def _recover(path, tag, write_name): + if not path.exists(): + return + saved = json.loads(path.read_text(encoding="utf-8")) + if saved.get("tag") != tag or type(saved.get("name")) is not str: + raise ValueError("Finish the interrupted name proof for " + str(saved.get("tag") or "the other server")) + write_name(saved["name"]) + path.unlink() + + +def _write_pending(path, tag, name): + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + json.dump({"tag": tag, "name": name}, handle) + os.chmod(path, 0o600) diff --git a/src/agent/publication_config.py b/src/agent/publication_config.py new file mode 100644 index 00000000..899f1786 --- /dev/null +++ b/src/agent/publication_config.py @@ -0,0 +1,38 @@ +"""Optional local machine-address expectations, excluded from agent wire metadata.""" + +import ipaddress + +class PublicationConfigError(ValueError): + """Invalid local expected-machine address selection.""" + + +MAX_PUBLIC_IPS = 32 + + +def public_ip(value): + if not isinstance(value, str) or '%' in value: + return None + try: + parsed = ipaddress.ip_address(value) + except ValueError: + return None + if not parsed.is_global or parsed.is_multicast or parsed.is_reserved or parsed.is_unspecified: + return None + if isinstance(parsed, ipaddress.IPv6Address) and (parsed.ipv4_mapped or parsed.sixtofour or parsed.teredo): + return None + return str(parsed) + + +def normalize_public_ips(value): + if type(value) is not list or not 1 <= len(value) <= MAX_PUBLIC_IPS: # pylint: disable=unidiomatic-typecheck + raise PublicationConfigError("Use --public-ip with one or more global numerical machine IPs") + selected = [public_ip(address) for address in value] + if any(address is None for address in selected): + raise PublicationConfigError("Expected machine IPs must be globally routable numerical unicast addresses") + return sorted(set(selected)) + + +def local_publication(provided=None, previous=None): + if provided is None and previous is not None: + provided = previous.get('expected_public_ips') + return {} if provided is None else {'expected_public_ips': normalize_public_ips(provided)} diff --git a/src/agent/reporting.py b/src/agent/reporting.py new file mode 100644 index 00000000..984b2638 --- /dev/null +++ b/src/agent/reporting.py @@ -0,0 +1,55 @@ +"""Latest metadata reconciliation and durable sequence, with no console/query data.""" + +from .contracts import validate_report, validate_policy + + +def policy_snapshot(cfg): + value = { + "schema_version": 1, + "installation_id": cfg["installation_id"], + "policy_version": cfg["policy_version"], + "bindings": [ + { + "binding_id": id_, + "binding_epoch": b["epoch"], + "allowed_actions": b["actions"] if cfg["management_enabled"] else [], + } + for id_, b in cfg["bindings"].items() + ], + } + return validate_policy(value) + + +def report_snapshot(config, runtime): + cfg = config.sequence() + selected = [] + for binding_id, binding in cfg["bindings"].items(): + metadata = dict(binding["metadata"]) + state = "unknown" + try: + # Metadata is a local publication choice. Do not call module query, + # info, console, RCON, or datastores containing private fields. + with runtime.lock(binding["tag"]): + state = runtime.state(binding["tag"]) + metadata = runtime.metadata(binding["tag"], metadata) + except Exception: + pass + selected.append( + { + "binding_id": binding_id, + "binding_epoch": binding["epoch"], + "publication": binding["publication"] and cfg["reporting_enabled"], + "runtime_state": state, + "allowed_actions": binding["actions"] if cfg["management_enabled"] else [], + **metadata, + } + ) + return validate_report( + { + "schema_version": 1, + "installation_id": cfg["installation_id"], + "sequence": cfg["sequence"], + "policy_version": cfg["policy_version"], + "bindings": selected, + } + ) diff --git a/src/agent/runtime_helper.py b/src/agent/runtime_helper.py new file mode 100644 index 00000000..4cea5052 --- /dev/null +++ b/src/agent/runtime_helper.py @@ -0,0 +1,36 @@ +"""Internal bounded subprocess adapter for the fixed local runtime operations.""" + +from contextlib import redirect_stdout, redirect_stderr, nullcontext +import os +from utils.state_io import inherited_state_lock +import json +from .config import local_tag + + +class Discard: + def write(self, text): + return len(text) + + def flush(self): + pass + + +def main(args): + if len(args) not in (2, 3) or args[1] not in ("status", "start", "stop", "restart"): + return 2 + tag, action = local_tag(args[0]), args[1] + with redirect_stdout(Discard()), redirect_stderr(Discard()): + from server.server import Server, DATAPATH + from server.runtime import get_runtime + + lock = inherited_state_lock(os.path.join(DATAPATH, tag + ".json"), int(args[2])) if len(args) == 3 else nullcontext() + with lock: + server = Server(tag) + dispatch = {"start": Server.start, "stop": Server.stop, "restart": Server.restart} + if action == "status": + result = "running" if get_runtime(server).is_running(server) else "stopped" + else: + dispatch[action](server) + result = None + print(json.dumps(result)) + return 0 diff --git a/src/agent/service.py b/src/agent/service.py new file mode 100644 index 00000000..a79d87a0 --- /dev/null +++ b/src/agent/service.py @@ -0,0 +1,204 @@ +"""Explicit optional outbound service; reporting survives management outages.""" + +import secrets +import threading +import time +from utils.state_io import state_lock +from .journal import Journal +from .lifecycle import AlphaRuntime, Executor +from .reporting import policy_snapshot, report_snapshot +from .preflight import final_report, preflight_report +from .socket_channel import SocketChannel, SocketError +from .transport import AgentLimit, Transport, TransportError + + +class Service: # pylint: disable=too-many-instance-attributes + def __init__(self, config, *, runtime=None, transport=None, channel_factory=None): + self.config = config + self.runtime = runtime or AlphaRuntime() + cfg = config.load() + self.relationship = (cfg["origin"], cfg["installation_id"]) + self.transport = transport or Transport(cfg["origin"]) + self.journal = Journal(config.root / "journal.json") + self.session_id = secrets.token_hex(16) + self.stop = threading.Event() + if channel_factory is not None: + self.channel_factory = channel_factory + + def report_once(self): + cfg = self.config.load() + if not cfg["reporting"] or (cfg["origin"], cfg["installation_id"]) != self.relationship: + return None + payload = report_snapshot(self.config, self.runtime) + checked = self.config.load() + payload, failures = preflight_report(payload, checked) + # DNS is outside the policy lock. The final approval check and send are + # serialized with local consent, endpoint and credential mutations. + with state_lock(self.config.path): + current = self.config.load() + if (not current['reporting'] or (current['origin'], current['installation_id']) != self.relationship + or current['origin'] != checked['origin'] + or current['installation_id'] != payload['installation_id']): + return None + payload, changed = final_report(payload, checked, current) + failures.update(changed) + for binding_id, reason in failures.items(): + print("Publication withheld for " + binding_id + ": " + reason) + return self.transport.request("POST", "/v1/agent/reports", credential=current["reporting"], payload=payload) + + def management_once(self, *, wait=25): + cfg = self.config.load() + if not cfg["management_enabled"] or (cfg["origin"], cfg["installation_id"]) != self.relationship: + return None + credential = cfg["management"] + self.transport.request("POST", "/v1/agent/policy", credential=credential, payload=policy_snapshot(cfg)) + options = {"timeout": 35} + if wait == 0: + # A live socket must not also hold the 25-second poll. + options = {"timeout": 10, "query": "wait=0"} + response = self.transport.request( + "GET", "/v1/agent/jobs", credential=credential, session_id=self.session_id, **options + ) + # Poll establishes a session before sending any previously unacknowledged result. + for result in self.journal.pending_results(): + if result["installation_id"] != cfg["installation_id"]: + continue + self.transport.request( + "POST", "/v1/agent/results", credential=credential, payload=result, session_id=self.session_id + ) + self.journal.acknowledge(result["job_id"]) + job = response.get("job") + if job is None: + return None + + def authorize(value): + latest = self.config.load() + if (not latest["management_enabled"] + or (latest["origin"], latest["installation_id"]) != self.relationship): + return False + result = self.transport.request( + "POST", + "/v1/agent/jobs/" + value["job_id"] + "/authorize", + credential=latest["management"], + session_id=self.session_id, + ) + return result.get("authorized") is True + + result = Executor(self.config, self.journal, self.runtime, authorize).run(job) + self.transport.request( + "POST", "/v1/agent/results", credential=credential, payload=result, session_id=self.session_id + ) + self.journal.acknowledge(job["job_id"]) + return result + + def _report_loop(self): + next_report = 0 + last_dirty = 0 + failures = 0 + while not self.stop.wait(1): + cfg = self.config.load() + dirty = cfg["dirty_at"] + if cfg["reporting_enabled"] and ( + time.monotonic() >= next_report or (failures == 0 and dirty != last_dirty and time.time() >= dirty + 5) + ): + try: + self.report_once() + failures = 0 + last_dirty = dirty + next_report = time.monotonic() + 55 + secrets.randbelow(11) + except (TransportError, ValueError, TimeoutError): + failures = min(failures + 1, 6) + next_report = time.monotonic() + min(300, 2**failures) + secrets.randbelow(5) + # Lifecycle operations can hold the config lock longer than + # reporting's lock budget. Retry without ending the reporter. + # An outage never changes the result of a successful local CLI command. + + def _open_channel(self, cfg): + factory = getattr(self, "channel_factory", None) + if factory is not None: + return factory(cfg, self.session_id) + return SocketChannel(cfg["origin"], cfg["reporting"], self.session_id) + + def _connection_once(self): + cfg = self.config.load() + if (cfg["origin"], cfg["installation_id"]) != self.relationship: + return "idle" + if not cfg["reporting"]: + if cfg["management_enabled"]: + self.management_once() + return "rest" + return "idle" + try: + opened = self._open_channel(cfg) + except AgentLimit: + raise + except (OSError, TransportError, ValueError): + if cfg["management_enabled"]: + self.management_once() + return "fallback" + return "retry" + with opened as channel: + self._serve_channel(channel) + return "socket" + + def _serve_channel(self, channel): + worker = None + interval = getattr(self, "heartbeat_interval", 30) + next_beat = time.monotonic() + interval + try: + while not self.stop.is_set(): + if worker is not None and not worker.is_alive(): + worker = None + timeout = max(0.05, min(interval, next_beat - time.monotonic())) + frame = channel.recv(timeout) + # The frame only names a job. Fetching, local checks, authorize, and the result post stay on HTTPS. + if isinstance(frame, dict) and frame.get("type") == "request" and worker is None: + worker = threading.Thread(target=self._run_requested_job, name="alphagsm-agent-job", daemon=True) + worker.start() + elif isinstance(frame, dict) and frame.get("type") == "heartbeat": + next_beat = time.monotonic() + interval + if time.monotonic() >= next_beat and not self.stop.is_set(): + channel.send({"type": "heartbeat"}) + next_beat = time.monotonic() + interval + finally: + if worker is not None: + worker.join() + + def _run_requested_job(self): + try: + self.management_once(wait=0) + except (TransportError, ValueError, OSError): + pass + + def _management_loop(self): + failures = 0 + while not self.stop.is_set(): + try: + outcome = self._connection_once() + except AgentLimit: + failures = min(failures + 1, 6) + self.stop.wait(max(60, min(300, 2 ** failures)) + secrets.randbelow(5)) + continue + except (SocketError, TransportError, ValueError, OSError): + failures = min(failures + 1, 6) + self.stop.wait(min(300, 2 ** failures) + secrets.randbelow(5)) + continue + if outcome == "socket": + failures = 0 + self.stop.wait(1) + continue + if outcome in ("rest", "fallback"): + failures = 0 + continue + failures = min(failures + 1, 6) + self.stop.wait(5 if outcome == "idle" else min(300, 2 ** failures) + secrets.randbelow(5)) + + def run(self): + with state_lock(self.config.root / "service", timeout=0): + reporter = threading.Thread(target=self._report_loop, daemon=True) + reporter.start() + try: + self._management_loop() + finally: + self.stop.set() + reporter.join(timeout=40) diff --git a/src/agent/socket_channel.py b/src/agent/socket_channel.py new file mode 100644 index 00000000..4270f7e4 --- /dev/null +++ b/src/agent/socket_channel.py @@ -0,0 +1,239 @@ +# pylint: disable=unidiomatic-typecheck +"""One outbound WebSocket to the agent API. The game host does not listen.""" + +import base64 +import hashlib +import json +import os +import socket +import time +from urllib.parse import urlsplit + +from .config import canonical_agent_origin +from .tls import client_context +from .transport import AgentLimit, TransportError + +_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" +_MAX_FRAME = 65536 + + +class SocketError(OSError): + """The outbound socket failed before a command could run.""" + + +class SocketClosed(SocketError): + def __init__(self, code=1000): + super().__init__("WebSocket closed") + self.code = code + + +def _token(credential, session_id): + if type(credential) is not dict or type(session_id) is not str: + raise SocketError("Invalid agent credential") + key_id, secret = credential.get("key_id"), credential.get("secret") + if type(key_id) is not str or type(secret) is not str: + raise SocketError("Invalid agent credential") + token = key_id + "." + secret + if any(ord(char) <= 32 or ord(char) > 126 for char in token + session_id): + raise SocketError("Invalid agent credential") + return token + + +def _accept_value(nonce): + digest = hashlib.sha1((nonce + _GUID).encode("ascii"), usedforsecurity=False).digest() + return base64.b64encode(digest).decode("ascii") + + +def encode_frame(payload, opcode=0x1): + if type(payload) is not bytes: + payload = json.dumps(payload, separators=(",", ":")).encode() + if len(payload) > _MAX_FRAME: + raise SocketError("WebSocket frame exceeds byte limit") + header = bytearray((0x80 | opcode,)) + length = len(payload) + if length < 126: + header.append(0x80 | length) + else: + header.append(0x80 | 126) + header.extend(length.to_bytes(2, "big")) + mask = os.urandom(4) + header.extend(mask) + header.extend(byte ^ mask[index % 4] for index, byte in enumerate(payload)) + return bytes(header) + + +def _pop_frame(buffer): + if len(buffer) < 2: + return None + first, second = buffer[0], buffer[1] + if second & 0x80: + raise SocketError("Server frame was masked") + length = second & 0x7F + offset = 2 + if length == 126: + if len(buffer) < 4: + return None + length = int.from_bytes(buffer[2:4], "big") + offset = 4 + elif length == 127: + raise SocketError("WebSocket frame exceeds byte limit") + if length > _MAX_FRAME or len(buffer) < offset + length: + if length > _MAX_FRAME: + raise SocketError("WebSocket frame exceeds byte limit") + return None + if not first & 0x80: + raise SocketError("Fragmented WebSocket frames are not accepted") + payload = bytes(buffer[offset:offset + length]) + del buffer[:offset + length] + return first & 0x0F, payload + + +class SocketChannel: + def __init__(self, origin, credential, session_id, *, context=None, connector=None): + selected = canonical_agent_origin(origin) + parsed = urlsplit(selected) + self.endpoint = (parsed.hostname, parsed.port or 443) + self.credential = credential + self.session_id = session_id + self.context = context + self.connector = connector + self._sock = None + self._buffer = bytearray() + + def __enter__(self): + self._sock = self._open() + try: + self._handshake() + except Exception: + self.close() + raise + return self + + def __exit__(self, exc_type, exc, traceback): + self.close() + return False + + def _open(self): + host, port = self.endpoint + if self.connector is not None: + return self.connector() + raw = socket.create_connection((host, port), timeout=10) + context = self.context or client_context() + try: + return context.wrap_socket(raw, server_hostname=host) + except Exception: + raw.close() + raise + + def _handshake(self): + host, port = self.endpoint + token = _token(self.credential, self.session_id) + nonce = base64.b64encode(os.urandom(16)).decode("ascii") + authority = "[" + host + "]" if ":" in host else host + if port != 443: + authority = authority + ":" + str(port) + request = "\r\n".join(( + "GET /v1/agent/session HTTP/1.1", + "Host: " + authority, + "Upgrade: websocket", + "Connection: Upgrade", + "Sec-WebSocket-Key: " + nonce, + "Sec-WebSocket-Version: 13", + "Authorization: Bearer " + token, + "X-Agent-Session: " + self.session_id, + "", + "", + )) + self._sock.sendall(request.encode("ascii")) + head, extra = self._read_head() + lines = head.split("\r\n") + if len(lines[0].split(" ")) < 2 or lines[0].split(" ")[1] != "101": + raise SocketError("WebSocket handshake rejected") + headers = {} + for line in lines[1:]: + if ":" not in line: + continue + name, value = line.split(":", 1) + headers[name.strip().lower()] = value.strip() + if headers.get("sec-websocket-accept") != _accept_value(nonce): + raise SocketError("WebSocket handshake rejected") + self._buffer.extend(extra) + + def _read_head(self): + data = bytearray() + while b"\r\n\r\n" not in data: + if len(data) > 8192: + raise SocketError("WebSocket handshake exceeds byte limit") + chunk = self._sock.recv(1024) + if not chunk: + raise SocketError("WebSocket handshake failed") + data.extend(chunk) + head, _, extra = bytes(data).partition(b"\r\n\r\n") + return head.decode("ascii", "replace"), extra + + def send(self, payload): + if self._sock is None: + raise SocketError("WebSocket closed") + self._sock.sendall(encode_frame(payload)) + + def recv(self, timeout): + deadline = time.monotonic() + timeout + while True: + message = self._next_message() + if message is not None: + return message + remaining = deadline - time.monotonic() + if remaining <= 0: + return None + self._sock.settimeout(remaining) + try: + chunk = self._sock.recv(4096) + except socket.timeout: + return None + if not chunk: + raise SocketClosed(1006) + self._buffer.extend(chunk) + + def _next_message(self): + while True: + frame = _pop_frame(self._buffer) + if frame is None: + return None + opcode, payload = frame + if opcode == 0x9: + self.send_raw(encode_frame(payload, opcode=0xA)) + continue + if opcode == 0x8: + code = int.from_bytes(payload[:2], "big") if len(payload) >= 2 else 1000 + if code == 4429: + raise AgentLimit("Live agent cap reached") + raise SocketClosed(code) + if opcode == 0xA: + continue + if opcode != 0x1: + raise SocketError("Unsupported WebSocket frame") + try: + message = json.loads(payload.decode()) + except (UnicodeError, json.JSONDecodeError): + raise SocketError("Invalid WebSocket message") from None + if type(message) is not dict: + raise SocketError("Invalid WebSocket message") + return message + + def send_raw(self, payload): + if self._sock is None: + raise SocketError("WebSocket closed") + self._sock.sendall(payload) + + def close(self): + sock, self._sock = self._sock, None + if sock is None: + return + try: + sock.sendall(encode_frame((1000).to_bytes(2, "big"), opcode=0x8)) + except OSError: + pass + try: + sock.close() + except OSError: + pass diff --git a/src/agent/tls.py b/src/agent/tls.py new file mode 100644 index 00000000..5413985c --- /dev/null +++ b/src/agent/tls.py @@ -0,0 +1,12 @@ +"""TLS settings for the outbound Sector Alpha agent.""" + +import ssl + + +def client_context(): + """Verified HTTPS context that refuses TLS 1.0 and 1.1.""" + context = ssl.create_default_context() + context.minimum_version = ssl.TLSVersion.TLSv1_2 + context.check_hostname = True + context.verify_mode = ssl.CERT_REQUIRED + return context diff --git a/src/agent/transport.py b/src/agent/transport.py new file mode 100644 index 00000000..d68ca0f1 --- /dev/null +++ b/src/agent/transport.py @@ -0,0 +1,149 @@ +# pylint: disable=unidiomatic-typecheck +"""Fixed-origin verified HTTPS without proxies, redirects or unbounded responses.""" + +import http.client +import json +import time +import threading +import urllib.error +import urllib.request +from .config import canonical_agent_origin +from .http_deadline import DeadlineBudget, DeadlineHTTPSHandler, bounded_exchange +from .tls import client_context + +REPORT_TIMEOUT = 10 + + +class TransportError(OSError): + """Bounded category-only network failures; credentials are never in errors.""" + + +class QueryBusy(TransportError): + """The name check can be requested again after the current query finishes.""" + + +class AgentLimit(TransportError): + """This account already has as many live agents as it allows.""" + + +_QUERY_BUSY = b'{"detail":"Request rejected","code":"query_busy"}' +_AGENT_LIMIT = b'{"detail":"Request rejected","code":"agent_limit"}' + + +def _exact_body(exc, expected): + try: + body = exc.read(len(expected) + 1) + except OSError: + return False + return body == expected + + +def _busy_body(exc): + return _exact_body(exc, _QUERY_BUSY) + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + raise TransportError("Credential redirects are forbidden") + + +class Transport: + def __init__(self, api_origin, *, context=None): + self.origin = canonical_agent_origin(api_origin) + self.report_lock = threading.Lock() + self.opener = urllib.request.build_opener( + urllib.request.ProxyHandler({}), + NoRedirect(), + DeadlineHTTPSHandler(context=context or client_context()), + ) + + def request(self, method, path, *, credential=None, payload=None, session_id=None, timeout=10, query=None): + if path not in ( + "/v1/agent/enroll", + "/v1/agent/bindings", + "/v1/agent/challenge", + "/v1/agent/reports", + "/v1/agent/policy", + "/v1/agent/jobs", + "/v1/agent/results", + "/v1/agent/verify", + ) and not ( + path.startswith("/v1/agent/jobs/") + and path.endswith("/authorize") + and path.count("/") == 5 + and all(c.isalnum() or c in "/_-" for c in path) + ) and not ( + path.startswith("/v1/agent/verify/") + and path.count("/") == 4 + and all(c.isalnum() or c in "/_-" for c in path) + ): + raise ValueError("Unapproved agent route") + if query is not None and not (method == "GET" and path == "/v1/agent/jobs" and query == "wait=0"): + raise ValueError("Unapproved agent route") + headers = {"Accept": "application/json"} + data = None + if payload is not None: + data = json.dumps(payload, separators=(",", ":")).encode() + if len(data) > 131072: + raise TransportError("Request exceeds byte limit") + headers["Content-Type"] = "application/json" + if credential is not None: + headers["Authorization"] = "Bearer " + credential["key_id"] + "." + credential["secret"] + if session_id is not None: + headers["X-Agent-Session"] = session_id + # Every operation uses the one configured origin. The default is the + # official API. A custom origin is used only when settings name it. + target = self.origin + path if query is None else self.origin + path + "?" + query + request = urllib.request.Request(target, method=method, data=data, headers=headers) + try: + if method == "POST" and path == "/v1/agent/reports": + return self._report_request(request, min(timeout, REPORT_TIMEOUT)) + return self._read_response(request, timeout) + except urllib.error.HTTPError as exc: + if exc.code == 403 and path == "/v1/agent/verify" and _busy_body(exc): + raise QueryBusy("Sector Alpha is still querying this server") from None + if exc.code == 403 and _exact_body(exc, _AGENT_LIMIT): + raise AgentLimit("Live agent cap reached") from None + raise TransportError("HTTPS request rejected (%s)" % exc.code) from None + except (OSError, ValueError, http.client.HTTPException, RecursionError) as exc: + if isinstance(exc, TransportError): + raise + raise TransportError("HTTPS exchange failed") from None + + + def _report_request(self, request, timeout): + if not self.report_lock.acquire(blocking=False): + raise TransportError("A timed-out report is still awaiting DNS cleanup") + budget = DeadlineBudget(timeout) + request.agent_deadline = budget + + def send(): + try: + return self._read_response(request, budget.remaining()) + finally: + self.report_lock.release() + + return bounded_exchange(send, budget, name="alphagsm-report-post") + + def _read_response(self, request, timeout): + deadline = time.monotonic() + timeout + with self.opener.open(request, timeout=timeout) as response: + if response.geturl() != request.full_url: + raise TransportError("Response origin changed") + parts = bytearray() + while response.fp is not None: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TransportError("HTTPS total deadline exceeded") + # urllib's TLS socket must share the total deadline across reads. + response.fp.raw._sock.settimeout(remaining) # pylint: disable=protected-access + chunk = response.read1(min(16384, 131073 - len(parts))) + parts.extend(chunk) + if len(parts) > 131072: + raise TransportError("Response exceeds byte limit") + if not chunk: + break + result = json.loads(parts) + if type(result) is not dict: + raise TransportError("Invalid HTTPS response") + return result diff --git a/src/agent/user_service.py b/src/agent/user_service.py new file mode 100644 index 00000000..fa8b3b7a --- /dev/null +++ b/src/agent/user_service.py @@ -0,0 +1,328 @@ +"""Optional Linux service for the Sector Alpha agent. + +AlphaGSM runs the agent. There is no separate alphagsm-agent program. +`install-service` keeps `alphagsm agent run` alive for this user. +`install-service --system` installs that same command as a system service +for one operator account. Enrollment, reporting and management stay unchanged. +""" + +import os +import shutil +import subprocess +from collections import namedtuple +from pathlib import Path + +from utils.platform_info import PLATFORM + + +UNIT_NAME = "alphagsm.service" +SYSTEM_UNIT_DIR = "/etc/systemd/system" +Account = namedtuple("Account", ("name", "group", "home")) +_UNSUPPORTED = ( + "Linux systemd is the supported optional agent service. " + "On this platform, run alphagsm agent run in the foreground or install it with your own service manager." +) + + +class ServiceInstallError(ValueError): + """The optional agent service could not be installed or removed.""" + + +def install_hint(*, platform=None, which=None, home=None, env=None, system_dir=None): + """Tell the operator how to keep the agent running, without installing it.""" + if (PLATFORM if platform is None else platform) != "linux": + return "Keep alphagsm agent run in the foreground." + finder = shutil.which if which is None else which + if finder("systemctl") is None: + return ( + "Keep alphagsm agent run in the foreground, or install that command with your own service manager." + ) + if system_unit_file(system_dir).is_file(): + return "Restart the system service with: alphagsm agent install-service --system" + if unit_file(home, env).is_file(): + return "Restart the installed service with: alphagsm agent install-service" + return ( + "To keep the agent running after you close the terminal: alphagsm agent install-service. " + "For a system service: alphagsm agent install-service --system" + ) + + +def install( + executable=None, + *, + runner=None, + home=None, + env=None, + platform=None, + user=None, + which=None, + system=False, + account=None, + system_dir=None, +): + """Install and start the user service, or a system service with --system.""" + _require_linux(platform) + finder = shutil.which if which is None else which + if finder("systemctl") is None: + raise ServiceInstallError( + "systemctl is not available for this user. " + "Run alphagsm agent run in the foreground, or install it with your own service manager." + ) + program = resolve_executable(executable) + selected_env = os.environ if env is None else env + invoke = _default_runner if runner is None else runner + if system: + selected = account if account is not None else lookup_account(user) + text = render_unit(program, selected_env.get("ALPHAGSM_CONFIG_LOCATION"), account=selected) + path = system_unit_file(system_dir) + _reject_user_unit(home, selected_env) + _require_system_write(path) + _write_unit(path, text) + _systemctl(invoke, "daemon-reload", system=True) + _systemctl(invoke, "enable", UNIT_NAME, system=True) + _systemctl(invoke, "restart", UNIT_NAME, system=True) + print("Installed the system service " + UNIT_NAME + " and started it.") + print("It runs as " + selected.name + ": " + program + " agent run") + print("Reporting and management are unchanged.") + return + _reject_system_unit(system_dir) + text = render_unit(program, selected_env.get("ALPHAGSM_CONFIG_LOCATION")) + path = unit_file(home, selected_env) + _write_unit(path, text) + _systemctl(invoke, "daemon-reload") + _systemctl(invoke, "enable", UNIT_NAME) + _systemctl(invoke, "restart", UNIT_NAME) + print("Installed the user service " + UNIT_NAME + " and started it.") + print("It runs: " + program + " agent run") + print("Reporting and management are unchanged.") + linger = _linger_line(invoke, _user_name(user)) + if linger: + print(linger) + + +def uninstall(*, runner=None, home=None, env=None, platform=None, which=None, system=False, system_dir=None): + """Remove the selected service. Local enrollment and permissions stay in place.""" + _require_linux(platform) + finder = shutil.which if which is None else which + if finder("systemctl") is None: + raise ServiceInstallError("systemctl is not available, so the agent service unit was left unchanged.") + path = system_unit_file(system_dir) if system else unit_file(home, os.environ if env is None else env) + if path.is_symlink(): + raise ServiceInstallError("Refusing to remove the agent service through a symlink") + label = "system" if system else "user" + if not path.is_file(): + print("No AlphaGSM " + label + " service is installed.") + return + if system: + _require_system_write(path) + invoke = _default_runner if runner is None else runner + result = invoke(_systemctl_command("disable", "--now", UNIT_NAME, system=system)) + if result.returncode != 0 and not _unit_absent(result): + detail = (result.stderr or result.stdout or "").strip() + rendered = " ".join(_systemctl_command("disable", "--now", UNIT_NAME, system=system)) + raise ServiceInstallError(rendered + " failed" + (": " + detail if detail else "")) + path.unlink() + _systemctl(invoke, "daemon-reload", system=system) + print("Removed the AlphaGSM " + label + " service. Local enrollment and permissions are unchanged.") + + +def lookup_account(user): + """Operator account for a system service. The service does not run as root.""" + import grp + import pwd + + name = user or os.environ.get("SUDO_USER") or os.environ.get("USER") or "" + if name == "root": + raise ServiceInstallError( + "The system service does not run as root. Choose the operator with --service-user." + ) + _plain_name(name, "account") + try: + record = pwd.getpwnam(name) + except KeyError as exc: + raise ServiceInstallError("Unknown service account: " + name) from exc + if not record.pw_dir or record.pw_dir == "/": + raise ServiceInstallError("The service account needs a home directory") + group = grp.getgrgid(record.pw_gid).gr_name + _plain_name(group, "group") + if any(ord(char) < 32 for char in record.pw_dir): + raise ServiceInstallError("The service home directory contains unsupported characters") + return Account(name, group, record.pw_dir) + + +def resolve_executable(invoked): + """Absolute program path the service will execute.""" + import sys + + if getattr(sys, "frozen", False): + candidate = sys.executable + elif invoked: + candidate = invoked if os.path.isabs(invoked) else os.path.abspath(invoked) + if not os.path.isfile(candidate): + found = shutil.which(invoked) + if found: + candidate = found + else: + here = os.path.abspath(__file__) + candidate = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(here))), "alphagsm") + if candidate and any(ord(char) < 32 for char in candidate): + raise ServiceInstallError("The AlphaGSM program path contains unsupported characters") + if not candidate or not os.path.isabs(candidate) or not os.path.isfile(candidate): + raise ServiceInstallError("Install the service from the AlphaGSM program you want it to run") + if not os.access(candidate, os.X_OK): + raise ServiceInstallError("The AlphaGSM program must be an executable file") + return candidate + + +def render_unit(executable, config_location=None, *, account=None): + """systemd unit. It runs alphagsm agent run and never changes consent.""" + lines = [ + "[Unit]", + "Description=AlphaGSM Sector Alpha agent", + "After=network-online.target", + "Wants=network-online.target", + "", + "[Service]", + "Type=simple", + ] + if account is not None: + _plain_name(account.name, "account") + _plain_name(account.group, "group") + if any(ord(char) < 32 for char in account.home): + raise ServiceInstallError("The service home directory contains unsupported characters") + lines.append("User=" + account.name) + lines.append("Group=" + account.group) + lines.append("ExecStart=" + systemd_quote(executable) + " agent run") + if account is not None: + lines.append("Environment=HOME=" + systemd_quote(account.home)) + lines.append("Environment=USER=" + systemd_quote(account.name)) + if config_location: + if any(ord(char) < 32 for char in config_location): + raise ServiceInstallError("The AlphaGSM config location cannot be stored in the service") + lines.append("Environment=ALPHAGSM_CONFIG_LOCATION=" + systemd_quote(config_location)) + lines.extend([ + "Restart=on-failure", + "RestartSec=5", + "", + "[Install]", + "WantedBy=" + ("multi-user.target" if account is not None else "default.target"), + "", + ]) + return "\n".join(lines) + + +def systemd_quote(value): + """Quote one systemd word, including specifier escapes.""" + escaped = value.replace("\\", "\\\\").replace("%", "%%").replace('"', '\\"') + return '"' + escaped + '"' + + +def unit_file(home=None, env=None): + """Path of the managed user unit.""" + selected = os.environ if env is None else env + base = selected.get("XDG_CONFIG_HOME") + if not base: + base = str((Path.home() if home is None else Path(home)) / ".config") + return Path(base) / "systemd" / "user" / UNIT_NAME + + +def system_unit_file(directory=None): + """Path of the managed system unit.""" + return Path(SYSTEM_UNIT_DIR if directory is None else directory) / UNIT_NAME + + +def _plain_name(value, label): + if not isinstance(value, str) or not value or any(ord(char) < 32 or char in " \"%\\" for char in value): + raise ServiceInstallError("The service " + label + " contains unsupported characters") + + +def _require_linux(platform): + if (PLATFORM if platform is None else platform) != "linux": + raise ServiceInstallError(_UNSUPPORTED) + + +def _user_name(user): + if user: + return user + return os.environ.get("USER") or "" + + +def _default_runner(args): + return subprocess.run(args, check=False, capture_output=True, text=True) + + +def _systemctl_command(*args, system=False): + if system: + return ["systemctl", *args] + return ["systemctl", "--user", *args] + + +def _systemctl(runner, *args, system=False): + command = _systemctl_command(*args, system=system) + result = runner(command) + if result.returncode != 0: + detail = (result.stderr or result.stdout or "").strip() + raise ServiceInstallError(" ".join(command) + " failed" + (": " + detail if detail else "")) + return result + + +def _unit_absent(result): + text = ((result.stderr or "") + "\n" + (result.stdout or "")).lower() + return any(phrase in text for phrase in ("not found", "does not exist", "not loaded")) + + +def _reject_user_unit(home, env): + path = unit_file(home, env) + if path.is_file(): + raise ServiceInstallError( + "A user service is already installed. Remove it with: alphagsm agent uninstall-service" + ) + + +def _reject_system_unit(directory): + path = system_unit_file(directory) + if path.is_file(): + raise ServiceInstallError( + "A system service is already installed. Remove it with: alphagsm agent uninstall-service --system" + ) + + +def _require_system_write(path): + parent = path.parent + if parent.exists() and not os.access(parent, os.W_OK): + raise ServiceInstallError( + "System install needs root to write " + str(path) + ". " + "Use alphagsm agent install-service without --system for a user service." + ) + + +def _write_unit(path, text): + if path.is_symlink() or path.parent.is_symlink(): + raise ServiceInstallError("Refusing to write the agent service through a symlink") + try: + path.parent.mkdir(parents=True, exist_ok=True, mode=0o755) + except OSError as exc: + raise ServiceInstallError("Could not create the systemd directory: " + str(exc)) from exc + if path.is_symlink() or path.parent.is_symlink(): + raise ServiceInstallError("Refusing to write the agent service through a symlink") + temporary = path.with_name("." + path.name + ".tmp") + if temporary.is_symlink(): + raise ServiceInstallError("Refusing to write the agent service through a symlink") + try: + temporary.write_text(text, encoding="utf-8") + os.chmod(temporary, 0o644) + os.replace(temporary, path) + except OSError as exc: + raise ServiceInstallError("Could not write the agent service unit: " + str(exc)) from exc + + +def _linger_line(runner, user): + if not user: + return "" + try: + result = runner(["loginctl", "show-user", user, "-p", "Linger"]) + except OSError: + return "" + if result.returncode != 0 or "Linger=no" not in (result.stdout or ""): + return "" + return "This service stops when you log out. To keep it running, enable lingering: loginctl enable-linger" diff --git a/src/core/main.py b/src/core/main.py index b77e5ca7..3c22a797 100644 --- a/src/core/main.py +++ b/src/core/main.py @@ -12,6 +12,7 @@ import traceback from . import program from .version import get_version +from .server_names import RESERVED_SERVER_NAMES from sys import stderr, stdout from textwrap import dedent @@ -59,6 +60,16 @@ def main(name, args): The main role of this function is to sanitize the command input from the user. """ + if not args or args[0] in ("--welcome", "--skip-welcome"): + from .welcome import dispatch as welcome_dispatch + result = welcome_dispatch(name, args, main) + if result is not None: + return result + + if args and args[0].lower() == "agent": + from agent.cli import run as run_agent + return run_agent(args[1:], executable=name) + if len(args) == 1 and args[0].lower() in ("-v", "--version", "version"): print("AlphaGSM %s" % (get_version(),)) return 0 @@ -94,23 +105,8 @@ def main(name, args): help(name, None) return 2 # prevent servers having similar names to commnds - banned = set( - ( - "help", - "create", - "up", - "down", - "shell", - "logs", - "compose", - "ps", - "self-update", - "steam-auth", - ) - + Server.default_commands - ) for s in raw_servers: - if s.lower() in banned: + if s.lower() in RESERVED_SERVER_NAMES: print( s, "is a banned server name as it's too similar to a command", @@ -327,6 +323,9 @@ def _run_one_unlocked(name, server, cmd, args): # try running the command try: server.run_command(cmd, *args, **opts) + if cmd in ("set", "start", "stop", "restart", "setup"): + from agent.cli import notify_success + notify_success(tag) # developers: be sure to check if command_functions dictionary # has been filled, otherwise the error called here will make # no sense @@ -701,6 +700,8 @@ def help(name, server, cmd=None, *, file=stderr, full_help=False): file=file, ) + print(name, "--welcome : Replay the optional setup tour.", file=file) + print(name, "--skip-welcome : Skip the first-run tour.", file=file) print("Top-level commands:", file=file) print( dedent(""" @@ -717,6 +718,11 @@ def help(name, server, cmd=None, *, file=stderr, full_help=False): scripts to list the servers in a script processable way. steam-auth login|status|logout : Manage local SteamCMD sessions. + agent COMMAND : Optional Sector Alpha connection. install-service + keeps alphagsm agent run running for this user. + install-service --system installs that same command + as a system service. Neither changes reporting + or management. self-update [OPTION]... : Check for a newer AlphaGSM release and apply it when supported. """).rstrip(), diff --git a/src/core/server_names.py b/src/core/server_names.py new file mode 100644 index 00000000..3a2e0e6f --- /dev/null +++ b/src/core/server_names.py @@ -0,0 +1,20 @@ +"""Names reserved by ordinary top-level server dispatch and interactive setup.""" + +from server import Server + +RESERVED_SERVER_NAMES = frozenset( + ( + "help", + "create", + "up", + "down", + "shell", + "logs", + "compose", + "ps", + "self-update", + "steam-auth", + "agent", + ) + + Server.default_commands +) diff --git a/src/core/welcome.py b/src/core/welcome.py new file mode 100644 index 00000000..499c1459 --- /dev/null +++ b/src/core/welcome.py @@ -0,0 +1,161 @@ +"""Optional interactive guidance; existing commands retain their own consent flows.""" + +import os +from pathlib import Path +import re +import shlex +import sys + +from . import welcome_state + + +def _interactive(): + return all(getattr(stream, 'isatty', lambda: False)() + for stream in (sys.stdin, sys.stdout)) + + +def _yes(prompt): + return input(prompt + ' [y/N]: ').strip().lower() in ('y', 'yes') + + +def _command(name, *args): + return shlex.join([name, *args]) + + +def _remember(choice): + try: + welcome_state.remember(choice) + except OSError as exc: + print('Could not remember tour preference: %s' % exc, file=sys.stderr) + return 1 + return 0 + + +def _commands(name, tag='my-server'): + print('\nEveryday flow: create -> setup -> start -> status/query/info -> stop.') + for command in ('setup', 'start', 'status', 'query', 'info --json', 'doctor', 'stop'): + print(' ' + _command(name, tag, *command.split())) + print('Help: ' + _command(name, '--help')) + print('Replay this tour: ' + _command(name, '--welcome')) + + +def _create(name, execute): + from server import server as server_module + from .server_names import RESERVED_SERVER_NAMES + from utils.state_io import state_lock + + catalogue = server_module.MODULE_CATALOG + while True: + game = input('Game module (minecraft.vanilla, tf2, ? to list; Enter to cancel): ').strip().lower() + if not game: + return 0 + if game == '?': + print('\n'.join(catalogue.canonical_modules)) + continue + game = catalogue.resolve(game) + if game in catalogue.canonical_modules: + break + print('Unknown game module. Enter ? to list the available modules.') + tag = input('Local server name (Enter to cancel): ').strip() + if not tag: + return 0 + if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,127}', tag): + print('Use a local name containing letters, digits, dots, underscores or hyphens.') + return 0 + if tag.isdigit() or tag.lower() in RESERVED_SERVER_NAMES | {'all', 'version'}: + print('That name is reserved for a command or server selector. Choose a different local name.') + return 0 + path = Path(server_module.DATAPATH) / (tag + '.json') + if os.path.lexists(path): + print('That server name already exists. Existing servers are not replaced by this tour.') + return 0 + print('\nCreate a local server record: ' + _command(name, tag, 'create', game)) + print('Setup/downloads require a separate confirmation. The game will not start automatically.') + if not _yes('Create this server record?'): + return 0 + with state_lock(path): + if os.path.lexists(path): + print('That server name was created by another command. Nothing was replaced.') + return 1 + result = execute(name, [tag, 'create', game]) + if result: + return result + print('Next: ' + _command(name, tag, 'setup')) + if _yes('Run setup now? It may download game files/install a runtime and asks for its settings'): + result = execute(name, [tag, 'setup']) + if result: + print('Setup did not finish. Retry with: ' + _command(name, tag, 'setup')) + return result + _commands(name, tag) + return 0 + + +def _connect(name, execute): + print('\nOptional Sector Alpha connection: get the enrollment code from your') + print('account at https://sector-alpha.net. The agent uses') + print('https://alphagsm-api.sector-alpha.net for every operation and does not') + print('ask you to choose a host. Another provider is used only with --origin.') + print('This tour opens agent setup. It previews servers and asks before saving.') + print('This tour never enables reporting or management. Existing choices are preserved.') + print('After this wizard, reporting needs a separate explicit agent enable-reporting command.') + if _yes('Open the Sector Alpha enrollment/binding wizard?'): + return execute(name, ['agent', 'setup']) + return 0 + + +def _tour(name, execute): + print('\nWelcome to AlphaGSM. Manage game servers locally; Sector Alpha is optional.') + print('Taking this tour does not enable remote reporting, public listings or management.') + while True: + print('\n1. Create/configure a game server\n2. Everyday commands\n3. Optional Sector Alpha connection') + choice = input('Choose 1, 2 or 3; Enter or s finishes the tour: ').strip().lower() + if choice in ('', 's', 'skip'): + return _remember('completed') + if choice == '1': + result = _create(name, execute) + elif choice == '2': + _commands(name) + result = 0 + elif choice == '3': + result = _connect(name, execute) + else: + print('Choose 1, 2, 3, or Enter to finish.') + continue + if result: + return result + + +def dispatch(name, args, execute): + """Return None for ordinary dispatch, otherwise handle an explicit/first-run tour.""" + if args == ['--welcome', '--help']: + print('Run %s --welcome to replay the optional tour; --skip-welcome remembers an opt-out.' % name) + return 0 + if args and args not in (['--welcome'], ['--skip-welcome']): + print('Usage: %s [--welcome | --skip-welcome]' % name, file=sys.stderr) + return 2 + try: + if args == ['--skip-welcome']: + result = _remember('skipped') + if not result: + print('Tour skipped. Replay any time with: ' + _command(name, '--welcome')) + return result + if not _interactive(): + if args: + print('The tour needs interactive input and output. Use --help for scripted commands.', file=sys.stderr) + return 2 + return None + if not args: + if welcome_state.seen(): + return None + if not _yes('First time using AlphaGSM? Start the optional setup tour? Enter skips it'): + return _remember('skipped') + return _tour(name, execute) + except EOFError: + print('\nTour cancelled; no further commands run. Replay with ' + _command(name, '--welcome')) + return 0 + except KeyboardInterrupt: + print('\nTour cancelled; no further commands run. Replay with ' + _command(name, '--welcome')) + return 130 + except OSError as exc: + print('Tour unavailable: %s' % exc, file=sys.stderr) + return 1 diff --git a/src/core/welcome_state.py b/src/core/welcome_state.py new file mode 100644 index 00000000..a7b7f9e6 --- /dev/null +++ b/src/core/welcome_state.py @@ -0,0 +1,47 @@ +"""Non-secret, per-operator acknowledgement of the optional first-run tour.""" + +import os +from pathlib import Path +import stat + +from utils.settings import settings +from utils.state_io import sync_directory + + +def state_path(): + """Use the configured operator home, outside server and agent datastores.""" + home = settings.user.getsection('core').get('alphagsm_path', '~/.alphagsm') + return Path(os.path.expanduser(home)) / 'welcome-v1.done' + + +def seen(): + """Recognize an ordinary owned marker without opening links or special files.""" + try: + info = state_path().lstat() + except FileNotFoundError: + return False + return stat.S_ISREG(info.st_mode) and ( + not hasattr(os, 'getuid') or info.st_uid == os.getuid()) + + +def remember(choice): + """Exclusively create a small acknowledgement; never replace an existing path.""" + path = state_path() + if path.parent.is_symlink(): + raise OSError('Tour preference directory must not be a symlink') + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + info = path.parent.stat() + if hasattr(os, 'getuid') and info.st_uid != os.getuid(): + raise OSError('Tour preference directory must be owned by you') + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, 'O_NOFOLLOW', 0) + try: + descriptor = os.open(path, flags, 0o600) + except FileExistsError: + if not seen(): + raise OSError('Tour preference path is not an owned regular file') from None + return + with os.fdopen(descriptor, 'w', encoding='utf-8') as handle: + handle.write(choice + '\n') + handle.flush() + os.fsync(handle.fileno()) + sync_directory(path.parent) diff --git a/src/gamemodules/etlegacyserver/main.py b/src/gamemodules/etlegacyserver/main.py index a4554fa9..eecb05d4 100644 --- a/src/gamemodules/etlegacyserver/main.py +++ b/src/gamemodules/etlegacyserver/main.py @@ -411,17 +411,46 @@ def _read_page(url): return response.read().decode("utf-8", "replace") +def _linux_archive_url(page_html): + """Return the official Linux x86_64 tar.gz on a current or older release page.""" + + patterns = ( + r'data-href="(https://www\.etlegacy\.com/download/file/\d+)"' + r'[^>]*title="[^"]*-x86_64\.tar\.gz"', + r'title="[^"]*-x86_64\.tar\.gz"[^>]*' + r'data-href="(https://www\.etlegacy\.com/download/file/\d+)"', + r'data-href="(https://www\.etlegacy\.com/download/file/\d+)">\s*x86_64 archive', + ) + for pattern in patterns: + match = re.search(pattern, page_html, re.IGNORECASE | re.DOTALL) + if match is not None: + return match.group(1) + return None + + def _parse_release_page(page_html): version_match = re.search(r"stable release ([0-9.]+)", page_html, re.IGNORECASE) - download_match = re.search( - r"Linux.*?data-href=\"(https://www\.etlegacy\.com/download/file/\d+)\">x86_64 archive", + download_url = _linux_archive_url(page_html) + if version_match is None or download_url is None: + raise ServerError("Unable to locate a suitable ET: Legacy Linux download") + return version_match.group(1), download_url + + +def _release_id(page_html, version): + """Return the release id whose visible version text is exactly `version`.""" + + wanted = str(version) + for match in re.finditer( + r'href="https://www\.etlegacy\.com/download/release/(\d+)"[^>]*>(.*?)', page_html, re.IGNORECASE | re.DOTALL, - ) - if version_match is None or download_match is None: - raise ServerError("Unable to locate a suitable ET: Legacy Linux download") - version = version_match.group(1) - return version, download_match.group(1) + ): + body = match.group(2) + span = re.search(r'dl-release-version[^>]*>\s*([^<]+)', body, re.IGNORECASE) + token = span.group(1).strip() if span else re.sub(r"<[^>]+>", " ", body).strip() + if token == wanted: + return match.group(1) + return None def resolve_download(version=None): @@ -431,17 +460,11 @@ def resolve_download(version=None): return _parse_release_page(_read_page(ETLEGACY_DOWNLOAD_PAGE)) release_list = _read_page(ETLEGACY_RELEASE_LIST_PAGE) - release_match = re.search( - r'href="https://www\.etlegacy\.com/download/release/(\d+)">%s\b' - % re.escape(str(version)), - release_list, - re.IGNORECASE, - ) - if release_match is None: + release_id = _release_id(release_list, version) + if release_id is None: raise ServerError("Unable to locate the requested ET: Legacy release") - release_url = "https://www.etlegacy.com/download/release/%s" % (release_match.group(1),) - resolved_version, resolved_url = _parse_release_page(_read_page(release_url)) - return resolved_version, resolved_url + release_url = "https://www.etlegacy.com/download/release/%s" % (release_id,) + return _parse_release_page(_read_page(release_url)) def configure( diff --git a/src/gamemodules/ts3server/main.py b/src/gamemodules/ts3server/main.py index 0069e3fe..f3e6875b 100644 --- a/src/gamemodules/ts3server/main.py +++ b/src/gamemodules/ts3server/main.py @@ -4,6 +4,8 @@ import pathlib import re import shutil +import subprocess +import time import urllib.request import downloader @@ -20,6 +22,8 @@ "teamspeak3-server_linux_amd64-%s.tar.bz2" ) TEAMSPEAK_USER_AGENT = "AlphaGSM/1.0 (+https://github.com/SectorAlpha/AlphaGSM)" +MANAGED_DB_IMAGE = "mariadb:11.5" +MANAGED_DB_ALIAS = "database" commands = () command_args = gamemodule_common.build_setup_version_url_command_args( @@ -104,6 +108,7 @@ def configure(server, ask, port=None, dir=None, *, version=None, url=None, exe_n server.data.setdefault("queryport", "10011") server.data.setdefault("filetransferport", "30033") server.data.setdefault("license_accepted", True) + server.data.setdefault("db_mode", "sqlite") if port is None: port = server.data.get("port", 9987) @@ -150,16 +155,17 @@ def get_start_command(server): exe_path = os.path.join(server.data["dir"], server.data["exe_name"]) if not os.path.isfile(exe_path): raise ServerError("Executable file not found") - return ( - [ - "./" + server.data["exe_name"], - "default_voice_port=%s" % (server.data["port"],), - "query_port=%s" % (server.data["queryport"],), - "filetransfer_port=%s" % (server.data["filetransferport"],), - "license_accepted=%s" % (1 if server.data.get("license_accepted", True) else 0,), - ], - server.data["dir"], - ) + command = [ + "./" + server.data["exe_name"], + "default_voice_port=%s" % (server.data["port"],), + "query_port=%s" % (server.data["queryport"],), + "filetransfer_port=%s" % (server.data["filetransferport"],), + "license_accepted=%s" % (1 if server.data.get("license_accepted", True) else 0,), + ] + if _uses_mariadb(server): + write_mariadb_parameter_file(server, mariadb_endpoint_host(server)) + command.extend(mariadb_command_arguments(server)) + return command, server.data["dir"] def get_runtime_requirements(server): @@ -208,13 +214,213 @@ def get_container_spec(server): cmd, _cwd = get_start_command(server) requirements = get_runtime_requirements(server) - return { + spec = { "working_dir": "/srv/server", "stdin_open": True, "mounts": requirements.get("mounts", []), "ports": requirements.get("ports", []), "command": cmd, } + if _uses_managed_database(server): + spec["network_mode"] = database_network_name(server) + if _uses_mariadb(server): + # TeamSpeak 3.12 loads libmariadb.so.2 from its own redist directory. + env = dict(server.data.get("env") or {}) + current = env.get("LD_LIBRARY_PATH", "") + redist = "/srv/server/redist" + if redist not in current.split(":"): + env["LD_LIBRARY_PATH"] = redist if not current else current + ":" + redist + env.setdefault("LANG", "C.UTF-8") + spec["env"] = env + return spec + + +def prestart(server, *_args, **_kwargs): + """Start a managed MariaDB sidecar before a MariaDB-backed server.""" + + if not _uses_mariadb(server): + return + if _uses_managed_database(server): + _ensure_managed_database(server) + write_mariadb_parameter_file(server, mariadb_endpoint_host(server)) + + +def _uses_mariadb(server): + return str(server.data.get("db_mode", "sqlite")).strip().lower() == "mariadb" + + +def _uses_managed_database(server): + if not _uses_mariadb(server): + return False + host = str(server.data.get("db_host", "")).strip().lower() + return host in ("", "managed") + + +def _safe_name(server): + return re.sub(r"[^a-z0-9_.-]", "-", str(server.name).lower()).strip("-") or "server" + + +def database_network_name(server): + """Return the private Docker network shared by TeamSpeak and MariaDB.""" + + return "alphagsm-ts3-" + _safe_name(server) + + +def database_container_name(server): + """Return the managed MariaDB container name.""" + + return "alphagsm-ts3-db-" + _safe_name(server) + + +def database_data_dir(server): + """Return the host directory that stores the MariaDB data directory.""" + + return os.path.join(server.data["dir"], ".alphagsm", "mariadb-data") + + +def mariadb_endpoint_host(server): + """Return the hostname TeamSpeak should use for MariaDB.""" + + host = str(server.data.get("db_host", "")).strip() + if host and host.lower() != "managed": + return host + if str(server.data.get("runtime", "")).strip().lower() == "docker" or _uses_managed_database(server): + return MANAGED_DB_ALIAS + return "127.0.0.1" + + +def mariadb_command_arguments(server): + """Return TeamSpeak arguments that select the MariaDB plugin.""" + + arguments = [ + "dbplugin=ts3db_mariadb", + "dbsqlpath=sql/", + "dbsqlcreatepath=create_mariadb/", + "dbpluginparameter=ts3db_mariadb.ini", + ] + licensepath = str(server.data.get("licensepath", "")).strip() + if licensepath: + arguments.append("licensepath=%s" % (licensepath,)) + return arguments + + +def write_mariadb_parameter_file(server, host): + """Write the MariaDB plugin file without placing the password on the command line.""" + + password = str(server.data.get("db_password", "")) + if not password or any(character in password for character in "\r\n"): + raise ServerError("MariaDB mode requires a db_password on one line") + username = str(server.data.get("db_user", "root")).strip() or "root" + database = str(server.data.get("db_name", "ts3")).strip() or "ts3" + port = int(server.data.get("db_port", 3306)) + directory = server.data["dir"] + os.makedirs(directory, exist_ok=True) + path = os.path.join(directory, "ts3db_mariadb.ini") + body = ( + "[config]\n" + "host=%s\n" + "port=%s\n" + "username=%s\n" + "password=%s\n" + "database=%s\n" + "socket=\n" + ) % (host, port, username, password, database) + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + handle.write(body) + os.chmod(path, 0o600) + return path + + +def _run_docker(*args, check=True): + return subprocess.run( + ["docker", *args], + check=check, + capture_output=True, + text=True, + ) + + +def _redact(text, secret): + if not text: + return "" + if secret: + text = text.replace(secret, "[redacted]") + return text.strip() + + +def managed_database_run_args(server, *, initialize): + """Return the docker run arguments for a new managed MariaDB container.""" + + password = str(server.data.get("db_password", "")) + data_dir = database_data_dir(server) + command = [ + "run", "-d", + "--name", database_container_name(server), + "--network", database_network_name(server), + "--network-alias", MANAGED_DB_ALIAS, + "--restart", "unless-stopped", + "-v", "%s:/var/lib/mysql" % (data_dir,), + "-e", "MARIADB_AUTO_UPGRADE=1", + ] + if initialize: + command.extend([ + "-e", "MYSQL_ROOT_PASSWORD=%s" % (password,), + "-e", "MYSQL_DATABASE=%s" % (str(server.data.get("db_name", "ts3")),), + ]) + user = str(server.data.get("db_user", "root")).strip() + if user and user.lower() != "root": + command.extend([ + "-e", "MYSQL_USER=%s" % (user,), + "-e", "MYSQL_PASSWORD=%s" % (password,), + ]) + command.append(MANAGED_DB_IMAGE) + return command + + +def _ensure_managed_database(server): + """Launch the private MariaDB container and wait until it accepts connections.""" + + if shutil.which("docker") is None: + raise ServerError("Docker is required to launch the managed TeamSpeak MariaDB") + if str(server.data.get("runtime", "docker")).strip().lower() != "docker": + raise ServerError("Managed TeamSpeak MariaDB requires the docker runtime, or set db_host to an existing database") + password = str(server.data.get("db_password", "")) + network = database_network_name(server) + container = database_container_name(server) + inspect_network = _run_docker("network", "inspect", network, check=False) + if inspect_network.returncode != 0: + created = _run_docker("network", "create", network, check=False) + if created.returncode != 0: + raise ServerError("Could not create the TeamSpeak database network: " + _redact(created.stderr, password)) + data_dir = database_data_dir(server) + os.makedirs(data_dir, exist_ok=True) + inspect_container = _run_docker("inspect", container, check=False) + if inspect_container.returncode != 0: + initialize = not os.path.isdir(os.path.join(data_dir, "mysql")) + started = _run_docker(*managed_database_run_args(server, initialize=initialize), check=False) + if started.returncode != 0: + raise ServerError("Could not start managed TeamSpeak MariaDB: " + _redact(started.stderr, password)) + else: + running = _run_docker("inspect", "-f", "{{.State.Running}}", container, check=False) + if running.returncode != 0 or running.stdout.strip().lower() != "true": + restarted = _run_docker("start", container, check=False) + if restarted.returncode != 0: + raise ServerError("Could not restart managed TeamSpeak MariaDB: " + _redact(restarted.stderr, password)) + _run_docker("network", "connect", "--alias", MANAGED_DB_ALIAS, network, container, check=False) + deadline = time.monotonic() + 60 + last_detail = "" + while time.monotonic() < deadline: + ready = _run_docker( + "exec", container, "mariadb-admin", "--user=root", "--password=%s" % (password,), + "--host=127.0.0.1", "ping", "--silent", + check=False, + ) + if ready.returncode == 0: + return + last_detail = _redact(ready.stderr or ready.stdout, password) + time.sleep(1) + raise ServerError("Managed TeamSpeak MariaDB did not become ready: " + last_detail) def get_query_address(server): @@ -301,10 +507,15 @@ def checkvalue(server, key, *value): return backup_utils.checkdatavalue(server.data["backup"], key, *value) if len(value) == 0: raise ServerError("No value specified") - if key[0] in ("port", "queryport", "filetransferport"): + if key[0] in ("port", "queryport", "filetransferport", "db_port"): return int(value[0]) - if key[0] in ("exe_name", "dir", "url", "version"): + if key[0] in ("exe_name", "dir", "url", "version", "db_name", "db_user", "db_password", "db_host", "licensepath"): return str(value[0]) + if key[0] == "db_mode": + mode = str(value[0]).strip().lower() + if mode not in ("sqlite", "mariadb"): + raise ServerError("db_mode must be sqlite or mariadb") + return mode if key[0] == "license_accepted": return str(value[0]).lower() in ("1", "true", "yes", "on") raise ServerError("Unsupported key") diff --git a/src/server/port_manager.py b/src/server/port_manager.py index ef3ab90e..ad5e37f3 100644 --- a/src/server/port_manager.py +++ b/src/server/port_manager.py @@ -472,6 +472,11 @@ def probe_live_listener(ip, port): for family, stype, proto, _canon, sockaddr in infos: sock = socket.socket(family, stype, proto) try: + # Closed TCP connections can remain in TIME_WAIT after stop. + # Reuse permits those ports while active listeners still conflict. + # UDP deliberately keeps exclusive-bind behavior. + if os.name == "posix" and stype == socket.SOCK_STREAM: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind(sockaddr) except OSError as exc: if getattr(exc, "errno", None) == errno.EADDRNOTAVAIL: diff --git a/src/server/public_protocols.py b/src/server/public_protocols.py new file mode 100644 index 00000000..41361376 --- /dev/null +++ b/src/server/public_protocols.py @@ -0,0 +1,376 @@ +"""Anonymous status protocols read from game-module source. No Server import.""" + +from pathlib import Path +import ast + +# These return a public name plus player counts. TCP, UDP, RCON, and raw +# handshakes are reachability checks, not status queries. +STRUCTURED = frozenset({"a2s", "slp", "quake", "quake2", "quakeworld", "bedrock", "ts3"}) +IPV4_ONLY = frozenset({"a2s", "quake", "quake2", "quakeworld", "bedrock"}) +_HOOKS = ("get_info_address", "get_query_address") +_CACHE = {} + + +def protocol_for(game_id): + """Return the anonymous status protocol for one canonical game id, or None.""" + if not isinstance(game_id, str): + return None + return _catalogue()["protocols"].get(game_id) + + +def queryable_games(): + """Return ``(game_id, protocol, name)`` rows for games Sector Alpha can query.""" + items = _catalogue()["games"] + return tuple(items) + + +def query_port_for(game_id): + """Return the module's usual query port, or None when the source has no single literal.""" + if not isinstance(game_id, str): + return None + return _catalogue()["ports"].get(game_id) + + +def supported_games(): + """Return ``(game_id, protocol or None, name, port or None)`` for every AlphaGSM game.""" + return tuple(_catalogue()["supported"]) + + +def game_supported(game_id): + """Return whether this canonical id is an AlphaGSM game module.""" + if not isinstance(game_id, str): + return False + return game_id in _catalogue()["known"] + + +def _catalogue(): + cached = _CACHE.get("value") + if cached is None: + cached = _build() + _CACHE["value"] = cached + return cached + + +def _build(): + root = Path(__file__).resolve().parents[1] + modules = root / "gamemodules" + helpers = _helper_protocols(root / "utils" / "valve_server.py") + from server.module_catalog import load_default_module_catalog + + protocols = {} + ports = {} + games = [] + supported = [] + known = set() + for game_id in load_default_module_catalog().canonical_modules: + path = _module_file(modules, game_id) + if path is None: + continue + classified = _classify(path, helpers, frozenset()) + protocol = _select(classified) + label = classified["label"] or _fallback_name(game_id) + port = _default_query_port(path, classified["files"]) + known.add(game_id) + supported.append((game_id, protocol, label, port)) + if protocol is None: + continue + protocols[game_id] = protocol + ports[game_id] = port + games.append((game_id, protocol, label)) + games.sort(key=lambda item: (item[2].casefold(), item[0])) + supported.sort(key=lambda item: (item[2].casefold(), item[0])) + return { + "protocols": protocols, + "ports": ports, + "games": tuple(games), + "supported": tuple(supported), + "known": frozenset(known), + } + + +def _select(classified): + info, query, valve = classified["get_info_address"], classified["get_query_address"], classified["valve"] + if info in STRUCTURED: + return info + if info == "none": + return query if query in STRUCTURED else None + if query in STRUCTURED: + return query + if info == "missing" and query == "missing" and valve: + return "a2s" + return None + + +def _literal_port(node): + if ( + isinstance(node, ast.Constant) + and isinstance(node.value, int) + and not isinstance(node.value, bool) + and 1 <= node.value <= 65535 + ): + return node.value + if isinstance(node, ast.Constant) and isinstance(node.value, str) and node.value.isdigit(): + value = int(node.value) + if 1 <= value <= 65535: + return value + return None + + +def _one(values): + return next(iter(values)) if len(values) == 1 else None + + +def _call_name(node): + func = node.func + if isinstance(func, ast.Name): + return func.id + if isinstance(func, ast.Attribute): + return func.attr + return None + + +def _constants(tree): + found = {} + for node in tree.body: + if isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name): + value = _literal_port(node.value) + if value is not None: + found[node.targets[0].id] = value + return found + + +def _port_value(node, constants): + direct = _literal_port(node) + if direct is not None: + return direct + if isinstance(node, ast.Name): + return constants.get(node.id) + return None + + +def _ports_in_tree(tree): + """One file's query port. The module's own file wins over a file it imports.""" + constants = _constants(tree) + queryports, valves, defaults, gameports, examples = set(), set(), set(), set(), set() + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + if _call_name(node) == "define_valve_server_module": + chosen = None + for keyword in node.keywords: + if keyword.arg == "port": + chosen = _port_value(keyword.value, constants) + valves.add(27015 if chosen is None else chosen) + for keyword in node.keywords: + if keyword.arg == "default_port": + value = _port_value(keyword.value, constants) + if value is not None: + defaults.add(value) + elif keyword.arg == "port_example": + value = _port_value(keyword.value, constants) + if value is not None: + examples.add(value) + if ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "get" + and len(node.args) >= 2 + and isinstance(node.args[0], ast.Constant) + ): + value = _port_value(node.args[1], constants) + if value is None: + continue + if node.args[0].value == "queryport": + queryports.add(value) + elif node.args[0].value == "port": + gameports.add(value) + return _one(queryports) or _one(valves) or _one(defaults) or _one(gameports) or _one(examples) + + +def _default_query_port(primary, files): + """Read the usual query port from the game module, then from the hook it imports.""" + own = _ports_in_tree(ast.parse(primary.read_text(encoding="utf-8"))) + if own is not None: + return own + inherited = set() + for path in files: + if path == primary: + continue + value = _ports_in_tree(ast.parse(path.read_text(encoding="utf-8"))) + if value is not None: + inherited.add(value) + return _one(inherited) + + +def _module_file(modules, game_id): + base = modules.joinpath(*game_id.split(".")) + for candidate in (base.with_suffix(".py"), base / "main.py", base / "__init__.py"): + if candidate.is_file(): + return candidate + return None + + +def _helper_protocols(path): + tree = ast.parse(path.read_text(encoding="utf-8")) + found = {} + for node in tree.body: + if isinstance(node, ast.FunctionDef): + literals, aliases = _returns(node) + if literals and not aliases: + found[node.name] = set(literals) + return found + + +def _classify(path, helpers, stack): + if path in stack: + return {hook: "missing" for hook in _HOOKS} | {"valve": False, "label": "", "files": frozenset()} + tree = ast.parse(path.read_text(encoding="utf-8")) + local = {} + for node in tree.body: + if isinstance(node, ast.FunctionDef) and node.name in _HOOKS: + local[node.name] = _returns(node) + imported = {} + files = {path} + for node in tree.body: + if not isinstance(node, ast.ImportFrom) or node.module is None: + continue + names = {alias.name for alias in node.names} + if "*" not in names and not names.intersection(_HOOKS): + continue + target = _relative_module(path, node.module, node.level) + if target is None: + continue + parent = _classify(target, helpers, stack | {path}) + files.update(parent["files"]) + for hook in _HOOKS: + if hook not in local and (hook in names or "*" in names): + imported.setdefault(hook, parent[hook]) + classified = { + "valve": _calls(tree, "define_valve_server_module"), + "label": _label(tree), + "files": frozenset(files), + } + for hook in _HOOKS: + if hook in local: + protocols = _resolve(hook, local, helpers, frozenset()) + classified[hook] = _pick(protocols) + else: + classified[hook] = imported.get(hook, "missing") + return classified + + +def _returns(function): + literals, aliases = set(), set() + for node in ast.walk(function): + if not isinstance(node, ast.Return) or node.value is None: + continue + value = node.value + if isinstance(value, ast.Tuple) and len(value.elts) >= 3 and isinstance(value.elts[2], ast.Constant): + if isinstance(value.elts[2].value, str): + literals.add(value.elts[2].value) + continue + name = _called_name(value) + if name: + aliases.add(name) + return literals, aliases + + +def _called_name(value): + if not isinstance(value, ast.Call): + return None + func = value.func + if isinstance(func, ast.Name): + return func.id + if isinstance(func, ast.Attribute): + return func.attr + if ( + isinstance(func, ast.Call) + and isinstance(func.func, ast.Name) + and func.func.id == "_package_override" + and len(func.args) >= 2 + and isinstance(func.args[1], ast.Name) + ): + return func.args[1].id + return None + + +def _resolve(name, local, helpers, seen): + if name in seen: + return {"?"} + if name not in local: + return set(helpers[name]) if name in helpers else {"?"} + literals, aliases = local[name] + protocols = set(literals) + for alias in aliases: + if alias in local: + protocols |= _resolve(alias, local, helpers, seen | {name}) + elif alias in helpers: + protocols |= helpers[alias] + else: + protocols.add("?") + return protocols + + +def _pick(protocols): + if not protocols or "?" in protocols or not protocols <= STRUCTURED or len(protocols) != 1: + return "none" + return next(iter(protocols)) + + +def _calls(tree, name): + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + if (isinstance(func, ast.Name) and func.id == name) or (isinstance(func, ast.Attribute) and func.attr == name): + return True + return False + + +def _fallback_name(game_id): + if game_id.startswith("minecraft."): + return "Minecraft " + game_id.split(".", 1)[1].replace("_", " ").title() + return game_id + + +def _label(tree): + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + called = (isinstance(func, ast.Name) and func.id == "define_valve_server_module") or ( + isinstance(func, ast.Attribute) and func.attr == "define_valve_server_module" + ) + if not called: + continue + for keyword in node.keywords: + if keyword.arg == "game_name" and isinstance(keyword.value, ast.Constant): + if isinstance(keyword.value.value, str) and keyword.value.value.strip(): + return keyword.value.value.strip() + text = ast.get_docstring(tree) or "" + first = text.split(".", 1)[0].strip() + marker = "-specific" + if marker in first: + return first.split(marker, 1)[0].strip() or "" + for suffix in ( + " dedicated server lifecycle helpers", + " dedicated server helpers", + " server lifecycle helpers", + " lifecycle helpers", + ): + if first.endswith(suffix): + return first[: -len(suffix)].strip() + return "" + + +def _relative_module(path, module, level): + if level < 1: + return None + base = path.parent + for _ in range(level - 1): + base = base.parent + parts = module.split(".") if module else [] + candidate = base.joinpath(*parts) + for item in (candidate.with_suffix(".py"), candidate / "__init__.py", candidate / "main.py"): + if item.is_file(): + return item + return None diff --git a/src/server/public_query.py b/src/server/public_query.py new file mode 100644 index 00000000..4636efe3 --- /dev/null +++ b/src/server/public_query.py @@ -0,0 +1,89 @@ +# pylint: disable=unidiomatic-typecheck +"""Anonymous status queries. No Server instances, discovery, credentials or fallback. + +Publication profiles stay the three games the local agent may publish. Every +other game with an anonymous status protocol can still be queried here. TCP, +UDP, and authenticated RCON are not status queries. +""" + +from types import MappingProxyType +import ipaddress +from utils import query +from server.public_protocols import IPV4_ONLY, protocol_for + +PROFILES = MappingProxyType({"teamfortress2": "a2s", "minecraft.vanilla": "slp", "minecraft.paper": "slp"}) +_OBSERVED = frozenset({"a2s", "slp", "quake", "quake2", "quakeworld", "bedrock"}) + + +def _bounded_text(value): + if not isinstance(value, str): + return "" + return "".join(c for c in value if ord(c) >= 32)[:256] + + +def _with_text(result, key, value): + text = _bounded_text(value) + if text: + result[key] = text + return result + + +def _quake_result(raw): + result = {"players": raw["players"], "capacity": raw["max_players"]} + _with_text(result, "name", raw.get("name", "")) + _with_text(result, "map", raw.get("map", "")) + if "name" not in result: + result["name"] = "" + return result + + +def query_public(game_id, address, port, *, virtual_host=None, timeout=5.0): + """Connect to one caller-validated numerical address, preserving SLP virtual host.""" + protocol = protocol_for(game_id) + if protocol not in _OBSERVED: + raise query.QueryError("Unsupported public profile") + try: + ip = ipaddress.ip_address(address) + except ValueError as exc: + raise query.QueryError("Public queries require a numerical address") from exc + if type(port) is not int or not 1024 <= port <= 65535: + raise query.QueryError("Destination port outside public profile policy") + if not 0 < timeout <= 5: + raise query.QueryError("Invalid public query budget") + if protocol in IPV4_ONLY and ip.version != 4: + if protocol == "a2s": + raise query.QueryError("A2S public profile supports IPv4 only") + raise query.QueryError("This public query supports IPv4 only") + if protocol == "a2s": + raw = query.parse_a2s_info(query.a2s_info(str(ip), port, timeout=timeout)) + if raw is None: + raise query.QueryError("Invalid A2S statistics") + result = {"players": raw["players"], "capacity": raw["max_players"]} + _with_text(result, "name", raw["name"]) + _with_text(result, "map", raw["map"]) + if "name" not in result: + result["name"] = "" + elif protocol == "slp": + raw = query.slp_info(str(ip), port, timeout=timeout, virtual_host=virtual_host) + result = {"players": raw["players_online"], "capacity": raw["players_max"]} + _with_text(result, "name", raw["description"]) + _with_text(result, "version", raw["version"]) + if "name" not in result: + result["name"] = "" + elif protocol == "bedrock": + raw = query.bedrock_info(str(ip), port, timeout=timeout) + result = {"players": raw["players_online"], "capacity": raw["players_max"]} + _with_text(result, "name", raw.get("name", "")) + _with_text(result, "map", raw.get("map", "")) + _with_text(result, "version", raw.get("version", "")) + if "name" not in result: + result["name"] = "" + else: + reader = {"quake": query.quake_status, "quake2": query.quake2_status, "quakeworld": query.quakeworld_status}[ + protocol + ] + result = _quake_result(reader(str(ip), port, timeout=timeout)) + players, capacity = result["players"], result["capacity"] + if type(players) is not int or type(capacity) is not int or not 0 <= players <= capacity <= 100000: + raise query.QueryError("Invalid player counts") + return result diff --git a/src/utils/query.py b/src/utils/query.py index ce0183b9..080d309a 100644 --- a/src/utils/query.py +++ b/src/utils/query.py @@ -26,9 +26,10 @@ TCP ping on the main port. """ -# pylint: disable=too-many-lines +# pylint: disable=too-many-lines,unidiomatic-typecheck import bz2 +import zlib import json import re import socket @@ -315,90 +316,91 @@ def source_rcon_info( raise QueryError("Source RCON query timed out after retries") from last_error -def _recv_a2s_packet(sock): - """Receive one A2S UDP response, reassembling multi-packet fragments. - - Uses a 65535-byte buffer (maximum UDP payload) so that large single-packet - responses are never silently truncated. Multi-packet responses (indicated - by the ``\\xfe\\xff\\xff\\xff`` header) are fully reassembled from all - fragments before returning. - - Returns the raw payload bytes with the 4-byte simple-packet header stripped. - """ - data = sock.recv(65535) - if data[:4] == _HEADER_SIMPLE: - return data[4:] - if data[:4] == _HEADER_MULTI: - # Fragment header layout (all little-endian): - # uint32 message_id – high bit set when payload is bz2-compressed - # uint8 total – total number of fragments - # uint8 frag_id – zero-based index of this fragment - # uint16 mtu – max fragment size (informational) - # If compressed, two more fields follow before the payload: - # uint32 decompressed_size - # uint32 crc32 - _FRAG_HDR = struct.Struct(" max_bytes: + raise QueryError("A2S response exceeds byte limit") + return data[4:] + if data[:4] != _HEADER_MULTI or len(data) < 12: + raise QueryError("A2S query failed: Unexpected response header") + message_id, total, index, mtu = struct.unpack_from("= total or not 1 <= mtu <= 65535: + raise QueryError("Invalid A2S fragment header") + key = (message_id, total, mtu) + if identity is not None and identity != key: + raise QueryError("Mismatched A2S fragment identity") + identity = key + if index in fragments: + raise QueryError("Duplicate A2S fragment") + payload = data[12:] + if message_id & 0x80000000 and index == 0: + if len(payload) < 8: + raise QueryError("Missing A2S compression metadata") + expected_size, checksum = struct.unpack_from(" max_bytes: + raise QueryError("A2S decompressed size exceeds byte limit") + payload = payload[8:] + total_size += len(payload) + if total_size > max_bytes or len(payload) > mtu: + raise QueryError("A2S fragments exceed byte limit") + fragments[index] = payload + if len(fragments) == total: + assembled = b"".join(fragments[i] for i in range(total)) + if message_id & 0x80000000: + try: + decompressor = bz2.BZ2Decompressor() + assembled = decompressor.decompress(assembled, max_length=max_bytes + 1) + if (len(assembled) > max_bytes or not decompressor.eof or decompressor.unused_data + or len(assembled) != expected_size or zlib.crc32(assembled) != checksum): + raise QueryError("Invalid bounded A2S compressed response") + except (OSError, EOFError, ValueError) as exc: + raise QueryError("Invalid A2S compression") from exc + _remaining(deadline) + return assembled[4:] if assembled[:4] == _HEADER_SIMPLE else assembled + raise QueryError("Too many A2S fragments") def a2s_info(host, port, timeout=2.0, phase2_timeout=None): - """Send an A2S_INFO packet to *host*:*port* and return the raw response. - - Handles the modern Steam challenge-response handshake: if the server - responds with a challenge (header byte 0x41) the request is re-sent with - the 4-byte challenge appended and the final info response is returned. - Multi-packet (fragmented) responses are automatically reassembled. - - *timeout* is the socket timeout for Phase 1 (waiting for the initial - challenge or response). *phase2_timeout* is the socket timeout for - Phase 2 (waiting for the info response after sending the challenge); - defaults to *timeout* when not specified. Use a longer *phase2_timeout* - when querying Source/SRCDS servers in hibernation mode: Phase 1 catches - the server during a brief wake window, while Phase 2 must wait an entire - hibernation cycle for the server to wake again. - - Raises :class:`QueryError` on timeout, socket error, or an unexpected - response header. + """Query one connected UDP endpoint with a total monotonic byte/time budget. + + Explicit longer phase-two budgets support local hibernating Source servers. + Public callers supply only timeout, giving a five-second total budget. """ - if phase2_timeout is None: - phase2_timeout = timeout + deadline = time.monotonic() + (timeout + phase2_timeout if phase2_timeout is not None else timeout) try: with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: - sock.settimeout(timeout) + sock.settimeout(min(timeout, _remaining(deadline))) + sock.connect((host, int(port))) sock.sendto(_A2S_REQUEST, (host, int(port))) - data = _recv_a2s_packet(sock) - # Some modern servers respond with a challenge before sending info. - if len(data) >= 5 and data[0] == _A2S_CHALLENGE_TYPE: - challenge = data[1:5] - sock.settimeout(phase2_timeout) - sock.sendto(_A2S_REQUEST + challenge, (host, int(port))) - data = _recv_a2s_packet(sock) + data = _recv_a2s_packet(sock, deadline) + if data and data[0] == _A2S_CHALLENGE_TYPE: + if len(data) != 5: + raise QueryError("Invalid A2S challenge") + sock.settimeout(_remaining(deadline)) + sock.sendto(_A2S_REQUEST + data[1:5], (host, int(port))) + data = _recv_a2s_packet(sock, deadline) except OSError as exc: raise QueryError("A2S query failed: " + str(exc)) from exc if not data or data[0] != _A2S_RESPONSE_TYPE: raise QueryError("Unexpected A2S response header") - return b"\xff\xff\xff\xff" + data + return _HEADER_SIMPLE + data def _read_cstring(data, pos): @@ -538,94 +540,119 @@ def _int_field(index, label): } -def slp_info(host, port, timeout=5.0): - """Send a Minecraft Server List Ping to *host*:*port* and return a dict. - - The returned dict contains at minimum: ``description`` (str), - ``players_online`` (int), ``players_max`` (int), ``version`` (str). - When online players are listed, ``player_names`` (list[str]) is also - included. - - Raises :class:`QueryError` on connection failure or unexpected response. +def _encode_varint(value): + buf = bytearray() + while True: + byte = value & 0x7F + value >>= 7 + buf.append(byte | (0x80 if value else 0)) + if not value: + return bytes(buf) + + +def _json_depth_bounded(payload, maximum=16): + depth = 0 + in_string = escaped = False + for char in payload: + if in_string: + if escaped: + escaped = False + elif char == 92: + escaped = True + elif char == 34: + in_string = False + elif char == 34: + in_string = True + elif char in (91, 123): + depth += 1 + if depth > maximum: + raise QueryError("SLP JSON nesting exceeds limit") + elif char in (93, 125): + depth -= 1 + + +def slp_info(host, port, timeout=5.0, *, virtual_host=None, max_bytes=256 * 1024): + """Read exactly one bounded SLP frame under a total deadline. + + A public worker connects to a validated numeric host and separately supplies + the original hostname for Minecraft's virtual-host handshake. """ - import json as _json + deadline = time.monotonic() + timeout - def _encode_varint(value): - buf = bytearray() - while True: - temp = value & 0x7F - value >>= 7 - if value != 0: - temp |= 0x80 - buf.append(temp) - if value == 0: - return bytes(buf) - - def _read_varint(sock): - num_read = 0 - result = 0 - while True: - raw = sock.recv(1) - if not raw: - raise QueryError("Connection closed while reading VarInt") - byte = raw[0] - result |= (byte & 0x7F) << (7 * num_read) - num_read += 1 - if num_read > 5: + def recv_exact(sock, count): + result = bytearray() + while len(result) < count: + sock.settimeout(_remaining(deadline)) + chunk = sock.recv(count - len(result)) + _remaining(deadline) + if not chunk: + raise QueryError("Connection closed while reading SLP payload") + result.extend(chunk) + return bytes(result) + + def read_varint(read): + value = 0 + for i in range(5): + byte = read(1)[0] + if i == 4 and byte > 7: raise QueryError("VarInt too large") - if (byte & 0x80) == 0: - return result - + value |= (byte & 127) << (i * 7) + if not byte & 128: + return value + raise QueryError("VarInt too large") + + host_bytes = (virtual_host if virtual_host is not None else host).encode("utf-8") + if not 1 <= len(host_bytes) <= 255: + raise QueryError("Invalid SLP virtual host") try: - with socket.create_connection((host, int(port)), timeout=timeout) as sock: - host_bytes = host.encode("utf-8") - handshake = b"".join([ - _encode_varint(0), # packet id 0 = handshake - _encode_varint(760), # protocol version (servers accept any) - _encode_varint(len(host_bytes)), - host_bytes, - struct.pack(">H", int(port)), - _encode_varint(1), # next state = status - ]) + with socket.create_connection((host, int(port)), timeout=_remaining(deadline)) as sock: + sock.settimeout(_remaining(deadline)) + handshake = (b"\x00" + _encode_varint(760) + _encode_varint(len(host_bytes)) + host_bytes + + struct.pack(">H", int(port)) + b"\x01") sock.sendall(_encode_varint(len(handshake)) + handshake) - sock.sendall(_encode_varint(1) + _encode_varint(0)) # status request - _read_varint(sock) # packet length (ignored) - packet_id = _read_varint(sock) - if packet_id != 0: - raise QueryError(f"Unexpected SLP packet id: {packet_id}") - payload_length = _read_varint(sock) - payload = bytearray() - while len(payload) < payload_length: - chunk = sock.recv(payload_length - len(payload)) - if not chunk: - raise QueryError("Connection closed while reading SLP payload") - payload.extend(chunk) - except QueryError: - raise + sock.settimeout(_remaining(deadline)) + sock.sendall(b"\x01\x00") + size = read_varint(lambda n: recv_exact(sock, n)) + if not 3 <= size <= max_bytes: + raise QueryError("SLP packet length exceeds limit") + frame = recv_exact(sock, size) except OSError as exc: raise QueryError("SLP query failed: " + str(exc)) from exc - - try: - raw = _json.loads(payload.decode("utf-8")) - except Exception as exc: - raise QueryError("Failed to parse SLP JSON: " + str(exc)) from exc - + position = 0 + + def frame_read(n): + nonlocal position + if position + n > len(frame): + raise QueryError("Truncated SLP frame") + part = frame[position:position + n] + position += n + return part + + if read_varint(frame_read) != 0: + raise QueryError("Unexpected SLP packet id") + size = read_varint(frame_read) + if size != len(frame) - position: + raise QueryError("Invalid SLP string framing") + payload = frame_read(size) + _json_depth_bounded(payload) try: + raw = json.loads(payload.decode("utf-8")) + players, capacity = raw["players"]["online"], raw["players"]["max"] + if type(players) is not int or type(capacity) is not int or not 0 <= players <= capacity <= 100000: + raise QueryError("Invalid SLP player counts") desc = raw.get("description", "") if isinstance(desc, dict): desc = desc.get("text", "") - result = { - "description": str(desc), - "players_online": int(raw["players"]["online"]), - "players_max": int(raw["players"]["max"]), - "version": str(raw["version"]["name"]), - } - sample = raw.get("players", {}).get("sample", []) + result = {"description": desc if type(desc) is str else "", "players_online": players, + "players_max": capacity, "version": raw["version"]["name"]} + sample = raw["players"].get("sample", []) if sample: - result["player_names"] = [p["name"] for p in sample if "name" in p] + result["player_names"] = [p["name"] for p in sample[:256] + if isinstance(p, dict) and isinstance(p.get("name"), str)] + _remaining(deadline) return result - except (KeyError, TypeError, ValueError) as exc: - raise QueryError("Unexpected SLP response structure: " + str(exc)) from exc + except (KeyError, TypeError, ValueError, UnicodeError, RecursionError) as exc: + raise QueryError("Unexpected SLP response structure") from exc def _format_http_host(host): @@ -920,7 +947,66 @@ def _ts3_unescape(value): ) -def ts3_serverinfo(host, port, timeout=5.0, login=None): +def ts3_escape(value): + """Encode one ServerQuery argument without putting a raw newline on the wire.""" + if type(value) is not str or any(ord(char) < 32 for char in value): + raise QueryError("TeamSpeak name contains a control character") + return value.replace("\\", "\\\\").replace(" ", "\\s").replace("|", "\\p").replace("/", "\\/") + + +def ts3_set_name(host, port, name, *, login, timeout=5.0, virtual_server=1): + """Rename virtual server 1. The caller keeps the previous name and restores it.""" + if login is None or len(login) != 2: + raise QueryError("TeamSpeak rename requires the local ServerQuery login") + if type(name) is not str or not name or len(name.encode()) > 64: + raise QueryError("TeamSpeak server name must be 1 to 64 bytes") + if type(virtual_server) is not int or virtual_server < 1: + raise QueryError("TeamSpeak virtual server id is invalid") + try: + conn = socket.create_connection((host, int(port)), timeout=timeout) + except OSError as exc: + raise QueryError("TS3 ServerQuery connection failed: " + str(exc)) from exc + + def _recvline(): + buf = bytearray() + while True: + chunk = conn.recv(1) + if not chunk: + raise QueryError("TS3 ServerQuery: connection closed unexpectedly") + buf.extend(chunk) + if buf.endswith(b"\n"): + return buf.decode("utf-8", errors="replace").strip() + + def _send(cmd): + time.sleep(0.35) + conn.sendall((cmd + "\n").encode("utf-8")) + + def _read_until_ok(): + while True: + line = _recvline() + if line.startswith("error "): + if "id=0" not in line: + raise QueryError("TS3 ServerQuery error: " + line) + return + + try: + banner = _recvline() + if not banner.startswith("TS3"): + raise QueryError("TS3 ServerQuery: unexpected banner: " + banner) + _recvline() + user, password = login + _send("login %s %s" % (ts3_escape(user), ts3_escape(password))) + _read_until_ok() + _send("use %d" % virtual_server) + _read_until_ok() + _send("serveredit virtualserver_name=%s" % ts3_escape(name)) + _read_until_ok() + _send("quit") + finally: + conn.close() + + +def ts3_serverinfo(host, port, timeout=5.0, login=None, details=False): """Connect to a TeamSpeak 3 ServerQuery interface and return server info. Opens a raw TCP session to the TS3 ServerQuery port (default 10011), @@ -928,13 +1014,17 @@ def ts3_serverinfo(host, port, timeout=5.0, login=None): with the following keys: * ``name`` — virtual server name (str) - * ``clients_online`` — current number of connected clients (int) + * ``clients_online`` — voice clients, excluding ServerQuery connections (int) + * ``query_clients`` — ServerQuery connections, including this one (int) * ``max_clients`` — maximum allowed clients (int) * ``uptime`` — server uptime in seconds (int) * ``platform`` — host platform string (str) * ``version`` — server software version (str) * ``channels`` — list of channel dicts, each with ``id`` (int) and - ``name`` (str) + ``name`` (str). When *details* is true each channel also has + ``parent_id`` and ``order``, and the result includes ``clients`` + (nickname, channel id, and client type only) plus ``roster_error`` + when either list is refused. Optional *login* is a ``(username, password)`` tuple. When provided a ``login`` command is sent before ``use 1``, which is required for @@ -1013,9 +1103,24 @@ def _parse_kv(line): _send("serverinfo") si_lines = _read_until_ok() - # Retrieve channel list. - _send("channellist") - cl_lines = _read_until_ok() + # Retrieve channel list. A detail read keeps the count when this list is refused. + roster_error = False + client_lines = [] + try: + _send("channellist") + cl_lines = _read_until_ok() + except QueryError: + if not details: + raise + roster_error = True + cl_lines = [] + if details and not roster_error: + try: + _send("clientlist") + client_lines = _read_until_ok() + except QueryError: + roster_error = True + client_lines = [] _send("quit") finally: @@ -1032,9 +1137,15 @@ def _int(d, k): except (ValueError, TypeError): return 0 + # virtualserver_clientsonline includes the ServerQuery session reading it. + raw_clients = _int(si, "virtualserver_clientsonline") + query_clients = _int(si, "virtualserver_queryclientsonline") + if query_clients > raw_clients: + raise QueryError("TeamSpeak query client count exceeds the online count") result = { "name": si.get("virtualserver_name", ""), - "clients_online": _int(si, "virtualserver_clientsonline"), + "clients_online": raw_clients - query_clients, + "query_clients": query_clients, "max_clients": _int(si, "virtualserver_maxclients"), "uptime": _int(si, "virtualserver_uptime"), "platform": si.get("virtualserver_platform", ""), @@ -1047,9 +1158,33 @@ def _int(d, k): for entry in cl_lines[0].split("|"): ch = _parse_kv(entry) try: - channels.append({"id": int(ch.get("cid", 0)), "name": ch.get("channel_name", "")}) + channels.append( + { + "id": int(ch.get("cid", 0)), + "parent_id": int(ch.get("pid", 0)), + "order": int(ch.get("channel_order", 0)), + "name": ch.get("channel_name", ""), + } + ) except (ValueError, TypeError): pass result["channels"] = channels + if details: + clients = [] + if client_lines: + for entry in client_lines[0].split("|"): + client = _parse_kv(entry) + try: + clients.append( + { + "nickname": client.get("client_nickname", ""), + "channel_id": int(client.get("cid", 0)), + "client_type": int(client.get("client_type", 1)), + } + ) + except (ValueError, TypeError): + pass + result["clients"] = clients + result["roster_error"] = roster_error return result diff --git a/src/utils/state_io.py b/src/utils/state_io.py index d0b38a81..529776c3 100644 --- a/src/utils/state_io.py +++ b/src/utils/state_io.py @@ -156,3 +156,45 @@ def _after_fork(): if hasattr(os, "register_at_fork"): os.register_at_fork(after_in_child=_after_fork) + + +def held_descriptor(filename): + """Return this thread's held POSIX lock for a trusted bounded child helper.""" + path = str(Path(filename).resolve()) + ".lock" + with _REGISTRY_MUTEX: + entry = _LOCKS.get(path) + if entry is None or not entry.mutex.acquire(blocking=False): + return None + try: + return entry.descriptor if entry.depth else None + finally: + entry.mutex.release() + + +@contextmanager +def inherited_state_lock(filename, descriptor): + """Adopt an inherited open-file lock without releasing the parent's lock. + + The inode must match the selected datastore sidecar. Nested datastore calls + reuse ownership. The descriptor is not inherited by further command children. + Only the internal helper uses this; ordinary commands acquire their own lock. + """ + path = str(Path(filename).resolve()) + ".lock" + expected, actual = os.stat(path), os.fstat(descriptor) + if (expected.st_dev, expected.st_ino) != (actual.st_dev, actual.st_ino): + raise ValueError("Inherited lock does not match selected server") + os.set_inheritable(descriptor, False) + _try_lock(descriptor) + entry = _LockState(depth=1, descriptor=descriptor) + with _REGISTRY_MUTEX: + if path in _LOCKS and _LOCKS[path].depth: + raise ValueError("Lock already registered") + _LOCKS[path] = entry + with entry.mutex: + try: + yield + finally: + with _REGISTRY_MUTEX: + _LOCKS.pop(path, None) + # Do not LOCK_UN: this open-file description is shared with the parent. + os.close(descriptor) diff --git a/tests/integration_tests/test_first_run_tour.py b/tests/integration_tests/test_first_run_tour.py new file mode 100644 index 00000000..e6504f42 --- /dev/null +++ b/tests/integration_tests/test_first_run_tour.py @@ -0,0 +1,150 @@ +"""Real CLI tour checks with isolated config; no game downloads or launches.""" + +import errno +import json +import os +from pathlib import Path +import select +import subprocess +import sys +import time + +import pytest + +REPO = Path(__file__).resolve().parents[2] + + +@pytest.fixture +def cli_home(tmp_path): + home = tmp_path / 'operator' + config = tmp_path / 'alphagsm.conf' + config.write_text('[core]\nalphagsm_path = %s\nuserconf = %s\n[server]\ndatapath = %s\n' % + (home, home, home / 'conf')) + env = dict(os.environ, ALPHAGSM_CONFIG_LOCATION=str(config), + ALPHAGSM_USERCONFIG_LOCATION=str(tmp_path / 'missing-user.conf')) + return home, env + + +def run_cli(env, *args): + return subprocess.run([sys.executable, str(REPO / 'alphagsm'), *args], env=env, + cwd=REPO, capture_output=True, text=True, check=False, timeout=15) + + +def run_terminal(env, args, answers): + pty = pytest.importorskip('pty', reason='Interactive terminal coverage requires POSIX PTY support') + master, slave = pty.openpty() + process = subprocess.Popen([sys.executable, str(REPO / 'alphagsm'), *args], env=env, + cwd=REPO, stdin=slave, stdout=slave, stderr=slave) + os.close(slave) + output = bytearray() + try: + os.write(master, answers.encode()) + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + ready, _, _ = select.select([master], [], [], 0.1) + if ready: + try: + chunk = os.read(master, 8192) + except OSError as exc: + if exc.errno == errno.EIO: + break + raise + if not chunk: + break + output.extend(chunk) + elif process.poll() is not None: + break + return process.wait(timeout=2), output.decode(errors='replace') + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + os.close(master) + + +def test_piped_invocation_keeps_help_behavior_without_state(cli_home): + home, env = cli_home + result = run_cli(env) + assert result.returncode == 2 + assert 'Start the optional setup tour' not in result.stdout + assert not home.exists() + + +def test_explicit_skip_is_noninteractive_and_replay_is_documented(cli_home): + home, env = cli_home + result = run_cli(env, '--skip-welcome') + assert result.returncode == 0, result.stderr + assert '--welcome' in result.stdout + assert (home / 'welcome-v1.done').read_text() == 'skipped\n' + assert not (home / 'agent').exists() and not (home / 'conf').exists() + + +def test_real_terminal_first_run_skip_and_explicit_replay(cli_home): + home, env = cli_home + code, output = run_terminal(env, [], '\n') + assert code == 0, output + assert 'Start the optional setup tour' in output + assert (home / 'welcome-v1.done').read_text() == 'skipped\n' + code, output = run_terminal(env, [], '') + assert code == 2 and 'Start the optional setup tour' not in output + code, output = run_terminal(env, ['--welcome'], '\n') + assert code == 0 and 'Welcome to AlphaGSM' in output + assert (home / 'welcome-v1.done').read_text() == 'skipped\n' + assert not (home / 'agent').exists() + + +def test_real_tour_creates_only_approved_local_record_and_skips_downloads(cli_home): + home, env = cli_home + code, output = run_terminal(env, ['--welcome'], '1\nminecraft\nworld\ny\nn\n\n') + assert code == 0, output + record = json.loads((home / 'conf/world.json').read_text()) + assert record['module'] == 'minecraft.vanilla' + assert 'Run setup now?' in output + assert not (home / 'agent').exists() + assert not (home / 'servers').exists() + + +def test_real_tour_preserves_existing_server_bytes(cli_home): + home, env = cli_home + (home / 'conf').mkdir(parents=True) + record = home / 'conf/world.json' + record.write_text('{"module":"minecraft.paper","world":"keep-me"}') + before = record.read_bytes() + code, output = run_terminal(env, ['--welcome'], '1\ntf2\nworld\n\n') + assert code == 0, output + assert 'already exists' in output and record.read_bytes() == before + assert not (home / 'agent').exists() + + +def test_explicit_tour_requires_a_terminal_but_help_and_version_do_not(cli_home): + home, env = cli_home + result = run_cli(env, '--welcome') + assert result.returncode == 2 and 'interactive input and output' in result.stderr + for args in [('--welcome', '--help'), ('--version',), ('--help',)]: + result = run_cli(env, *args) + assert result.returncode == 0, result.stderr + assert not home.exists() + + +def test_server_named_welcome_still_uses_normal_dispatch(cli_home): + home, env = cli_home + result = run_cli(env, 'welcome', 'create', 'minecraft.vanilla') + assert result.returncode == 0, result.stderr + assert (home / 'conf/welcome.json').is_file() + assert not (home / 'welcome-v1.done').exists() + + +@pytest.mark.parametrize('tag', ['all', 'ALL', '1', '02', 'agent', 'self-update', 'start']) +def test_real_tour_rejects_cli_selectors_and_reserved_names(cli_home, tag): + home, env = cli_home + (home / 'conf').mkdir(parents=True) + record = home / 'conf/world.json' + record.write_text('{"module":"minecraft.vanilla","port":25565,"keep":"existing world"}') + before = record.read_bytes() + code, output = run_terminal(env, ['--welcome'], '1\ntf2\n' + tag + '\ny\nn\n\n') + assert code == 0, output + assert 'reserved' in output, output + assert 'Create a local server record:' not in output + assert record.read_bytes() == before + assert sorted(path.name for path in (home / 'conf').glob('*.json')) == ['world.json'] + assert not (home / 'agent').exists() diff --git a/tests/integration_tests/test_minecraft_vanilla.py b/tests/integration_tests/test_minecraft_vanilla.py index 56822a77..6b17cec2 100644 --- a/tests/integration_tests/test_minecraft_vanilla.py +++ b/tests/integration_tests/test_minecraft_vanilla.py @@ -256,6 +256,57 @@ def test_minecraft_vanilla_download_install_and_start(tmp_path): assert _info_data.get("players_max") == 20, ( f"Expected max-players=20 (Minecraft vanilla default): {_info_data!r}" ) + + if os.environ.get("ALPHAGSM_VALIDATE_AGENT") == "1": + from unittest.mock import patch + + with patch.dict(os.environ, {"ALPHAGSM_CONFIG_LOCATION": str(config_path)}): + # The optional agent uses the same fixed lifecycle via bounded helpers, + # with durable no-replay intent and the normal local CLI lock. + from datetime import datetime, timedelta, timezone + from agent.config import Config + from agent.cli import run as agent_cli + from agent.journal import Journal + from agent.lifecycle import AlphaRuntime, Executor + import server.server as server_module + from utils.state_io import state_lock + + cfg = Config(home_dir / "agent") + cfg.enroll("https://control.example", { + "schema_version": 1, "installation_id": "integration-install", + "credential_generation": 1, "reporting": None, + "management": {"key_id": "integration-key", "secret": "x" * 43}, + }) + # Infer the real configured game/port without querying private fields. + with patch.object(server_module, "DATAPATH", str(home_dir / "conf")), \ + patch("builtins.input", side_effect=["", "yes"]): + assert agent_cli(["bind", server_name, "--binding-id", "integration-binding", + "--host", "game.example", "--private", "--actions", "restart", "status"], + root=cfg.root) == 0 + inferred = cfg.load()["bindings"]["integration-binding"] + assert inferred["metadata"]["game_id"] == module_name + assert inferred["metadata"]["port"] == port + assert inferred["publication"] is False + assert cfg.load()["management_enabled"] is False + cfg.enable(management=True) + runtime = AlphaRuntime() + runtime.lock_path = lambda tag: home_dir / "conf" / (tag + ".json") + if os.environ.get("ALPHAGSM_BINARY"): + runtime.helper_command = lambda tag, action: [ + os.environ["ALPHAGSM_BINARY"], "--_agent-runtime", tag, action, + ] + now = datetime.now(timezone.utc) + job = {"schema_version": 1, "job_id": "integration-restart", + "installation_id": "integration-install", "binding_id": "integration-binding", + "binding_epoch": 1, "action": "restart", "policy_version": cfg.load()["policy_version"], + "credential_generation": 1, "requested_by": "integration-owner", + "issued_at": now.isoformat(), "expires_at": (now + timedelta(seconds=120)).isoformat()} + executor = Executor(cfg, Journal(cfg.root / "journal.json"), runtime, lambda value: True) + result = executor.run(job) + assert result["outcome"] == "succeeded", result + assert executor.run(job) == result + wait_for_info_protocol(env, server_name, "slp", START_TIMEOUT_SECONDS, expected_port=port) + finally: stop_result = capture_alphagsm_stop( env, server_name, sys.exc_info()[1], timeout=STOP_TIMEOUT_SECONDS diff --git a/tests/integration_tests/test_ts3server.py b/tests/integration_tests/test_ts3server.py index 0d752153..70aa746f 100644 --- a/tests/integration_tests/test_ts3server.py +++ b/tests/integration_tests/test_ts3server.py @@ -114,10 +114,9 @@ def test_ts3server_lifecycle(tmp_path): assert isinstance(_info_data["clients_online"], int), ( f"Expected clients_online integer: {_info_data!r}" ) - # TS3 counts authenticated ServerQuery connections as clients, so a - # freshly started server may report 0 or 1 depending on timing. - assert _info_data["clients_online"] <= 1, ( - f"Expected 0-1 clients on fresh server (SQ admin counts): {_info_data!r}" + # The info connection is a ServerQuery client and is not a voice user. + assert _info_data["clients_online"] == 0, ( + f"Expected no voice clients on a fresh server: {_info_data!r}" ) assert isinstance(_info_data["max_clients"], int) and _info_data["max_clients"] > 0, ( f"Expected positive max_clients: {_info_data!r}" diff --git a/tests/smoke_tests/README.md b/tests/smoke_tests/README.md index 24153ab0..5c6972a4 100644 --- a/tests/smoke_tests/README.md +++ b/tests/smoke_tests/README.md @@ -6,7 +6,23 @@ verification. Unlike the pytest-based integration tests, these scripts stream command output directly to the terminal or CI log as they run. -Current smoke test: +Current smoke tests: + +- `run_sector_alpha_agent.sh` + Exercises website enrollment/binding and API stats/execution routing, trusted + local upgrades from the old official website origin, preserved custom providers, + independent opt-ins, private credential storage, strict contracts, + local revocation, no-replay journaling, bounded lifecycle helper timeout and + hostile public protocol parsing. Guided single/bulk binding uses isolated local + inventories and mocked registration/address discovery; no public destination is contacted. + Hostname publication checks cover complete DNS/machine-IP matching, withdrawal, + repair, bounded DNS lifetimes and explicit pinned endpoint diagnostics. + Individual and wildcard binding cover distinct Minecraft/TF2 subdomains on one + public IP, per-game ports, preserved repeat runs and independent endpoint edits. + Player connection ports are approved separately from query ports, with NAT + overrides, legacy report compatibility and no new metadata before rebinding. + The Minecraft integration test also proves inferred binding, agent restart and duplicate delivery + against the selected process/Docker runtime. - `run_minecraft_vanilla.sh` Creates a temporary AlphaGSM config, downloads a vanilla Minecraft server, diff --git a/tests/smoke_tests/run_first_run_tour.sh b/tests/smoke_tests/run_first_run_tour.sh new file mode 100755 index 00000000..adbf8628 --- /dev/null +++ b/tests/smoke_tests/run_first_run_tour.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# First-run TTY/skip/replay and real CLI creation; no game downloads or launches. +set -euo pipefail +cd "$(dirname "$0")/../.." +PYTHONPATH=.:src "${PYTHON_BIN:-python3}" -m pytest \ + tests/unit_tests/core/test_welcome.py tests/integration_tests/test_first_run_tour.py -q diff --git a/tests/smoke_tests/run_sector_alpha_agent.sh b/tests/smoke_tests/run_sector_alpha_agent.sh new file mode 100755 index 00000000..8ef51de3 --- /dev/null +++ b/tests/smoke_tests/run_sector_alpha_agent.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Offline non-player-port rejection, approved player/query ports, one configured agent origin, hostname preflight and consent gates. +set -euo pipefail +cd "$(dirname "$0")/../.." +PYTHONPATH=.:src "${PYTHON_BIN:-python3}" -m pytest \ + tests/unit_tests/agent tests/unit_tests/server/test_public_query.py \ + tests/unit_tests/utils/test_query.py -q diff --git a/tests/unit_tests/agent/test_agent.py b/tests/unit_tests/agent/test_agent.py new file mode 100644 index 00000000..e9bfb542 --- /dev/null +++ b/tests/unit_tests/agent/test_agent.py @@ -0,0 +1,166 @@ +"""Opt-in storage, strict origin transport and durable no-replay execution.""" +from datetime import datetime, timezone +import json +import os +import pytest +from agent.config import Config, ConfigError +from agent.journal import Journal +from agent.lifecycle import Executor +from agent.transport import Transport +from test_contracts import job + + +def config(tmp_path): + cfg = Config(tmp_path/'agent') + cfg.enroll('https://control.example', dict(schema_version=1, installation_id='install-1', + credential_generation=1, reporting=dict(key_id='report-key', secret='x'*43), + management=dict(key_id='manage-key', secret='y'*43))) + cfg.bind('binding-1', 'my-server', 1, actions=['status','start','stop','restart'], + publication=False, metadata=dict(name='game',game_id='minecraft.vanilla',host='game.example',port=25565,capacity=20)) + cfg.enable(reporting=True, management=True) + return cfg + + +def test_teamspeak_publication_stays_a_website_choice(tmp_path): + cfg = Config(tmp_path / "agent") + cfg.enroll( + "https://control.example", + dict( + schema_version=1, + installation_id="install-1", + credential_generation=1, + reporting=dict(key_id="report-key", secret="x" * 43), + management=None, + ), + ) + cfg.bind( + "binding-ts3", + "my-teamspeak", + 1, + actions=[], + publication=False, + metadata=dict( + name="voice", + game_id="ts3server", + host="voice.example", + port=10011, + connect_port=9987, + capacity=64, + ), + ) + with pytest.raises(ConfigError, match="website choice"): + cfg.publication("binding-ts3", True) + + +def test_config_disabled_by_default_and_private(tmp_path): + cfg = Config(tmp_path/'agent') + assert cfg.load()['reporting_enabled'] is False + assert cfg.load()['management_enabled'] is False + cfg = config(tmp_path) + assert os.stat(cfg.root).st_mode & 0o777 == 0o700 + assert os.stat(cfg.path).st_mode & 0o777 == 0o600 + assert 'x'*43 not in repr(cfg) + for tag in ['*', '../other', 'user/server', '/tmp/server', 'a\\b']: + with pytest.raises(ConfigError): + cfg.bind('binding-2', tag, 1, actions=['start'], publication=False, metadata={}) + + +def test_transport_rejects_untrusted_origins_and_redirects(): + for origin in ['http://control.example','https://user:pass@control.example','https://control.example/path', + 'https://control.example?x=1','https://control.example#x']: + with pytest.raises(ValueError): + Transport(origin) + from urllib.request import ProxyHandler + assert all(not isinstance(h, ProxyHandler) or h.proxies == {} for h in Transport('https://control.example').opener.handlers) + + +class Runtime: + def __init__(self): + self.running = True + self.calls = [] + def state(self, tag): + return 'running' if self.running else 'stopped' + def execute(self, tag, action): + self.calls.append((tag, action)) + self.running = action != 'stop' + + +def test_duplicate_restart_and_crash_never_replay(tmp_path): + cfg = config(tmp_path) + runtime = Runtime() + journal = Journal(cfg.root/'journal.json') + calls = [] + ex = Executor(cfg, journal, runtime, lambda j: calls.append(j) or True, + lock=lambda tag: __import__('contextlib').nullcontext()) + j = job(); j['policy_version'] = cfg.load()['policy_version'] + first = ex.run(j) + assert first['outcome'] == 'succeeded' + assert ex.run(j) == first + assert runtime.calls == [('my-server', 'restart')] + j['job_id'] = 'crashed-job' + journal.accept(j) + journal.executing(j['job_id']) + recovered = ex.run(j) + assert recovered['outcome'] == 'unknown' + assert len(runtime.calls) == 1 + assert len(calls) == 1 + + +def test_final_authorization_and_local_policy_fail_closed(tmp_path): + cfg = config(tmp_path); runtime = Runtime(); j = job(); j['policy_version'] = cfg.load()['policy_version'] + ex = Executor(cfg, Journal(cfg.root/'journal.json'), runtime, lambda job: False, + lock=lambda tag: __import__('contextlib').nullcontext()) + assert ex.run(j)['category'] == 'denied' + assert runtime.calls == [] + cfg.enable(management=False) + j['job_id'] = 'new-job' + assert ex.run(j)['category'] == 'denied' + assert runtime.calls == [] + + +def test_restart_stopped_is_precondition_failure(tmp_path): + cfg=config(tmp_path); runtime=Runtime(); runtime.running=False + j=job(); j['policy_version']=cfg.load()['policy_version'] + ex=Executor(cfg,Journal(cfg.root/'journal.json'),runtime,lambda job:True, + lock=lambda tag:__import__('contextlib').nullcontext()) + assert ex.run(j)['category']=='precondition' + assert runtime.calls==[] + + +def test_runtime_deadline_kills_helper_and_journals_unknown(tmp_path, monkeypatch): + import sys + from agent.lifecycle import AlphaRuntime + cfg = config(tmp_path) + journal = Journal(cfg.root/'journal.json') + runtime = AlphaRuntime() + monkeypatch.setattr(runtime, 'state', lambda tag: 'running') + monkeypatch.setattr(runtime, 'operation_budget', .1) + monkeypatch.setattr(runtime, 'helper_command', lambda tag, action: [sys.executable, '-c', 'import time; time.sleep(30)']) + ex = Executor(cfg, journal, runtime, lambda job: True, + lock=lambda tag: __import__('contextlib').nullcontext()) + j = job(); j['policy_version'] = cfg.load()['policy_version'] + result = ex.run(j) + assert result['outcome'] == 'unknown' + assert result['category'] == 'budget_exceeded' + assert ex.run(j) == result + + +def test_replace_credential_preserves_policy_and_other_capability(tmp_path): + cfg = config(tmp_path) + old = cfg.load() + cfg.replace_credential("reporting", "new-report", "r" * 43) + assert cfg.load()["management"] == old["management"] + assert cfg.load()["bindings"] == old["bindings"] + cfg.replace_credential("management", "new-management", "m" * 43, generation=2) + current = cfg.load() + assert current["credential_generation"] == 2 + assert current["reporting"] == {"key_id": "new-report", "secret": "r" * 43} + assert current["bindings"] == old["bindings"] + with pytest.raises(ConfigError): + cfg.replace_credential("management", "old-key", "x" * 43, generation=1) + reporting_only = Config(tmp_path / "reporting-only") + reporting_only.enroll("https://control.example", { + "schema_version": 1, "installation_id": "only-report", "credential_generation": 1, + "reporting": {"key_id": "report", "secret": "r" * 43}, "management": None}) + with pytest.raises(ConfigError): + reporting_only.replace_credential("management", "forbidden", "m" * 43, generation=2) diff --git a/tests/unit_tests/agent/test_cli.py b/tests/unit_tests/agent/test_cli.py new file mode 100644 index 00000000..9ac1e2fc --- /dev/null +++ b/tests/unit_tests/agent/test_cli.py @@ -0,0 +1,15 @@ +"""Explicit agent routes cannot be interpreted as server selectors.""" +import json +from agent.cli import run +from agent.config import Config + + +def test_disable_does_not_require_a_provider(tmp_path, capsys): + assert run(['disable-reporting'], root=tmp_path/'agent') == 0 + assert not Config(tmp_path/'agent').load()['reporting_enabled'] + assert run(['status'], root=tmp_path/'agent') == 0 + assert 'secret' not in capsys.readouterr().out + + +def test_secret_command_line_options_are_rejected(tmp_path): + assert run(['enroll', '--secret', 'unsafe-argument'], root=tmp_path/'agent') == 2 diff --git a/tests/unit_tests/agent/test_connection.py b/tests/unit_tests/agent/test_connection.py new file mode 100644 index 00000000..0b26e92d --- /dev/null +++ b/tests/unit_tests/agent/test_connection.py @@ -0,0 +1,287 @@ +"""The agent socket names a job; execution still uses the HTTPS job, authorize, and result calls.""" + +import socket +import threading +import time +from contextlib import nullcontext + +from agent.service import Service +from agent.socket_channel import SocketChannel, _accept_value +from agent.transport import AgentLimit +from test_agent import config +from test_contracts import job + + +class FakeChannel: + def __init__(self, frames, stop): + self.frames = list(frames) + self.sent = [] + self.stop = stop + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, traceback): + return False + + def recv(self, timeout): + if self.frames: + item = self.frames.pop(0) + if isinstance(item, BaseException): + raise item + return item + self.stop.wait(timeout) + return None + + def send(self, payload): + self.sent.append(payload) + + +class Runtime: + def __init__(self, channel, service, errors): + self.channel = channel + self.service = service + self.errors = errors + self.calls = [] + self._state = "stopped" + + def lock(self, _tag): + return nullcontext() + + def state(self, _tag): + return self._state + + def execute(self, _tag, action): + self.calls.append(action) + self._state = "running" if action == "start" else "stopped" + deadline = time.monotonic() + 2 + while not self.channel.sent and time.monotonic() < deadline: + time.sleep(0.01) + if not self.channel.sent: + self.errors.append("heartbeat") + self.service.stop.set() + + +def test_request_frame_runs_the_fetched_job_not_the_frame(tmp_path): + cfg = config(tmp_path) + errors = [] + channel = FakeChannel([{"type": "request", "job_id": "other-job", "action": "stop"}], threading.Event()) + runtime = Runtime(channel, None, errors) + sent = [] + + def request(method, path, **kwargs): + sent.append((method, path, kwargs.get("query"))) + if method == "GET": + queued = job() + queued["action"] = "start" + queued["installation_id"] = "install-1" + queued["policy_version"] = cfg.load()["policy_version"] + return {"job": queued} + if path.endswith("/authorize"): + return {"authorized": True} + return {"accepted": True} + + service = Service( + cfg, + runtime=runtime, + transport=type("Transport", (), {"request": staticmethod(request)})(), + channel_factory=lambda _cfg, _session: channel, + ) + runtime.service = service + channel.stop = service.stop + service.heartbeat_interval = 0.05 + assert service._connection_once() == "socket" + assert runtime.calls == ["start"] + assert ("GET", "/v1/agent/jobs", "wait=0") in sent + assert not errors + + +def test_socket_failure_uses_one_rest_poll_only_when_management_is_enabled(tmp_path): + cfg = config(tmp_path) + calls = [] + + def factory(_cfg, _session): + raise OSError("socket down") + + service = Service(cfg, transport=object(), channel_factory=factory) + service.management_once = lambda wait=25: calls.append(wait) + assert service._connection_once() == "fallback" + assert calls == [25] + cfg.enable(management=False) + calls.clear() + assert service._connection_once() == "retry" + assert calls == [] + + +def test_missing_reporting_key_skips_the_socket(tmp_path): + cfg = __import__("agent.config", fromlist=["Config"]).Config(tmp_path / "agent") + cfg.enroll( + "https://control.example", + dict( + schema_version=1, + installation_id="install-1", + credential_generation=1, + reporting=None, + management=dict(key_id="manage-key", secret="y" * 43), + ), + ) + cfg.enable(management=True) + calls = [] + + def factory(_cfg, _session): + raise AssertionError("socket opened without a reporting key") + + service = Service(cfg, transport=object(), channel_factory=factory) + service.management_once = lambda wait=25: calls.append(wait) + assert service._connection_once() == "rest" + assert calls == [25] + + +def test_agent_limit_backs_off_without_a_rest_poll(tmp_path): + cfg = config(tmp_path) + calls = [] + + def factory(_cfg, _session): + raise AgentLimit("Live agent cap reached") + + service = Service(cfg, transport=object(), channel_factory=factory) + service.management_once = lambda wait=25: calls.append(wait) + waits = [] + + class Stop: + def is_set(self): + return len(waits) >= 1 + + def wait(self, timeout): + waits.append(timeout) + return True + + def set(self): + waits.append("set") + + service.stop = Stop() + service._management_loop() + assert calls == [] + assert waits[0] >= 60 + + +def _read_exact(conn, size): + data = b"" + while len(data) < size: + chunk = conn.recv(size - len(data)) + if not chunk: + raise OSError("closed") + data += chunk + return data + + +def _read_client_frame(conn): + first, second = _read_exact(conn, 2) + assert first & 0x80 and second & 0x80 + length = second & 0x7F + if length == 126: + length = int.from_bytes(_read_exact(conn, 2), "big") + mask = _read_exact(conn, 4) + payload = _read_exact(conn, length) + return bytes(item ^ mask[index % 4] for index, item in enumerate(payload)) + + +def test_socket_channel_posts_the_reporting_key_and_reads_a_request(tmp_path): + del tmp_path + listener = socket.socket() + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + port = listener.getsockname()[1] + seen = [] + + def serve(): + conn, _addr = listener.accept() + try: + data = b"" + while b"\r\n\r\n" not in data: + data += conn.recv(1024) + head = data.split(b"\r\n\r\n", 1)[0].decode() + headers = {} + for line in head.split("\r\n")[1:]: + name, value = line.split(":", 1) + headers[name.strip().lower()] = value.strip() + seen.append(headers["authorization"]) + seen.append(headers["x-agent-session"]) + accept = _accept_value(headers["sec-websocket-key"]) + conn.sendall( + ( + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n" + "Connection: Upgrade\r\nSec-WebSocket-Accept: %s\r\n\r\n" % accept + ).encode() + ) + body = b'{"type":"request","job_id":"job-1","action":"stop"}' + conn.sendall(bytes((0x81, len(body))) + body) + assert _read_client_frame(conn) == b'{"type":"heartbeat"}' + finally: + conn.close() + + thread = threading.Thread(target=serve) + thread.start() + try: + channel = SocketChannel( + "https://control.example", + {"key_id": "report-key", "secret": "x" * 43}, + "abc123", + connector=lambda: socket.create_connection(("127.0.0.1", port), timeout=2), + ) + with channel as opened: + assert opened.recv(2) == {"type": "request", "job_id": "job-1", "action": "stop"} + opened.send({"type": "heartbeat"}) + assert seen == ["Bearer report-key." + "x" * 43, "abc123"] + finally: + thread.join(2) + listener.close() + + +def test_socket_close_4429_is_the_agent_cap(tmp_path): + del tmp_path + listener = socket.socket() + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + port = listener.getsockname()[1] + + def serve(): + conn, _addr = listener.accept() + try: + data = b"" + while b"\r\n\r\n" not in data: + data += conn.recv(1024) + key = "" + for line in data.split(b"\r\n\r\n", 1)[0].decode().split("\r\n")[1:]: + name, value = line.split(":", 1) + if name.strip().lower() == "sec-websocket-key": + key = value.strip() + accept = _accept_value(key) + conn.sendall( + ( + "HTTP/1.1 101 Switching Protocols\r\nSec-WebSocket-Accept: %s\r\n\r\n" % accept + ).encode() + ) + payload = (4429).to_bytes(2, "big") + conn.sendall(bytes((0x88, len(payload))) + payload) + finally: + conn.close() + + thread = threading.Thread(target=serve) + thread.start() + try: + channel = SocketChannel( + "https://control.example", + {"key_id": "report-key", "secret": "x" * 43}, + "abc123", + connector=lambda: socket.create_connection(("127.0.0.1", port), timeout=2), + ) + with channel as opened: + try: + opened.recv(2) + except AgentLimit: + return + raise AssertionError("expected the agent cap") + finally: + thread.join(2) + listener.close() diff --git a/tests/unit_tests/agent/test_connection_ports.py b/tests/unit_tests/agent/test_connection_ports.py new file mode 100644 index 00000000..8fc45c2c --- /dev/null +++ b/tests/unit_tests/agent/test_connection_ports.py @@ -0,0 +1,249 @@ +"""Approved player endpoints remain separate from public query endpoints.""" +from contextlib import nullcontext +from types import SimpleNamespace + +import pytest + +from agent import cli, preflight +from agent.contracts import ContractError, validate_metadata +from agent.inventory import read_local +from agent.lifecycle import AlphaRuntime +from agent.reporting import report_snapshot +from agent.service import Service +from test_onboarding import answers, enrolled, local_servers, provider + + +def metadata(**values): + return dict(game_id='minecraft.vanilla', host='game.example', port=25565, + name='World', capacity=20, **values) + + +def runtime(): + return SimpleNamespace(lock=lambda tag: nullcontext(), state=lambda tag: 'running', + metadata=lambda tag, approved: approved) + + +@pytest.mark.parametrize('connect_port', [1, 25565, 65535, None]) +@pytest.mark.parametrize('game', ['minecraft.vanilla', 'teamfortress2', 'terraria.vanilla']) +def test_closed_metadata_accepts_optional_player_port_for_all_games(connect_port, game): + item = dict(binding_id='world', binding_epoch=1, publication=False, allowed_actions=[], + runtime_state='unknown', **metadata(connect_port=connect_port)) + item['game_id'] = game + assert validate_metadata(item) == item + for extra in ({'connect_port': True}, {'connect_port': 0}, {'connect_port': 65536}, + {'connect_port': '25565'}, {'connect_port': 1.5}, {'player_port': 25565}, + {'password': 'secret'}): + with pytest.raises(ContractError): + validate_metadata({**item, **extra}) + del item['connect_port'] + assert validate_metadata(item) == item + + +@pytest.mark.parametrize('primary,query,expected', [ + ('27015', '27025', (27015, 27025)), (25565, 'bad', (25565, 25565)), + (False, 27025, (None, 27025)), (0, 27025, (None, 27025)), +]) +def test_inventory_reads_primary_player_port_separately(local_servers, primary, query, expected): + local_servers[1]('world', port=primary, queryport=query) + selected = read_local('world') + assert (selected['connect_port'], selected['port']) == expected + + +def test_guided_preview_approves_distinct_ports_before_registration(tmp_path, local_servers, provider, monkeypatch, capsys): + local_servers[1]('tf2', module='teamfortress2', port=27015, queryport=27025) + cfg = enrolled(tmp_path) + args = ['bind', 'tf2', '--host', 'game.example', '--connect-port', '30015', '--port', '30025'] + before = cfg.path.read_bytes() + answers(monkeypatch, ['', '', '', 'n']) + assert cli.run(args, root=cfg.root) == 1 + preview = next(line for line in capsys.readouterr().out.splitlines() if line.startswith('Preview:')) + assert all(value in preview for value in ('game.example', 'player port=30015', 'query port=30025')) + assert cfg.path.read_bytes() == before and provider == [] + answers(monkeypatch, ['', '', '', 'y']) + assert cli.run(args, root=cfg.root) == 0 + binding = next(iter(cfg.load()['bindings'].values())) + assert binding['metadata']['connect_port'] == 30015 and binding['metadata']['port'] == 30025 + assert set(provider[0][3]['payload']) == {'scope', 'binding_ref'} + assert not cfg.load()['reporting_enabled'] + + +@pytest.mark.parametrize('game,override,expected', [ + ('minecraft.vanilla', [], 30000), ('minecraft.paper', [], 30000), + ('minecraft.vanilla', ['--connect-port', '30001'], 30001), ('teamfortress2', [], 27015), +]) +def test_query_override_tracks_minecraft_player_port_only(tmp_path, local_servers, provider, monkeypatch, game, override, expected): + local_servers[1]('world', module=game, port=27015, queryport=27025) + cfg = enrolled(tmp_path) + answers(monkeypatch, ['', '', '', 'y']) + assert cli.run(['bind', 'world', '--host', 'game.example', '--port', '30000'] + override, root=cfg.root) == 0 + selected = next(iter(cfg.load()['bindings'].values()))['metadata'] + assert selected['port'] == 30000 and selected['connect_port'] == expected + + +@pytest.mark.parametrize('approved_connect,flags,expected', [ + (None, [], 25570), (25565, [], 25570), (30001, [], 30001), + (30001, ['--port', '25569'], 25570), (None, ['--connect-port', '30001'], 30001), +]) +def test_interactive_minecraft_query_edit_follows_shared_player_port(tmp_path, local_servers, provider, monkeypatch, + approved_connect, flags, expected): + cfg = enrolled(tmp_path) + if approved_connect is not None: + cfg.bind('world', 'world', 1, actions=[], publication=False, + metadata=metadata(connect_port=approved_connect)) + answers(monkeypatch, ['game.example:25570', '', '', 'y']) + assert cli.run(['bind', 'world', '--host', 'game.example'] + flags, root=cfg.root) == 0 + selected = next(iter(cfg.load()['bindings'].values()))['metadata'] + assert selected['port'] == 25570 and selected['connect_port'] == expected + + +def test_missing_primary_port_is_reviewed_as_unknown_without_query_fallback(tmp_path, local_servers, provider, monkeypatch, capsys): + local_servers[1]('world', port=0, queryport=25565) + cfg = enrolled(tmp_path) + answers(monkeypatch, ['', '', '', 'y']) + assert cli.run(['bind', 'world', '--host', 'game.example'], root=cfg.root) == 0 + selected = next(iter(cfg.load()['bindings'].values()))['metadata'] + assert selected['port'] == 25565 and selected['connect_port'] is None + assert 'player port=unknown' in capsys.readouterr().out + + +def test_private_game_has_player_endpoint_without_new_public_profile(tmp_path, local_servers, provider, monkeypatch): + local_servers[1]('terraria', module='terraria.vanilla', port=7777, queryport=7778) + cfg = enrolled(tmp_path) + answers(monkeypatch, ['', '', 'y']) + assert cli.run(['bind', 'terraria', '--host', 'game.example'], root=cfg.root) == 0 + selected = next(iter(cfg.load()['bindings'].values())) + assert selected['metadata']['connect_port'] == 7777 and selected['metadata']['port'] == 7778 + assert not selected['publication'] + assert cli.run(['publication', next(iter(cfg.load()['bindings'])), 'on'], root=cfg.root) == 1 + + +def test_old_report_omits_player_port_until_rebind_approval(tmp_path, local_servers, provider, monkeypatch, capsys): + cfg = enrolled(tmp_path) + cfg.bind('world', 'world', 9, actions=['status'], publication=True, metadata=metadata()) + cfg.enable(management=True) + before = cfg.load() + assert 'connect_port' not in report_snapshot(cfg, runtime())['bindings'][0] + answers(monkeypatch, ['', '', '', 'n']) + assert cli.run(['bind', 'world'], root=cfg.root) == 1 + assert 'player port=25565' in capsys.readouterr().out + assert 'connect_port' not in cfg.load()['bindings']['world']['metadata'] + answers(monkeypatch, ['', '', '', 'y']) + assert cli.run(['bind', 'world'], root=cfg.root) == 0 + after = cfg.load() + assert after['bindings']['world'] == {**before['bindings']['world'], 'metadata': metadata(connect_port=25565)} + assert after['management_enabled'] and not after['reporting_enabled'] and provider == [] + snapshot = report_snapshot(cfg, runtime())['bindings'][0] + assert snapshot['connect_port'] == 25565 and not snapshot['publication'] + stable = cfg.path.read_bytes() + local_servers[1]('world', port=25566, queryport=25567) + answers(monkeypatch, ['', '', '', 'y']) + assert cli.run(['bind', 'world'], root=cfg.root) == 0 + assert cfg.path.read_bytes() == stable + + +@pytest.mark.parametrize('port', ['0', '65536']) +def test_invalid_connection_override_does_not_prompt_or_write(tmp_path, local_servers, provider, monkeypatch, port): + cfg = enrolled(tmp_path) + before = cfg.path.read_bytes() + monkeypatch.setattr('builtins.input', lambda prompt: pytest.fail('invalid explicit port prompted')) + assert cli.run(['bind', 'world', '--connect-port', port], root=cfg.root) == 1 + assert cfg.path.read_bytes() == before and provider == [] + + +def test_bulk_connection_override_does_not_apply_one_port_to_all(tmp_path, local_servers, provider, monkeypatch): + local_servers[1]('second') + cfg = enrolled(tmp_path) + before = cfg.path.read_bytes() + monkeypatch.setattr('builtins.input', lambda prompt: pytest.fail('ambiguous bulk override prompted')) + assert cli.run(['bind', '*', '--connect-port', '30000'], root=cfg.root) == 1 + assert cfg.path.read_bytes() == before and provider == [] + + +@pytest.mark.parametrize('game,flags,expected', [ + ('teamfortress2', [], 27015), ('minecraft.vanilla', [], 30025), + ('teamfortress2', ['--connect-port', '30015'], 30015), + ('minecraft.vanilla', ['--connect-port', '30015'], 30015), +]) +def test_legacy_full_flags_infer_player_port_without_prompts(tmp_path, local_servers, monkeypatch, game, flags, expected): + local_servers[1]('world', module=game, port=27015, queryport=27025) + cfg = enrolled(tmp_path) + monkeypatch.setattr('builtins.input', lambda prompt: pytest.fail('legacy command prompted')) + assert cli.run(['bind', 'world-id', 'world', '--game', game, '--host', 'game.example', + '--port', '30025', '--name', 'World'] + flags, root=cfg.root) == 0 + selected = cfg.load()['bindings']['world-id']['metadata'] + assert selected['port'] == 30025 and selected['connect_port'] == expected + + +def test_runtime_refresh_cannot_infer_or_replace_approved_connection_ports(local_servers): + local_servers[1]('world', port=27015, queryport=27025) + assert 'connect_port' not in AlphaRuntime.metadata('world', metadata()) + selected = AlphaRuntime.metadata('world', metadata(connect_port=30015)) + assert selected['connect_port'] == 30015 and selected['port'] == 25565 + + +def test_player_endpoint_edit_during_dns_withdraws_stale_publication(tmp_path, monkeypatch): + cfg = enrolled(tmp_path) + cfg.bind('world', 'world', 1, actions=[], publication=True, + metadata=metadata(connect_port=25565), expected_public_ips=['8.8.8.8']) + cfg.enable(reporting=True) + def resolve(host, timeout): + cfg.bind('world', 'world', 1, actions=[], publication=True, + metadata=metadata(connect_port=30000), expected_public_ips=['8.8.8.8']) + return ['8.8.8.8'] + monkeypatch.setattr(preflight, 'resolve_host', resolve) + sent = [] + service = Service(cfg, runtime=runtime(), transport=SimpleNamespace( + request=lambda method, path, **kwargs: sent.append(kwargs['payload']))) + service.report_once() + assert sent[0]['bindings'][0]['publication'] is False + assert cfg.load()['bindings']['world']['publication'] is True + + +@pytest.mark.parametrize('initial_present', [False, True]) +def test_player_port_presence_change_during_dns_withdraws_then_reports_current_choice(tmp_path, monkeypatch, initial_present): + cfg = enrolled(tmp_path) + initial = metadata(**({'connect_port': None} if initial_present else {})) + current = metadata(**({} if initial_present else {'connect_port': None})) + cfg.bind('world', 'world', 1, actions=[], publication=True, + metadata=initial, expected_public_ips=['8.8.8.8']) + cfg.enable(reporting=True) + resolutions = [] + def resolve(host, timeout): + if not resolutions: + cfg.bind('world', 'world', 1, actions=[], publication=True, + metadata=current, expected_public_ips=['8.8.8.8']) + resolutions.append(host) + return ['8.8.8.8'] + monkeypatch.setattr(preflight, 'resolve_host', resolve) + sent = [] + service = Service(cfg, runtime=runtime(), transport=SimpleNamespace( + request=lambda method, path, **kwargs: sent.append(kwargs['payload']))) + service.report_once() + assert sent[0]['bindings'][0]['publication'] is False + assert ('connect_port' in sent[0]['bindings'][0]) is initial_present + binding = cfg.load()['bindings']['world'] + assert binding['publication'] is True and binding['metadata'] == current + service.report_once() + assert sent[1]['bindings'][0]['publication'] is True + assert ('connect_port' in sent[1]['bindings'][0]) is not initial_present + if not initial_present: + assert sent[1]['bindings'][0]['connect_port'] is None + + +@pytest.mark.parametrize('game', ['trackmaniaserver', 'enshrouded', 'arma3.headless', 'arma3headlessserver']) +def test_non_player_primary_ports_are_not_advertised_as_player_endpoints(local_servers, game): + local_servers[1]('special', module=game, port=5000, queryport=5001) + selected = read_local('special') + assert selected['connect_port'] is None + assert selected['port'] == 5001 + + +@pytest.mark.parametrize('flags,expected', [([], None), (['--connect-port', '2350'], 2350)]) +def test_trackmania_requires_explicit_player_port_approval(tmp_path, local_servers, provider, monkeypatch, + flags, expected): + local_servers[1]('track', module='trackmaniaserver', port=5000) + cfg = enrolled(tmp_path) + answers(monkeypatch, ['', '', 'y']) + assert cli.run(['bind', 'track', '--host', 'game.example'] + flags, root=cfg.root) == 0 + selected = next(iter(cfg.load()['bindings'].values()))['metadata'] + assert selected['port'] == 5000 and selected['connect_port'] == expected diff --git a/tests/unit_tests/agent/test_contracts.py b/tests/unit_tests/agent/test_contracts.py new file mode 100644 index 00000000..371dcf1d --- /dev/null +++ b/tests/unit_tests/agent/test_contracts.py @@ -0,0 +1,40 @@ +"""Strict shared wire schema: no strings converted into permissions.""" +from datetime import datetime, timedelta, timezone +import pytest +from agent.contracts import validate_job, validate_report, validate_result, ContractError + + +def job(): + now = datetime.now(timezone.utc) + return dict(schema_version=1, job_id='job-1', installation_id='install-1', + binding_id='binding-1', binding_epoch=1, action='restart', policy_version=1, + credential_generation=1, requested_by='owner-1', + issued_at=now.isoformat(), expires_at=(now + timedelta(seconds=120)).isoformat()) + + +def test_job_accepts_only_closed_action_contract(): + assert validate_job(job())['action'] == 'restart' + for update in ({'action': 'kill'}, {'arguments': []}, {'policy_version': True}, + {'binding_id': '../other'}, {'action': ['start']}, {'expires_at': 'tomorrow'}): + with pytest.raises(ContractError): + validate_job({**job(), **update}) + + +def test_report_rejects_private_fields_and_false_string_consent(): + report = dict(schema_version=1, installation_id='install-1', sequence=1, policy_version=1, + bindings=[dict(binding_id='binding-1', binding_epoch=1, publication=False, + name='My server', game_id='minecraft.vanilla', host='game.example', + port=25565, capacity=20, runtime_state='stopped', allowed_actions=['status'])]) + assert validate_report(report)['bindings'][0]['publication'] is False + for extra in ({'password': 'secret'}, {'publication': 'false'}, {'capacity': -1}, {'allowed_actions': ['shell']}): + with pytest.raises(ContractError): + validate_report({**report, 'bindings': [{**report['bindings'][0], **extra}]}) + + +def test_result_never_accepts_raw_console_or_unknown_state(): + result = dict(schema_version=1, job_id='job-1', installation_id='install-1', + outcome='succeeded', runtime_state='running', category='ok', + observed_at=datetime.now(timezone.utc).isoformat()) + assert validate_result(result) == result + with pytest.raises(ContractError): + validate_result({**result, 'logs': 'console output'}) diff --git a/tests/unit_tests/agent/test_discovery.py b/tests/unit_tests/agent/test_discovery.py new file mode 100644 index 00000000..8f911e7a --- /dev/null +++ b/tests/unit_tests/agent/test_discovery.py @@ -0,0 +1,290 @@ +"""Only allowlisted local metadata and globally routable network suggestions.""" +import importlib +import json +from types import SimpleNamespace +import ssl +import urllib.request + +import pytest + + +def module(name): + assert importlib.util.find_spec('agent.' + name), 'missing focused discovery helper' + return importlib.import_module('agent.' + name) + + +def test_inventory_excludes_secrets_links_invalid_and_cross_user(tmp_path, monkeypatch): + inventory = module('inventory') + import server.server as server_module + monkeypatch.setattr(server_module, 'DATAPATH', str(tmp_path)) + (tmp_path / 'world.json').write_text(json.dumps(dict(module='minecraft.vanilla', port=25565, + servername='World', maxplayers=20, rconpassword='NEVER-COPY'))) + (tmp_path / 'world.secrets.json').write_text('{"secret":"NEVER-READ"}') + (tmp_path / 'bad tag.json').write_text('{}') + (tmp_path / 'link.json').symlink_to(tmp_path / 'world.json') + (tmp_path / 'other.json').write_text('{}') + actual_stat = inventory.os.fstat + monkeypatch.setattr(inventory.os, 'fstat', lambda fd: SimpleNamespace(**{ + key: (99999 if key == 'st_uid' else getattr(actual_stat(fd), key)) + for key in ('st_uid', 'st_mode', 'st_size')}) if inventory.os.readlink('/proc/self/fd/' + str(fd)).endswith('other.json') else actual_stat(fd)) + items, failures = inventory.inventory() + assert [item['tag'] for item in items] == ['world'] + assert 'NEVER' not in repr((items, failures)) + assert set(items[0]) == {'tag', 'game_id', 'name', 'port', 'connect_port', 'capacity', 'publicip', 'externalip', 'hostip'} + assert all('secrets' not in tag for tag, reason in failures) + + +@pytest.mark.parametrize('address', ['127.0.0.1', '10.0.0.1', '0.0.0.0', '169.254.1.1', '224.0.0.1', '::1', 'example.com']) +def test_global_ip_rejects_non_global_and_hostnames(address): + assert module('network').global_ip(address) is None + + +def test_suggestion_prefers_configured_public_numeric_without_network(monkeypatch): + network = module('network') + monkeypatch.setattr(network, 'routing_ip', lambda: pytest.fail('routing should not be called')) + monkeypatch.setattr(network, 'lookup_ip', lambda: pytest.fail('lookup should not be called')) + assert network.suggest_public_host({'publicip': '10.0.0.1', 'externalip': '8.8.8.8'}) == '8.8.8.8' + + +def test_suggestion_uses_routing_global_then_lookup(monkeypatch): + network = module('network') + monkeypatch.setattr(network, 'routing_ip', lambda: '8.8.4.4') + monkeypatch.setattr(network, 'lookup_ip', lambda: pytest.fail('lookup should not be called')) + assert network.suggest_public_host({}) == '8.8.4.4' + monkeypatch.setattr(network, 'routing_ip', lambda: '192.168.1.2') + monkeypatch.setattr(network, 'lookup_ip', lambda: '1.1.1.1') + assert network.suggest_public_host({}) == '1.1.1.1' + + +class Response: + def __init__(self, body=b'8.8.8.8', url='https://api.ipify.org'): + self.body = body + self.url = url + self.fp = SimpleNamespace(raw=SimpleNamespace(_sock=SimpleNamespace(settimeout=lambda value: None))) + def __enter__(self): + return self + def __exit__(self, *args): + return False + def geturl(self): + return self.url + def read1(self, limit): + body, self.body = self.body[:limit], self.body[limit:] + return body + + +def lookup(monkeypatch, response): + network = module('network') + captured = [] + def opener(*handlers): + captured.extend(handlers) + def open_request(request, timeout): + assert request.full_url == 'https://api.ipify.org' + assert request.get_method() == 'GET' + assert request.data is None + assert not any(key.lower() in ('authorization', 'cookie') for key in request.headers) + assert 0 < timeout <= 5 + return response + return SimpleNamespace(open=open_request) + monkeypatch.setattr(network.urllib.request, 'build_opener', opener) + return network.lookup_ip(), captured + + +def test_lookup_uses_verified_tls_no_proxy_and_no_redirect_or_secrets(monkeypatch): + value, handlers = lookup(monkeypatch, Response()) + assert value == '8.8.8.8' + assert next(h for h in handlers if isinstance(h, urllib.request.ProxyHandler)).proxies == {} + tls = next(h for h in handlers if isinstance(h, urllib.request.HTTPSHandler))._context + assert tls.verify_mode == ssl.CERT_REQUIRED and tls.check_hostname + redirect = next(h for h in handlers if isinstance(h, urllib.request.HTTPRedirectHandler)) + with pytest.raises(OSError): + redirect.redirect_request(None, None, 302, '', {}, 'https://elsewhere.example') + + +@pytest.mark.parametrize('response', [Response(b'10.1.2.3'), Response(b'x' * 100), Response(url='https://elsewhere.example')]) +def test_lookup_fails_closed_for_non_global_oversized_or_changed_url(monkeypatch, response): + assert lookup(monkeypatch, response)[0] is None + + +def test_lookup_failure_offline_is_graceful(monkeypatch): + network = module('network') + def unavailable(*args, **kwargs): + raise ssl.SSLCertVerificationError('hostile certificate') + monkeypatch.setattr(network.urllib.request, 'build_opener', lambda *args: SimpleNamespace(open=unavailable)) + assert network.lookup_ip() is None + + +def test_lookup_total_time_budget_is_bounded(monkeypatch): + network = module('network') + moments = iter([0, 6]) + monkeypatch.setattr(network.time, 'monotonic', lambda: next(moments, 6)) + assert lookup(monkeypatch, Response())[0] is None + + +def test_lookup_rejects_ipv6_response_from_ipv4_service(monkeypatch): + assert lookup(monkeypatch, Response(b'2606:4700:4700::1111'))[0] is None + + +def test_routing_socket_does_not_send_and_ignores_local_address(monkeypatch): + network = module('network') + class Route: + def __enter__(self): + return self + def __exit__(self, *args): + return False + def settimeout(self, value): + assert value == 1 + def connect(self, address): + assert address == ('1.1.1.1', 80) + def getsockname(self): + return ('192.168.1.2', 40000) + monkeypatch.setattr(network.socket, 'socket', lambda *args: Route()) + assert network.routing_ip() is None + + +def test_inventory_does_not_construct_servers_or_invoke_hooks(tmp_path, monkeypatch): + inventory = module('inventory') + import server.server as server_module + monkeypatch.setattr(server_module, 'DATAPATH', str(tmp_path)) + monkeypatch.setattr(server_module, 'Server', lambda *args: pytest.fail('constructor can load secrets')) + (tmp_path / 'world.json').write_text(json.dumps(dict(module='minecraft.vanilla', queryport='invalid', port='25565'))) + items, failures = inventory.inventory() + assert items[0]['port'] == 25565 and not failures + + +def test_inventory_limits_to_100_and_reports_remaining_locals(tmp_path, monkeypatch): + inventory = module('inventory') + import server.server as server_module + monkeypatch.setattr(server_module, 'DATAPATH', str(tmp_path)) + for index in range(101): + (tmp_path / ('server-' + str(index) + '.json')).write_text('{"module":"minecraft.vanilla","port":25565}') + items, failures = inventory.inventory() + assert len(items) == 100 and len(failures) == 1 + assert '100' in failures[0][1] + + +def test_inventory_rejects_oversized_local_file(tmp_path, monkeypatch): + inventory = module('inventory') + import server.server as server_module + monkeypatch.setattr(server_module, 'DATAPATH', str(tmp_path)) + (tmp_path / 'oversized.json').write_bytes(b'x' * (inventory.MAX_DATA_BYTES + 1)) + items, failures = inventory.inventory() + assert not items and failures[0][0] == 'oversized' + + +def test_lookup_deadline_covers_real_verified_tls_slow_headers(tmp_path, monkeypatch): + """Dripping headers never reset the total lookup budget or leave live I/O.""" + import shutil + import socket + import subprocess + import threading + import time + + network = module('network') + openssl = shutil.which('openssl') + if not openssl: + pytest.skip('The loopback TLS regression needs openssl for its temporary certificate') + key, cert = tmp_path / 'test.key', tmp_path / 'test.crt' + subprocess.run([openssl, 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1', + '-subj', '/CN=127.0.0.1', '-addext', 'subjectAltName=IP:127.0.0.1', + '-keyout', str(key), '-out', str(cert)], check=True, capture_output=True, timeout=5) + try: + listener = socket.socket() + listener.bind(('127.0.0.1', 0)) + except PermissionError: + pytest.skip('The loopback TLS regression needs local socket permission') + listener.listen(1) + listener.settimeout(2) + server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + server_context.load_cert_chain(cert, key) + client_context = ssl.create_default_context(cafile=str(cert)) + peer_finished = threading.Event() + def peer(): + try: + raw, _ = listener.accept() + with server_context.wrap_socket(raw, server_side=True) as connection: + connection.settimeout(2) + connection.recv(4096) + connection.sendall(b'HTTP/1.1 200 OK\r\n') + for _ in range(9): + time.sleep(.1) + connection.sendall(b'X-Slow: yes\r\n') + connection.sendall(b'Content-Length: 7\r\n\r\n8.8.8.8') + except (OSError, TimeoutError): + pass + finally: + peer_finished.set() + worker = threading.Thread(target=peer, daemon=True) + worker.start() + monkeypatch.setattr(network, 'LOOKUP_URL', 'https://127.0.0.1:' + str(listener.getsockname()[1])) + monkeypatch.setattr(network, 'LOOKUP_TIMEOUT', .3) + monkeypatch.setattr(network.ssl, 'create_default_context', lambda: client_context) + try: + started = time.monotonic() + assert network.lookup_ip() is None + elapsed = time.monotonic() - started + assert elapsed < .55, f'Header drip bypassed the .3s lookup deadline: {elapsed:.2f}s' + assert peer_finished.wait(.3), 'Lookup deadline did not close its active TLS connection' + finally: + listener.close() + worker.join(2) + + +def test_stalled_dns_is_bounded_and_cannot_connect_after_cancellation(monkeypatch): + import socket + import threading + import time + + network = module('network') + started, release = threading.Event(), threading.Event() + sockets = [] + def resolve(*args, **kwargs): + started.set() + assert release.wait(2) + return [(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, '', ('8.8.8.8', 443))] + class LateSocket: + def __init__(self, *args): + self.closed = False + sockets.append(self) + def close(self): + self.closed = True + def connect(self, address): + pytest.fail('Cancelled DNS lookup must never connect afterwards') + monkeypatch.setattr(network, 'LOOKUP_TIMEOUT', .1) + monkeypatch.setattr(network.socket, 'getaddrinfo', resolve) + monkeypatch.setattr(network.socket, 'socket', LateSocket) + before = time.monotonic() + try: + assert network.lookup_ip() is None + assert started.is_set() + assert time.monotonic() - before < .3 + finally: + release.set() + deadline = time.monotonic() + 1 + while any(worker.name == 'alphagsm-public-ip' for worker in threading.enumerate()): + assert time.monotonic() < deadline, 'Cancelled DNS worker did not finish when the resolver returned' + time.sleep(.01) + assert all(connection.closed for connection in sockets) + + +def test_repeated_lookup_does_not_accumulate_workers_while_system_dns_is_stalled(monkeypatch): + import threading + import time + network = module('network') + release = threading.Event() + calls = [] + def stalled(budget): + calls.append(budget) + assert release.wait(2) + return None + monkeypatch.setattr(network, 'LOOKUP_TIMEOUT', .03) + monkeypatch.setattr(network, '_lookup_exchange', stalled) + try: + assert network.lookup_ip() is None + assert network.lookup_ip() is None + assert len(calls) == 1, 'A timed-out system resolver spawned another uncancellable worker' + finally: + release.set() + deadline = time.monotonic() + 1 + while any(worker.name == 'alphagsm-public-ip' for worker in threading.enumerate()): + assert time.monotonic() < deadline + time.sleep(.01) diff --git a/tests/unit_tests/agent/test_dns_lookup.py b/tests/unit_tests/agent/test_dns_lookup.py new file mode 100644 index 00000000..994dd006 --- /dev/null +++ b/tests/unit_tests/agent/test_dns_lookup.py @@ -0,0 +1,59 @@ +"""Bounded real DNS refuses partial A success when AAAA cannot be resolved.""" +import importlib +import socket +import threading +import time + +import pytest +import dns.message +import dns.rdatatype +import dns.rrset +import dns.resolver + + +def test_dns_real_timeout_discards_partial_family_and_closes_all_workers(monkeypatch): + assert importlib.util.find_spec('agent.dns_lookup'), 'missing bounded DNS helper' + module = importlib.import_module('agent.dns_lookup') + try: + server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + server.bind(('127.0.0.1', 0)) + except PermissionError: + pytest.skip('Local DNS regression needs loopback socket permission') + server.settimeout(.1) + stopped = threading.Event() + questions = [] + def peer(): + while not stopped.is_set(): + try: + data, address = server.recvfrom(4096) + request = dns.message.from_wire(data) + question = request.question[0] + questions.append(question.rdtype) + if question.rdtype == dns.rdatatype.A: + response = dns.message.make_response(request) + response.answer.append(dns.rrset.from_text('game.example.', 60, 'IN', 'A', '8.8.8.8')) + server.sendto(response.to_wire(), address) + except (OSError, dns.exception.DNSException): + continue + worker = threading.Thread(target=peer, daemon=True) + worker.start() + sync_resolver = dns.resolver.Resolver(configure=False) + sync_resolver.nameservers = ['127.0.0.1'] + sync_resolver.port = server.getsockname()[1] + monkeypatch.setattr(module.dns.resolver, 'Resolver', lambda: sync_resolver) + if hasattr(module.dns, 'asyncresolver'): + async_resolver = module.dns.asyncresolver.Resolver(configure=False) + async_resolver.nameservers = ['127.0.0.1'] + async_resolver.port = server.getsockname()[1] + monkeypatch.setattr(module.dns.asyncresolver, 'Resolver', lambda: async_resolver) + try: + for _ in range(3): + started = time.monotonic() + with pytest.raises(ValueError, match='timed out'): + module.resolve_host('game.example', .1) + assert time.monotonic() - started < .17, 'DNS retry backoff escaped the shared .1s lifetime' + assert dns.rdatatype.A in questions and dns.rdatatype.AAAA in questions + finally: + stopped.set() + server.close() + worker.join(1) diff --git a/tests/unit_tests/agent/test_local_query.py b/tests/unit_tests/agent/test_local_query.py new file mode 100644 index 00000000..72363f66 --- /dev/null +++ b/tests/unit_tests/agent/test_local_query.py @@ -0,0 +1,71 @@ +"""The agent reads a local server itself and does not upload that observation.""" + +import pytest + +from agent.config import Config +from agent.local_query import observe_local, query_tag +from utils.query import QueryError + + +def _config(tmp_path): + cfg = Config(tmp_path / "agent") + cfg.enroll( + "https://control.example", + dict( + schema_version=1, + installation_id="install-1", + credential_generation=1, + reporting=dict(key_id="report-key", secret="x" * 43), + management=None, + ), + ) + cfg.bind( + "binding-1", + "my-server", + 1, + actions=["status"], + publication=False, + metadata=dict(name="game", game_id="counterstrike2", host="game.example", port=27015, capacity=16), + ) + return cfg + + +def test_observe_local_uses_the_module_hook_and_the_shared_query(monkeypatch): + class Module: + def get_info_address(self, server): + del server + return ("10.1.2.3", 27015, "a2s") + + class Local: + module = Module() + data = {"port": 27015} + + import server.public_query as public_query + + monkeypatch.setattr(public_query.query, "a2s_info", lambda host, port, timeout=2.0, **kwargs: (host, port)) + monkeypatch.setattr( + public_query.query, + "parse_a2s_info", + lambda raw: dict(name="Dust", map="de_dust2", players=0, max_players=16, bots=4, address=raw), + ) + result = observe_local(Local(), "counterstrike2") + assert result == dict(name="Dust", map="de_dust2", players=0, capacity=16) + + +def test_games_without_an_anonymous_status_query_are_refused(): + with pytest.raises(QueryError, match="no anonymous status query"): + observe_local(object(), "valheim") + + +def test_query_command_prints_the_local_result_and_does_not_construct_a_client(tmp_path, monkeypatch, capsys): + cfg = _config(tmp_path) + monkeypatch.setattr( + "agent.local_query.observe_local", + lambda server, game_id, timeout=5.0: dict(name="Dust", players=1, capacity=16, map="de_dust2"), + ) + query_tag(cfg, "my-server", open_server=lambda tag: object()) + printed = capsys.readouterr().out + assert "not sent to Sector Alpha" in printed + assert "name=Dust" in printed + assert "players=1/16" in printed + assert "map=de_dust2" in printed diff --git a/tests/unit_tests/agent/test_lock_handoff.py b/tests/unit_tests/agent/test_lock_handoff.py new file mode 100644 index 00000000..5a5496a0 --- /dev/null +++ b/tests/unit_tests/agent/test_lock_handoff.py @@ -0,0 +1,31 @@ +"""A bounded helper cannot release the parent's lock or select a different inode.""" +import subprocess +import sys +import pytest +from utils.state_io import state_lock, held_descriptor, inherited_state_lock + + +def test_child_reuses_lock_without_unlocking_parent(tmp_path): + path=tmp_path/'server.json' + with state_lock(path): + fd=held_descriptor(path) + child="""import sys +from utils.state_io import inherited_state_lock, state_lock +with inherited_state_lock(sys.argv[1], int(sys.argv[2])): + with state_lock(sys.argv[1], timeout=.1): pass +""" + subprocess.run([sys.executable,'-c',child,str(path),str(fd)],pass_fds=(fd,),check=True,timeout=5) + contender="""import sys +from utils.state_io import state_lock +try: + with state_lock(sys.argv[1],timeout=.1): pass +except TimeoutError: sys.exit(0) +sys.exit(1) +""" + subprocess.run([sys.executable,'-c',contender,str(path)],check=True,timeout=5) + other=tmp_path/'other.json' + with state_lock(other): + with pytest.raises(ValueError): + with inherited_state_lock(other,fd): pass + assert held_descriptor(path) is None + with state_lock(path,timeout=.1): pass diff --git a/tests/unit_tests/agent/test_onboarding.py b/tests/unit_tests/agent/test_onboarding.py new file mode 100644 index 00000000..f4af71e9 --- /dev/null +++ b/tests/unit_tests/agent/test_onboarding.py @@ -0,0 +1,522 @@ +"""Guided onboarding uses real private policy and isolated local datastores.""" +import hashlib +import json + +import pytest + +from agent import cli +from agent.config import Config, origin +from agent.transport import Transport, TransportError + + +@pytest.fixture +def local_servers(tmp_path, monkeypatch): + import server.server as server_module + datapath = tmp_path / 'servers' + datapath.mkdir() + monkeypatch.setattr(server_module, 'DATAPATH', str(datapath)) + + def create(tag, **fields): + values = {'module': 'minecraft.vanilla', 'port': 25565, 'servername': 'Local world', 'maxplayers': 20} + values.update(fields) + (datapath / (tag + '.json')).write_text(json.dumps(values)) + return datapath / (tag + '.json') + + create('world') + return datapath, create + + +@pytest.fixture +def provider(monkeypatch): + calls = [] + + class Provider: + def __init__(self, api_origin): + self.origin = origin(api_origin) + + def request(self, method, path, **kwargs): + calls.append((self.origin, method, path, kwargs)) + if path.endswith('/enroll'): + return dict(schema_version=1, installation_id='install-1', credential_generation=1, + reporting=dict(key_id='report-key', secret='r' * 43), + management=dict(key_id='manage-key', secret='m' * 43)) + if path.endswith('/challenge'): + return dict(challenge='SA-' + 'a' * 43, expires_at=1) + return dict(schema_version=1, binding_id='binding-' + str(len(calls)), binding_epoch=3) + + monkeypatch.setattr(cli, 'Transport', Provider) + monkeypatch.setattr(cli.getpass, 'getpass', lambda prompt: 'hidden-code') + return calls + + +def answers(monkeypatch, values): + replies = iter(values) + monkeypatch.setattr('builtins.input', lambda prompt: next(replies)) + + +def enrolled(tmp_path, reporting=True, management=True): + cfg = Config(tmp_path / 'agent') + cfg.enroll('https://control.example', dict(schema_version=1, installation_id='install-1', credential_generation=1, + reporting=dict(key_id='report-key', secret='r' * 43) if reporting else None, + management=dict(key_id='manage-key', secret='m' * 43) if management else None)) + return cfg + + +def test_root_origin_slash_is_normalized_in_storage_and_transport(tmp_path): + assert origin('https://sector-alpha.net/') == 'https://sector-alpha.net' + cfg = Config(tmp_path / 'agent') + cfg.enroll('https://sector-alpha.net/', dict(schema_version=1, installation_id='i', credential_generation=1, + reporting=None, management=None)) + assert cfg.load()['origin'] == 'https://alphagsm-api.sector-alpha.net' + assert Transport('https://sector-alpha.net/').origin == 'https://alphagsm-api.sector-alpha.net' + + +def test_enroll_default_only_prompts_for_hidden_code(tmp_path, provider, monkeypatch): + import server.server as server_module + empty = tmp_path / 'servers' + empty.mkdir() + monkeypatch.setattr(server_module, 'DATAPATH', str(empty)) + monkeypatch.setattr('builtins.input', lambda prompt: pytest.fail('unexpected visible prompt')) + assert cli.run(['enroll'], root=tmp_path / 'agent') == 0 + assert provider == [('https://alphagsm-api.sector-alpha.net', 'POST', '/v1/agent/enroll', {'payload': {'code': 'hidden-code'}})] + loaded = Config(tmp_path / 'agent').load() + assert loaded['reporting_enabled'] is True + assert not loaded['management_enabled'] + assert not loaded['bindings'] + + +def test_enroll_binds_public_servers_and_requests_an_ownership_challenge(tmp_path, local_servers, provider, monkeypatch, capsys): + monkeypatch.setattr('builtins.input', lambda prompt: pytest.fail('unexpected visible prompt')) + local_servers[1]('world', publicip='8.8.8.8') + local_servers[1]('private', port=25566, servername='Private') + assert cli.run(['enroll'], root=tmp_path / 'agent') == 0 + output = capsys.readouterr().out + assert 'alphagsm agent bind private --host HOST' in output + assert 'SA-' + 'a' * 43 in output + assert 'Sector Alpha verifies it by querying the server.' in output + assert [call[0:3] for call in provider] == [ + ('https://alphagsm-api.sector-alpha.net', 'POST', '/v1/agent/enroll'), + ('https://alphagsm-api.sector-alpha.net', 'POST', '/v1/agent/bindings'), + ('https://alphagsm-api.sector-alpha.net', 'POST', '/v1/agent/reports'), + ('https://alphagsm-api.sector-alpha.net', 'POST', '/v1/agent/challenge'), + ] + value = Config(tmp_path / 'agent').load() + assert value['reporting_enabled'] is True and value['management_enabled'] is False + assert len(value['bindings']) == 1 + item = next(iter(value['bindings'].values())) + assert item['tag'] == 'world' and item['publication'] is False and item['actions'] == [] + assert item['metadata']['host'] == '8.8.8.8' + assert item['metadata']['port'] == 25565 and item['metadata']['connect_port'] == 25565 + assert provider[1][3]['payload']['scope'] == 'reporting' + + +def test_enroll_places_a_teamspeak_challenge_for_the_site_query(tmp_path, local_servers, provider, monkeypatch): + placed = [] + monkeypatch.setattr('builtins.input', lambda prompt: pytest.fail('unexpected visible prompt')) + monkeypatch.setattr( + 'agent.name_proof.prove_binding', + lambda cfg, tag, binding_id, binding, token, transport: placed.append((tag, token)) or {'verified': False}, + ) + local_servers[1]('lobby', module='ts3server', port=9987, queryport=10011, publicip='8.8.8.8', servername='Voice') + assert cli.run(['enroll'], root=tmp_path / 'agent') == 0 + assert placed == [('lobby', 'SA-' + 'a' * 43)] + assert [call[2] for call in provider] == [ + '/v1/agent/enroll', '/v1/agent/bindings', '/v1/agent/reports', '/v1/agent/challenge', + ] + + +def test_guided_bind_infers_metadata_and_registers_only_scoped_hash(tmp_path, local_servers, provider, monkeypatch): + cfg = enrolled(tmp_path) + answers(monkeypatch, ['', '', '', 'y']) + assert cli.run(['bind', 'world', '--host', '8.8.8.8'], root=cfg.root) == 0 + item = next(iter(cfg.load()['bindings'].values())) + assert item == dict(tag='world', epoch=3, actions=[], publication=False, + metadata=dict(game_id='minecraft.vanilla', host='8.8.8.8', port=25565, + connect_port=25565, name='Local world', capacity=20)) + assert provider == [('https://control.example', 'POST', '/v1/agent/bindings', { + 'credential': {'key_id': 'report-key', 'secret': 'r' * 43}, + 'payload': {'scope': 'reporting', 'binding_ref': hashlib.sha256(b'world').hexdigest()}})] + assert not cfg.load()['reporting_enabled'] + + +def test_bulk_preserves_existing_binding_and_policy_on_rerun(tmp_path, local_servers, provider, monkeypatch): + cfg = enrolled(tmp_path) + cfg.bind('approved', 'world', 7, actions=['status'], publication=True, + metadata=dict(game_id='minecraft.vanilla', host='approved.example', port=30000, + connect_port=30001, name='Approved', capacity=30)) + old = cfg.load() + answers(monkeypatch, ['', '', '', 'y']) + assert cli.run(['bind', '*'], root=cfg.root) == 0 + assert cfg.load() == old + assert provider == [] + + +def test_cancel_before_registration_or_configuration(tmp_path, local_servers, provider, monkeypatch): + cfg = Config(tmp_path / 'agent') + answers(monkeypatch, ['', '', '', 'n']) + assert cli.run(['bind', '*', '--host', '8.8.8.8'], root=cfg.root) == 1 + assert provider == [] + assert not cfg.path.exists() + + +@pytest.mark.parametrize('scope', ['reporting', 'management']) +def test_guided_registration_uses_available_scope(tmp_path, local_servers, provider, monkeypatch, scope): + cfg = enrolled(tmp_path, reporting=scope == 'reporting', management=scope == 'management') + monkeypatch.setattr('builtins.input', lambda prompt: 'y' if prompt.startswith('Save these exact') else '') + assert cli.run(['bind', 'world', '--host', '8.8.8.8'], root=cfg.root) == 0 + assert provider[0][3]['payload']['scope'] == scope + + +def test_legacy_full_flags_remain_noninteractive_and_exact_game(tmp_path, local_servers, monkeypatch): + cfg = enrolled(tmp_path) + monkeypatch.setattr('builtins.input', lambda prompt: pytest.fail('legacy command prompted')) + args = ['bind', 'legacy-id', 'world', '--game', 'minecraft.vanilla', '--host', 'game.example', + '--port', '25565', '--name', 'World', '--actions', 'status'] + assert cli.run(args, root=cfg.root) == 0 + before = cfg.load() + assert cli.run([value.replace('minecraft.vanilla', 'minecraft.paper') for value in args], root=cfg.root) == 1 + assert cfg.load() == before + + +def test_setup_enrolls_once_then_binds_all_private_without_starting(tmp_path, local_servers, provider, monkeypatch): + local_servers[1]('tf2', module='teamfortress2', queryport='27025', port=27015, maxplayers='32') + answers(monkeypatch, ['', '', '', '', '', '', 'y']) + assert cli.run(['setup', '--host', '8.8.8.8'], root=tmp_path / 'agent') == 0 + value = Config(tmp_path / 'agent').load() + assert len(value['bindings']) == 2 + assert not value['reporting_enabled'] and not value['management_enabled'] + assert {b['metadata']['port'] for b in value['bindings'].values()} == {25565, 27025} + assert {b['metadata']['connect_port'] for b in value['bindings'].values()} == {25565, 27015} + assert all(not b['publication'] and not b['actions'] for b in value['bindings'].values()) + assert [call[2] for call in provider] == ['/v1/agent/enroll', '/v1/agent/bindings', '/v1/agent/bindings'] + + +def test_individual_games_keep_distinct_subdomains_on_one_machine(tmp_path, local_servers, provider, monkeypatch): + local_servers[1]('tf2', module='teamfortress2', queryport=27025, port=27015) + cfg = enrolled(tmp_path) + endpoints = {'world': ('minecraft.mygameserver.com', 25565), 'tf2': ('tf2.mygameserver.com', 27025)} + for tag, (hostname, _) in endpoints.items(): + answers(monkeypatch, ['', 'y']) + assert cli.run(['bind', tag, '--host', hostname, '--public-ip', '1.1.1.1', + '--publish', '--actions', 'status'], root=cfg.root) == 0 + before = cfg.load()['bindings'] + by_tag = {item['tag']: item for item in before.values()} + assert set(by_tag) == set(endpoints) and len(provider) == 2 + for tag, endpoint in endpoints.items(): + assert (by_tag[tag]['metadata']['host'], by_tag[tag]['metadata']['port']) == endpoint + assert by_tag[tag]['expected_public_ips'] == ['1.1.1.1'] + assert by_tag[tag]['publication'] and by_tag[tag]['actions'] == ['status'] + # Editing Minecraft's public endpoint cannot overwrite TF2 or register new IDs. + answers(monkeypatch, ['', '', '', 'y']) + assert cli.run(['bind', 'world', '--host', 'survival.mygameserver.com', '--port', '25566'], root=cfg.root) == 0 + after = cfg.load()['bindings'] + assert set(after) == set(before) and len(provider) == 2 + assert next(item for item in after.values() if item['tag'] == 'tf2') == by_tag['tf2'] + world = next(item for item in after.values() if item['tag'] == 'world') + assert (world['metadata']['host'], world['metadata']['port']) == ('survival.mygameserver.com', 25566) + assert world['expected_public_ips'] == ['1.1.1.1'] and world['publication'] + + +def test_bulk_prompts_bind_per_game_subdomains_and_preserve_them_on_rerun(tmp_path, local_servers, provider, monkeypatch): + local_servers[1]('world', publicip='1.1.1.1') + local_servers[1]('tf2', module='teamfortress2', queryport=27025, port=27015, publicip='1.1.1.1') + cfg = enrolled(tmp_path) + original = {path.name: path.read_bytes() for path in local_servers[0].glob('*.json')} + endpoints = {'tf2': 'tf2.mygameserver.com:27025', 'world': 'minecraft.mygameserver.com:25565'} + def reply(prompt): + for tag, endpoint in endpoints.items(): + if prompt.startswith('Confirm public host:port for ' + tag + ' '): + return endpoint + return 'y' if prompt.startswith('Save these exact') else '' + monkeypatch.setattr('builtins.input', reply) + assert cli.run(['bind', '*', '--public-ip', '1.1.1.1', '--publish', '--actions', 'status'], root=cfg.root) == 0 + before = cfg.path.read_bytes() + assert {item['tag']: item['metadata']['host'] + ':' + str(item['metadata']['port']) + for item in cfg.load()['bindings'].values()} == endpoints + answers(monkeypatch, ['', '', '', '', '', '', 'y']) + assert cli.run(['bind', '*'], root=cfg.root) == 0 + assert cfg.path.read_bytes() == before and len(provider) == 2 + assert {path.name: path.read_bytes() for path in local_servers[0].glob('*.json')} == original + + +def test_explicit_host_bypasses_every_discovery_and_nat_port_override(tmp_path, local_servers, provider, monkeypatch): + from agent import onboarding + cfg = enrolled(tmp_path) + monkeypatch.setattr(onboarding, 'suggest_public_host', lambda metadata: pytest.fail('explicit host must bypass detection')) + answers(monkeypatch, ['', '', '', 'y']) + assert cli.run(['bind', 'world', '--host', 'public.example', '--port', '30000'], root=cfg.root) == 0 + metadata = next(iter(cfg.load()['bindings'].values()))['metadata'] + assert metadata['host'] == 'public.example' and metadata['port'] == 30000 + + +def test_private_unprofiled_canonical_game_is_bindable(tmp_path, local_servers, provider, monkeypatch, capsys): + local_servers[1]('terraria', module='terraria.vanilla', port=7777, servername='', maxplayers=None) + cfg = enrolled(tmp_path) + answers(monkeypatch, ['', '', 'y']) + assert cli.run(['bind', 'terraria', '--host', '8.8.8.8'], root=cfg.root) == 0 + item = next(iter(cfg.load()['bindings'].values())) + assert item['metadata']['game_id'] == 'terraria.vanilla' and item['metadata']['name'] == 'terraria' + assert item['metadata']['capacity'] == 0 and not item['publication'] + assert 'Public listing unavailable' in capsys.readouterr().out + + +def test_unsupported_publication_rejects_before_all_writes(tmp_path, local_servers, provider, monkeypatch): + local_servers[1]('terraria', module='terraria.vanilla', port=7777) + cfg = enrolled(tmp_path) + old = cfg.path.read_bytes() + answers(monkeypatch, ['']) + assert cli.run(['bind', 'terraria', '--host', '8.8.8.8', '--publish'], root=cfg.root) == 1 + assert cfg.path.read_bytes() == old and provider == [] + + +@pytest.mark.parametrize('invalid', ['10.1.2.3:25565', 'http://public.example:25565', 'public.example:0', 'public.example:70000']) +def test_invalid_endpoint_does_not_register_or_save(tmp_path, local_servers, provider, monkeypatch, invalid): + cfg = enrolled(tmp_path) + old = cfg.path.read_bytes() + answers(monkeypatch, [invalid]) + assert cli.run(['bind', 'world', '--host', '8.8.8.8'], root=cfg.root) == 1 + assert cfg.path.read_bytes() == old and provider == [] + + +@pytest.mark.parametrize('exception, expected', [(EOFError, 1), (KeyboardInterrupt, 130)]) +def test_unattended_or_interrupted_guidance_never_saves(tmp_path, local_servers, provider, monkeypatch, exception, expected): + cfg = enrolled(tmp_path) + old = cfg.path.read_bytes() + def reject(prompt): + raise exception() + monkeypatch.setattr('builtins.input', reject) + assert cli.run(['bind', '*', '--host', '8.8.8.8'], root=cfg.root) == expected + assert cfg.path.read_bytes() == old and provider == [] + + +def test_bulk_reports_unconfigured_port_and_binds_other_valid_server(tmp_path, local_servers, provider, monkeypatch, capsys): + local_servers[1]('broken', port=0, queryport='bad') + cfg = enrolled(tmp_path) + answers(monkeypatch, ['', '', '', 'y']) + assert cli.run(['bind', '*', '--host', '8.8.8.8'], root=cfg.root) == 1 + assert [b['tag'] for b in cfg.load()['bindings'].values()] == ['world'] + assert 'Skipped broken: No valid configured port' in capsys.readouterr().out + + +def test_bulk_registration_partial_failure_reports_and_retains_success(tmp_path, local_servers, provider, monkeypatch, capsys): + local_servers[1]('z-fail') + cfg = enrolled(tmp_path) + original = cli.Transport + class Partial(original): + def request(self, method, path, **kwargs): + if kwargs['payload']['binding_ref'] == hashlib.sha256(b'z-fail').hexdigest(): + raise TransportError('offline') + return super().request(method, path, **kwargs) + monkeypatch.setattr(cli, 'Transport', Partial) + answers(monkeypatch, ['', '', '', '', '', '', 'y']) + assert cli.run(['bind', '*', '--host', '8.8.8.8'], root=cfg.root) == 1 + assert [b['tag'] for b in cfg.load()['bindings'].values()] == ['world'] + assert 'Failed z-fail:' in capsys.readouterr().out + + +def test_bind_same_policy_is_idempotent_in_real_config(tmp_path): + cfg = enrolled(tmp_path) + metadata = dict(game_id='minecraft.vanilla', host='public.example', port=25565, name='World', capacity=20) + cfg.bind('binding-1', 'world', 1, actions=[], publication=False, metadata=metadata) + old = cfg.path.read_bytes() + cfg.bind('binding-1', 'world', 1, actions=[], publication=False, metadata=metadata) + assert cfg.path.read_bytes() == old + + +def test_legacy_private_binding_accepts_any_exact_canonical_game(tmp_path, local_servers, monkeypatch): + local_servers[1]('terraria', module='terraria.vanilla', port=7777) + cfg = enrolled(tmp_path) + monkeypatch.setattr('builtins.input', lambda prompt: pytest.fail('legacy command prompted')) + args = ['bind', 'legacy-id', 'terraria', '--game', 'terraria.vanilla', '--host', 'public.example', + '--port', '7777', '--name', 'Terraria'] + assert cli.run(args, root=cfg.root) == 0 + old = cfg.load() + assert cli.run(args + ['--publish'], root=cfg.root) == 1 + assert cfg.load() == old + + +def test_one_bulk_port_override_is_rejected_for_multiple_servers(tmp_path, local_servers, provider, monkeypatch): + local_servers[1]('another') + cfg = enrolled(tmp_path) + old = cfg.path.read_bytes() + monkeypatch.setattr('builtins.input', lambda prompt: pytest.fail('invalid bulk override must fail before prompts')) + assert cli.run(['bind', '*', '--host', '8.8.8.8', '--port', '30000'], root=cfg.root) == 1 + assert cfg.path.read_bytes() == old and provider == [] + + +@pytest.mark.parametrize('bad_origin', ['https://control.example//', 'https://control.example/?', + 'https://control.example/#', 'https://control.example/\n', 'https://control.example/?x=y']) +def test_origin_accepts_only_clean_root_trailing_slash(bad_origin): + with pytest.raises(ValueError): + origin(bad_origin) + + +@pytest.mark.parametrize('result', [ + dict(schema_version=1, binding_id='bad/id', binding_epoch=1), + dict(schema_version=1, binding_id='good', binding_epoch=True), + dict(schema_version=1, binding_id='good', binding_epoch=1, tag='world'), +]) +def test_registration_response_is_closed_and_validated_before_save(tmp_path, local_servers, provider, monkeypatch, result): + cfg = enrolled(tmp_path) + old = cfg.path.read_bytes() + class Bad: + def __init__(self, api_origin): + pass + def request(self, *args, **kwargs): + return result + monkeypatch.setattr(cli, 'Transport', Bad) + answers(monkeypatch, ['', '', '', 'y']) + assert cli.run(['bind', 'world', '--host', '8.8.8.8'], root=cfg.root) == 1 + assert cfg.path.read_bytes() == old + + +def test_registered_id_cannot_replace_another_existing_local_binding(tmp_path, local_servers, provider, monkeypatch): + local_servers[1]('other') + cfg = enrolled(tmp_path) + cfg.bind('binding-1', 'other', 1, actions=['status'], publication=False, + metadata=dict(game_id='minecraft.vanilla', host='approved.example', port=25565, name='Other', capacity=20)) + old = cfg.path.read_bytes() + answers(monkeypatch, ['', '', '', 'y']) + assert cli.run(['bind', 'world', '--host', '8.8.8.8'], root=cfg.root) == 1 + assert cfg.path.read_bytes() == old + + +def test_invalid_explicit_binding_id_rejects_before_enrollment_and_prompt(tmp_path, local_servers, provider, monkeypatch): + cfg = Config(tmp_path / 'agent') + monkeypatch.setattr('builtins.input', lambda prompt: pytest.fail('invalid ID must be validated first')) + assert cli.run(['bind', 'world', '--binding-id', 'bad/id', '--host', '8.8.8.8'], root=cfg.root) == 1 + assert provider == [] and not cfg.path.exists() + + +def test_bulk_port_error_explains_single_server_override(tmp_path, local_servers, provider, capsys): + local_servers[1]('another') + cfg = enrolled(tmp_path) + assert cli.run(['bind', '*', '--port', '30000', '--host', '8.8.8.8'], root=cfg.root) == 1 + assert 'bind one tag' in capsys.readouterr().out + + +def test_policy_changed_during_preview_cannot_restore_permissions(tmp_path, local_servers, provider, monkeypatch): + cfg = enrolled(tmp_path) + metadata = dict(game_id='minecraft.vanilla', host='approved.example', port=25565, name='World', capacity=20) + cfg.bind('approved', 'world', 3, actions=['start'], publication=False, metadata=metadata) + prompts = iter(['', '', '', 'y']) + def reply(prompt): + if prompt.startswith('Save these exact'): + cfg.bind('approved', 'world', 3, actions=[], publication=False, metadata=metadata) + return next(prompts) + monkeypatch.setattr('builtins.input', reply) + assert cli.run(['bind', '*'], root=cfg.root) == 1 + assert cfg.load()['bindings']['approved']['actions'] == [] and provider == [] + + +def test_changed_game_between_preview_and_save_is_rejected(tmp_path, local_servers, provider, monkeypatch): + cfg = enrolled(tmp_path) + old = cfg.path.read_bytes() + prompts = iter(['', '', '', 'y']) + def reply(prompt): + if prompt.startswith('Save these exact'): + local_servers[1]('world', module='minecraft.paper') + return next(prompts) + monkeypatch.setattr('builtins.input', reply) + assert cli.run(['bind', 'world', '--host', '8.8.8.8'], root=cfg.root) == 1 + assert cfg.path.read_bytes() == old and provider == [] + + +def test_publication_guard_applies_to_existing_publication_command(tmp_path, local_servers): + cfg = enrolled(tmp_path) + metadata = dict(game_id='terraria.vanilla', host='public.example', port=7777, name='Terraria', capacity=20) + cfg.bind('terraria-binding', 'terraria', 1, actions=[], publication=False, metadata=metadata) + old = cfg.path.read_bytes() + assert cli.run(['publication', 'terraria-binding', 'on'], root=cfg.root) == 1 + assert cfg.path.read_bytes() == old + with pytest.raises(ValueError): + cfg.bind('terraria-binding', 'terraria', 1, actions=[], publication=True, metadata=metadata) + assert cfg.path.read_bytes() == old + + +def test_enrollment_never_falls_back_to_echoed_input(tmp_path, provider, monkeypatch): + import getpass + import warnings + def no_terminal(prompt): + warnings.warn('echoed fallback', getpass.GetPassWarning) + return 'unsafe-code' + monkeypatch.setattr(cli.getpass, 'getpass', no_terminal) + assert cli.run(['enroll'], root=tmp_path / 'agent') == 1 + assert not Config(tmp_path / 'agent').path.exists() and provider == [] + + +def test_bulk_network_fallback_is_detected_once(tmp_path, local_servers, provider, monkeypatch): + from agent import onboarding + cfg = enrolled(tmp_path) + local_servers[1]('another') + calls = [] + monkeypatch.setattr(onboarding, 'suggest_public_host', lambda metadata: calls.append(metadata) or '8.8.8.8') + answers(monkeypatch, ['', '', '', '', '', '', 'y']) + assert cli.run(['bind', '*'], root=cfg.root) == 0 + assert len(calls) == 1 + + +def test_final_preview_contains_all_approved_metadata_overrides(tmp_path, local_servers, provider, monkeypatch, capsys): + cfg = enrolled(tmp_path) + answers(monkeypatch, ['', '', '', 'n']) + assert cli.run(['bind', 'world', '--host', '8.8.8.8', '--name', 'Approved title', '--capacity', '42'], root=cfg.root) == 1 + preview = next(line for line in capsys.readouterr().out.splitlines() if line.startswith('Preview:')) + assert all(value in preview for value in ('Approved title', '42', 'minecraft.vanilla', '8.8.8.8:25565')) + assert provider == [] and not cfg.load()['bindings'] + + +@pytest.mark.parametrize('scope', ['reporting', 'management']) +def test_known_scope_limits_permission_prompts_and_next_steps(tmp_path, local_servers, provider, monkeypatch, capsys, scope): + cfg = enrolled(tmp_path, reporting=scope == 'reporting', management=scope == 'management') + prompts = [] + def reply(prompt): + prompts.append(prompt) + if scope == 'reporting': + assert 'Allowed remote actions' not in prompt + else: + assert 'Publish this server' not in prompt + return 'y' if prompt.startswith('Save these exact') else '' + monkeypatch.setattr('builtins.input', reply) + assert cli.run(['bind', 'world', '--host', '8.8.8.8'], root=cfg.root) == 0 + output = capsys.readouterr().out + available = 'enable-' + scope + unavailable = 'enable-' + ('management' if scope == 'reporting' else 'reporting') + assert available in output and unavailable not in output + item = next(iter(cfg.load()['bindings'].values())) + assert not item['actions'] and not item['publication'] + + +@pytest.mark.parametrize('scope, override', [('reporting', ['--actions', 'start']), ('management', ['--publish'])]) +def test_explicit_unavailable_permission_rejects_before_any_prompt(tmp_path, local_servers, provider, monkeypatch, scope, override): + cfg = enrolled(tmp_path, reporting=scope == 'reporting', management=scope == 'management') + old = cfg.path.read_bytes() + monkeypatch.setattr('builtins.input', lambda prompt: pytest.fail('unavailable permission must reject before prompts')) + assert cli.run(['bind', 'world', '--host', '8.8.8.8'] + override, root=cfg.root) == 1 + assert cfg.path.read_bytes() == old and provider == [] + + +@pytest.mark.parametrize('binding_id, epoch', [('selected-id', 4), (None, None)]) +def test_final_preview_shows_binding_identity_or_pending_registration(tmp_path, local_servers, provider, monkeypatch, capsys, binding_id, epoch): + cfg = enrolled(tmp_path) + args = ['bind', 'world', '--host', '8.8.8.8'] + if binding_id: + args += ['--binding-id', binding_id, '--epoch', str(epoch)] + answers(monkeypatch, ['', '', '', 'n']) + assert cli.run(args, root=cfg.root) == 1 + preview = next(line for line in capsys.readouterr().out.splitlines() if line.startswith('Preview:')) + if binding_id: + assert 'binding=' + binding_id in preview and 'epoch=' + str(epoch) in preview + else: + assert 'binding=pending provider registration' in preview and 'epoch=pending' in preview + assert provider == [] and not cfg.load()['bindings'] + + +@pytest.mark.parametrize('invalid_host', ['', 'https://public.example/path', '10.1.2.3']) +def test_explicit_invalid_host_never_discovers_network_or_prompts(tmp_path, local_servers, provider, monkeypatch, invalid_host): + from agent import onboarding + cfg = enrolled(tmp_path) + old = cfg.path.read_bytes() + monkeypatch.setattr(onboarding, 'suggest_public_host', lambda metadata: pytest.fail('explicit host cannot detect network')) + monkeypatch.setattr('builtins.input', lambda prompt: pytest.fail('invalid explicit host must reject before prompts')) + assert cli.run(['bind', 'world', '--host', invalid_host], root=cfg.root) == 1 + assert cfg.path.read_bytes() == old and provider == [] diff --git a/tests/unit_tests/agent/test_origins.py b/tests/unit_tests/agent/test_origins.py new file mode 100644 index 00000000..3509f7b0 --- /dev/null +++ b/tests/unit_tests/agent/test_origins.py @@ -0,0 +1,154 @@ +"""Official onboarding and execution origins are selected locally, never by redirect.""" +from contextlib import nullcontext +import importlib +import json +from types import SimpleNamespace + +import pytest + +from agent import cli +from agent.config import Config +from agent.service import Service +from agent.transport import Transport +import agent.service as service_module +from test_onboarding import answers, local_servers, provider + +API = 'https://alphagsm-api.sector-alpha.net' +ALIASES = ['https://sector-alpha.net', 'https://sector-alpha.net/', 'https://sector-alpha.net:443', + 'https://SECTOR-ALPHA.NET:443/', 'HTTPS://Sector-Alpha.Net/', + 'https://ALPHAGSM-API.SECTOR-ALPHA.NET:443/'] + + +def enrollment(): + return dict(schema_version=1, installation_id='existing-install', credential_generation=3, + reporting=dict(key_id='old-report', secret='r' * 43), + management=dict(key_id='old-management', secret='m' * 43)) + + +def legacy_config(tmp_path, old): + cfg = Config(tmp_path / 'agent') + cfg.enroll('https://control.example', enrollment()) + cfg.bind('old-binding', 'world', 7, actions=['status', 'restart'], publication=True, + metadata=dict(game_id='minecraft.vanilla', host='8.8.8.8', port=25565, name='World', capacity=20)) + cfg.enable(reporting=True, management=True) + value = cfg.load() + value['origin'] = old + cfg.path.write_text(json.dumps(value, sort_keys=True)) + return cfg, value + + +@pytest.mark.parametrize('old', ALIASES) +def test_legacy_official_origin_is_normalized_locally_without_changing_relationship(tmp_path, old): + cfg, before = legacy_config(tmp_path, old) + original_bytes = cfg.path.read_bytes() + loaded = cfg.load() + assert loaded == {**before, 'origin': API} + assert cfg.path.read_bytes() == original_bytes, 'Read-only upgrade must not write policy or credentials' + cfg.changed() + saved = json.loads(cfg.path.read_text()) + assert saved['origin'] == API + assert {key: saved[key] for key in saved if key != 'dirty_at'} == { + key: loaded[key] for key in loaded if key != 'dirty_at'} + assert set(saved) == set(Config.defaults()) and 'reporting_origin' not in saved + + +@pytest.mark.parametrize('old', ALIASES) +def test_enroll_normalizes_provider_before_transport_submission(tmp_path, provider, old, monkeypatch): + import server.server as server_module + empty = tmp_path / 'servers' + empty.mkdir() + monkeypatch.setattr(server_module, 'DATAPATH', str(empty)) + assert cli.run(['enroll', '--origin', old], root=tmp_path / 'agent') == 0 + assert provider == [(API, 'POST', '/v1/agent/enroll', {'payload': {'code': 'hidden-code'}})] + assert Config(tmp_path / 'agent').load()['origin'] == API + + +@pytest.mark.parametrize('custom', ['https://control.example', 'https://CONTROL.example:443', + 'https://sector-alpha.net:8443', 'https://reports.sector-alpha.net', 'https://sector-alpha.net.attacker.example', + 'https://alphagsm-api.sector-alpha.net.attacker.example', 'https://sector-alpha%2Enet', + 'https://gameserver-api.sector-alpha.net', 'https://gameserver-api.sector-alpha.net:443']) +def test_custom_and_lookalike_origins_are_not_remapped(tmp_path, custom): + config_module = importlib.import_module('agent.config') + canonical = getattr(config_module, 'canonical_agent_origin', None) + assert canonical, 'missing exact trusted official-origin canonicalization' + assert canonical(custom) == custom + assert Transport(custom).origin == custom + cfg = Config(tmp_path / 'agent') + cfg.enroll(custom, enrollment()) + assert cfg.load()['origin'] == custom + + +@pytest.mark.parametrize('unsafe', ['http://sector-alpha.net', 'https://sector-alpha.net/agent', + 'https://sector-alpha.net?next=attacker', 'https://sector-alpha.net#attacker', + 'https://user@sector-alpha.net', 'https://sector-alpha.net@attacker.example']) +def test_alias_mapping_never_relaxes_origin_validation(unsafe): + config_module = importlib.import_module('agent.config') + canonical = getattr(config_module, 'canonical_agent_origin', None) + assert canonical, 'missing exact trusted official-origin canonicalization' + with pytest.raises(ValueError): + canonical(unsafe) + + +def test_old_relationship_uses_one_new_api_transport_for_reporting_and_management(tmp_path, monkeypatch): + cfg, before = legacy_config(tmp_path, 'https://SECTOR-ALPHA.NET:443/') + constructed, sent = [], [] + class Provider: + def __init__(self, api_origin): + constructed.append(api_origin) + def request(self, method, path, **kwargs): + sent.append((method, path, kwargs)) + return {'job': None} + monkeypatch.setattr(service_module, 'Transport', Provider) + runtime = SimpleNamespace(lock=lambda tag: nullcontext(), state=lambda tag: 'running', metadata=lambda tag, approved: approved) + service = Service(cfg, runtime=runtime) + service.report_once() + service.management_once() + assert constructed == [API] and service.relationship == (API, before['installation_id']) + assert [path for method, path, kwargs in sent] == ['/v1/agent/reports', '/v1/agent/policy', '/v1/agent/jobs'] + assert sent[0][2]['credential'] == before['reporting'] + assert all(kwargs['credential'] == before['management'] for method, path, kwargs in sent[1:]) + assert cfg.load()['bindings'] == before['bindings'] and cfg.load()['policy_version'] == before['policy_version'] + + +def test_legacy_registration_preserves_provider_and_existing_bindings(tmp_path, local_servers, provider, monkeypatch): + local_servers[1]('new-server') + cfg, before = legacy_config(tmp_path, 'https://sector-alpha.net') + answers(monkeypatch, ['', '', '', 'y']) + assert cli.run(['bind', 'new-server', '--host', '8.8.8.8', '--origin', 'https://SECTOR-ALPHA.NET:443/'], root=cfg.root) == 0 + assert provider[0][0:3] == (API, 'POST', '/v1/agent/bindings') + assert provider[0][3]['credential'] == before['reporting'] + assert cfg.load()['bindings']['old-binding'] == before['bindings']['old-binding'] + + +def test_direct_transport_canonicalizes_old_official_origin_before_credentials(): + assert Transport('https://SECTOR-ALPHA.NET:443/').origin == API + + +@pytest.mark.parametrize('provider,destination', [ + *[(alias, API) for alias in [API, API + ':443/', *ALIASES]], + ('https://control.example', 'https://control.example'), + ('https://sector-alpha.net:8443', 'https://sector-alpha.net:8443'), + ('https://alphagsm-api.sector-alpha.net.attacker.example', + 'https://alphagsm-api.sector-alpha.net.attacker.example'), +]) +def test_transport_uses_the_configured_origin_for_every_operation(monkeypatch, provider, destination): + transport = Transport(provider) + sent = [] + def capture(request, _timeout): + sent.append((request.full_url, request.get_method(), request.get_header('Authorization'))) + return {'accepted': True} + monkeypatch.setattr(transport, '_read_response', capture) + credential = {'key_id': 'test-key', 'secret': 's' * 43} + for method, path in ( + ('POST', '/v1/agent/enroll'), + ('POST', '/v1/agent/bindings'), + ('POST', '/v1/agent/challenge'), + ('POST', '/v1/agent/reports'), + ('POST', '/v1/agent/policy'), + ('GET', '/v1/agent/jobs'), + ('POST', '/v1/agent/jobs/job-1/authorize'), + ('POST', '/v1/agent/results'), + ): + transport.request(method, path, credential=credential) + assert sent[-1] == (destination + path, method, 'Bearer test-key.' + 's' * 43) + assert transport.origin == destination diff --git a/tests/unit_tests/agent/test_preflight.py b/tests/unit_tests/agent/test_preflight.py new file mode 100644 index 00000000..4a39492c --- /dev/null +++ b/tests/unit_tests/agent/test_preflight.py @@ -0,0 +1,457 @@ +"""Hostname publication stays local until every fresh DNS address matches this machine.""" +from contextlib import nullcontext +import importlib +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from agent import cli +from agent.config import Config +from agent.reporting import report_snapshot +from agent.service import Service +from test_onboarding import answers, enrolled, local_servers, provider + + +def preflight(): + assert importlib.util.find_spec('agent.preflight'), 'missing hostname publication preflight' + return importlib.import_module('agent.preflight') + + +def add_binding(cfg, binding_id='world', host='game.example', expected=None, publication=True): + kwargs = {} if expected is None else {'expected_public_ips': expected} + cfg.bind(binding_id, binding_id, 1, actions=[], publication=publication, + metadata=dict(game_id='minecraft.vanilla', host=host, port=25565, name='World', capacity=20), **kwargs) + + +def runtime(): + return SimpleNamespace(lock=lambda tag: nullcontext(), state=lambda tag: 'running', metadata=lambda tag, approved: approved) + + +def published_config(tmp_path): + cfg = enrolled(tmp_path) + cfg.enable(reporting=True) + return cfg + + +def test_runtime_pins_bounded_dns_library(): + root = Path(__file__).resolve().parents[3] + assert 'dnspython==2.7.0' in (root / 'requirements-runtime.txt').read_text().splitlines() + + +def test_local_expected_ips_are_validated_optional_and_preserved_on_rerun(tmp_path): + cfg = enrolled(tmp_path) + add_binding(cfg, expected=['2606:4700:4700:0:0:0:0:1111', '8.8.8.8', '8.8.8.8']) + value = cfg.load() + assert value['bindings']['world']['expected_public_ips'] == ['2606:4700:4700::1111', '8.8.8.8'] + add_binding(cfg) + assert cfg.load() == value + for invalid in (['10.1.2.3'], ['127.0.0.1'], ['game.example'], [], '8.8.8.8', ['224.0.0.1']): + with pytest.raises(ValueError): + add_binding(cfg, expected=invalid) + assert cfg.load() == value + + +def test_old_config_and_numeric_private_reports_remain_offline(tmp_path, monkeypatch): + module = preflight() + cfg = published_config(tmp_path) + add_binding(cfg, 'numeric', host='8.8.8.8') + add_binding(cfg, 'private', publication=False) + snapshot = report_snapshot(cfg, runtime()) + def forbidden(*args, **kwargs): + pytest.fail('private/numeric reports must not discover, resolve or probe') + outgoing, failures = module.preflight_report(snapshot, cfg.load(), resolve=forbidden, discover=forbidden, read=forbidden) + assert outgoing == snapshot and not failures + assert 'expected_public_ips' not in json.dumps(outgoing) + + +@pytest.mark.parametrize('dns_answers, expected, allowed', [ + (['8.8.8.8'], ['8.8.8.8'], True), + (['8.8.4.4'], ['8.8.8.8'], False), + ([], ['8.8.8.8'], False), + (['8.8.8.8', '10.1.2.3'], ['8.8.8.8'], False), + (['8.8.8.8', '8.8.4.4'], ['8.8.8.8'], False), + (['8.8.8.8', '2606:4700:4700::1111'], ['8.8.8.8', '2606:4700:4700::1111'], True), + (['8.8.8.8', '2606:4700:4700::1111'], ['8.8.8.8'], False), +]) +def test_every_dns_answer_must_be_global_and_match_expected_machine(tmp_path, dns_answers, expected, allowed): + module = preflight() + cfg = published_config(tmp_path) + add_binding(cfg, expected=expected) + snapshot = report_snapshot(cfg, runtime()) + old = cfg.path.read_bytes() + calls = [] + def resolve(host, timeout): + calls.append((host, timeout)) + return dns_answers + outgoing, failures = module.preflight_report(snapshot, cfg.load(), resolve=resolve) + assert outgoing['bindings'][0]['publication'] is allowed + assert bool(failures) is not allowed + assert snapshot['bindings'][0]['publication'] is True and cfg.path.read_bytes() == old + assert calls[0][0] == 'game.example' and 0 < calls[0][1] <= 2 + assert 'expected_public_ips' not in json.dumps(outgoing) + + +def test_dns_change_and_repair_are_checked_every_renewal_without_consent_changes(tmp_path): + module = preflight() + cfg = published_config(tmp_path) + add_binding(cfg, expected=['8.8.8.8']) + replies = iter([['8.8.8.8'], ['8.8.4.4'], ['8.8.8.8']]) + flags = [] + for _ in range(3): + snapshot = report_snapshot(cfg, runtime()) + outgoing, _ = module.preflight_report(snapshot, cfg.load(), resolve=lambda host, timeout: next(replies)) + flags.append(outgoing['bindings'][0]['publication']) + assert flags == [True, False, True] + assert cfg.load()['bindings']['world']['publication'] is True + + +def test_one_bad_hostname_does_not_withdraw_other_bindings(tmp_path): + module = preflight() + cfg = published_config(tmp_path) + add_binding(cfg, 'bad', host='bad.example', expected=['8.8.8.8']) + add_binding(cfg, 'good', host='good.example', expected=['8.8.8.8']) + add_binding(cfg, 'numeric', host='8.8.8.8') + snapshot = report_snapshot(cfg, runtime()) + def resolve(host, timeout): + if host == 'bad.example': + raise TimeoutError('resolver unavailable') + return ['8.8.8.8'] + outgoing, failures = module.preflight_report(snapshot, cfg.load(), resolve=resolve) + assert {item['binding_id']: item['publication'] for item in outgoing['bindings']} == {'bad': False, 'good': True, 'numeric': True} + assert set(failures) == {'bad'} + + +def test_machine_ip_discovery_is_once_per_batch_and_local_hints_take_priority(tmp_path): + module = preflight() + cfg = published_config(tmp_path) + for name in ('a', 'b', 'hint'): + add_binding(cfg, name, host=name + '.example') + calls = [] + def read(tag): + return {'publicip': '8.8.4.4' if tag == 'hint' else '10.0.0.1'} + def discover(metadata): + calls.append(metadata) + return '8.8.8.8' + outgoing, failures = module.preflight_report(report_snapshot(cfg, runtime()), cfg.load(), read=read, discover=discover, + resolve=lambda host, timeout: ['8.8.4.4' if host == 'hint.example' else '8.8.8.8']) + assert all(item['publication'] for item in outgoing['bindings']) and not failures + assert len(calls) == 1 + + +def test_unavailable_machine_ip_withholds_hostname_with_actionable_reason(tmp_path): + module = preflight() + cfg = published_config(tmp_path) + add_binding(cfg) + outgoing, failures = module.preflight_report(report_snapshot(cfg, runtime()), cfg.load(), read=lambda tag: {}, + discover=lambda metadata: None, resolve=lambda *args: pytest.fail('no expected machine IP means no DNS/probe')) + assert not outgoing['bindings'][0]['publication'] + assert '--public-ip' in failures['world'] + + +def test_service_gates_actual_post_without_changing_local_consent(tmp_path, monkeypatch): + module = preflight() + cfg = published_config(tmp_path) + add_binding(cfg, expected=['8.8.8.8']) + monkeypatch.setattr(module, 'resolve_host', lambda host, timeout: ['8.8.4.4']) + sent = [] + service = Service(cfg, runtime=runtime(), transport=SimpleNamespace(request=lambda method, path, **kwargs: sent.append(kwargs['payload']))) + service.report_once() + assert sent[0]['bindings'][0]['publication'] is False + assert cfg.load()['bindings']['world']['publication'] is True + assert 'expected_public_ips' not in json.dumps(sent) + + +def test_guided_override_is_local_only_and_survives_exact_rerun(tmp_path, local_servers, provider, monkeypatch, capsys): + cfg = enrolled(tmp_path) + args = ['bind', 'world', '--host', 'game.example', '--public-ip', '8.8.8.8', '--public-ip', '2606:4700:4700::1111'] + answers(monkeypatch, ['', '', '', 'y']) + assert cli.run(args, root=cfg.root) == 0 + old = cfg.path.read_bytes() + binding = next(iter(cfg.load()['bindings'].values())) + assert binding['expected_public_ips'] == ['2606:4700:4700::1111', '8.8.8.8'] + assert '8.8.8.8' in capsys.readouterr().out + assert set(provider[0][3]['payload']) == {'scope', 'binding_ref'} + answers(monkeypatch, ['', '', '', 'y']) + assert cli.run(['bind', 'world', '--host', 'game.example'], root=cfg.root) == 0 + assert cfg.path.read_bytes() == old and len(provider) == 1 + + +@pytest.mark.parametrize('address', ['2002:0808:0808::1', '::ffff:8.8.8.8', '2001:0:4136:e378:8000:63bf:3fff:fdd2']) +def test_expected_ip_and_dns_answers_reject_ipv6_translation_tunnels(address): + from agent.publication_config import normalize_public_ips + module = preflight() + with pytest.raises(ValueError): + normalize_public_ips([address]) + with pytest.raises(ValueError): + module.matched_addresses([address], {address}) + + +def test_explicit_endpoint_probe_uses_only_matched_pinned_ips_and_original_host(tmp_path): + module = preflight() + endpoint = importlib.import_module('agent.endpoint_check') if importlib.util.find_spec('agent.endpoint_check') else None + assert endpoint, 'missing explicit endpoint check' + cfg = enrolled(tmp_path) + add_binding(cfg, expected=['8.8.8.8', '2606:4700:4700::1111']) + binding = cfg.load()['bindings']['world'] + calls = [] + result = endpoint.check_endpoint(binding, resolve=lambda host, timeout: ['8.8.8.8', '2606:4700:4700::1111'], + probe=lambda game, address, port, **kwargs: calls.append((game, address, port, kwargs)) or {}) + assert result == ['2606:4700:4700::1111', '8.8.8.8'] + assert {call[1] for call in calls} == {'8.8.8.8', '2606:4700:4700::1111'} + assert all(call[3]['virtual_host'] == 'game.example' and 0 < call[3]['timeout'] <= 5 for call in calls) + assert cfg.load()['bindings']['world'] == binding + + +def test_explicit_endpoint_check_rejects_unmatched_or_timeout_dns_without_probe(tmp_path): + assert importlib.util.find_spec('agent.endpoint_check'), 'missing explicit endpoint check' + endpoint = importlib.import_module('agent.endpoint_check') + cfg = enrolled(tmp_path) + add_binding(cfg, expected=['8.8.8.8']) + def no_probe(*args, **kwargs): + pytest.fail('unmatched/failed DNS cannot trigger native I/O') + with pytest.raises(ValueError): + endpoint.check_endpoint(cfg.load()['bindings']['world'], resolve=lambda host, timeout: ['10.1.2.3'], probe=no_probe) + def unavailable(host, timeout): + raise TimeoutError('DNS expired') + with pytest.raises(ValueError): + endpoint.check_endpoint(cfg.load()['bindings']['world'], resolve=unavailable, probe=no_probe) + + +def test_native_failure_explains_public_port_and_nat_loopback(tmp_path): + assert importlib.util.find_spec('agent.endpoint_check'), 'missing explicit endpoint check' + endpoint = importlib.import_module('agent.endpoint_check') + cfg = enrolled(tmp_path) + add_binding(cfg, expected=['8.8.8.8']) + def refused(*args, **kwargs): + raise OSError('refused') + with pytest.raises(ValueError, match='NAT loopback'): + endpoint.check_endpoint(cfg.load()['bindings']['world'], resolve=lambda host, timeout: ['8.8.8.8'], probe=refused) + + +def test_check_endpoint_cli_does_not_write_config_or_offer_publication(tmp_path, local_servers, monkeypatch, capsys): + assert importlib.util.find_spec('agent.endpoint_check'), 'missing explicit endpoint check' + endpoint = importlib.import_module('agent.endpoint_check') + cfg = enrolled(tmp_path) + add_binding(cfg, expected=['8.8.8.8'], publication=False) + old = cfg.path.read_bytes() + monkeypatch.setattr(endpoint, 'check_endpoint', lambda binding, expected_public_ips=None: ['8.8.8.8']) + assert cli.run(['check-endpoint', 'world'], root=cfg.root) == 0 + assert cfg.path.read_bytes() == old + assert 'ownership challenge' in capsys.readouterr().out + + +def test_a2s_hostname_with_ipv6_answer_is_withheld_with_profile_reason(tmp_path): + module = preflight() + cfg = published_config(tmp_path) + cfg.bind('tf2', 'tf2', 1, actions=[], publication=True, expected_public_ips=['2606:4700:4700::1111'], + metadata=dict(game_id='teamfortress2', host='tf2.example', port=27015, name='TF2', capacity=24)) + outgoing, failures = module.preflight_report(report_snapshot(cfg, runtime()), cfg.load(), + resolve=lambda host, timeout: ['2606:4700:4700::1111']) + assert outgoing['bindings'][0]['publication'] is False + assert 'IPv4 only' in failures['tf2'] + + +def test_repeated_small_batches_do_not_permanently_starve_a_healthy_last_hostname(tmp_path, monkeypatch): + import time + module = preflight() + cfg = published_config(tmp_path) + hosts = ['host-' + str(index) + '.example' for index in range(7)] + last = list(set(hosts))[-1] + for index, host in enumerate(hosts): + add_binding(cfg, str(index), host=host, expected=['8.8.8.8']) + monkeypatch.setattr(module, 'DNS_WORKERS', 1) + monkeypatch.setattr(module, 'DNS_TIMEOUT', .04) + monkeypatch.setattr(module, 'BATCH_TIMEOUT', .05) + def resolve(host, timeout): + if host == last: + return ['8.8.8.8'] + time.sleep(timeout) + raise TimeoutError('DNS expired') + checked = [] + for _ in hosts: + outgoing, _ = module.preflight_report(report_snapshot(cfg, runtime()), cfg.load(), resolve=resolve) + checked += [item['publication'] for item in outgoing['bindings'] if item['host'] == last] + assert any(checked), 'Repeated early DNS failures permanently starved the last healthy hostname' + + +def test_invalid_expected_override_rejects_before_guidance_or_discovery(tmp_path, local_servers, provider, monkeypatch): + cfg = enrolled(tmp_path) + monkeypatch.setattr('builtins.input', lambda prompt: pytest.fail('invalid --public-ip must reject before prompts')) + assert cli.run(['bind', 'world', '--host', 'game.example', '--public-ip', '10.0.0.1'], root=cfg.root) == 1 + assert not cfg.load()['bindings'] and not provider + + +def test_explicit_native_check_uses_real_bounded_slp_with_original_virtual_host(monkeypatch): + """Loopback is permitted only by this test's explicit address-policy stub.""" + import socket + import threading + from agent import endpoint_check, publication_config + module = preflight() + try: + listener = socket.socket() + listener.bind(('127.0.0.1', 0)) + except PermissionError: + pytest.skip('The native protocol regression needs loopback socket permission') + listener.listen(1) + listener.settimeout(2) + port = listener.getsockname()[1] + seen, errors = [], [] + def peer(): + try: + connection, _ = listener.accept() + with connection: + connection.settimeout(2) + length = connection.recv(1)[0] + handshake = bytearray() + while len(handshake) < length: + handshake.extend(connection.recv(length - len(handshake))) + seen.append(bytes(handshake)) + assert connection.recv(2) == b'\x01\x00' + status = b'{"description":"World","players":{"online":0,"max":20},"version":{"name":"test"}}' + assert len(status) < 126 + connection.sendall(bytes([len(status) + 2, 0, len(status)]) + status) + except Exception as exc: + errors.append(exc) + worker = threading.Thread(target=peer, daemon=True) + worker.start() + original = publication_config.public_ip + allowed_test_address = lambda value: '127.0.0.1' if value == '127.0.0.1' else original(value) + monkeypatch.setattr(publication_config, 'public_ip', allowed_test_address) + monkeypatch.setattr(module, 'public_ip', allowed_test_address) + binding = {'tag': 'world', 'expected_public_ips': ['127.0.0.1'], + 'metadata': {'game_id': 'minecraft.vanilla', 'host': 'game.example', 'port': port}} + try: + assert endpoint_check.check_endpoint(binding, resolve=lambda host, timeout: ['127.0.0.1']) == ['127.0.0.1'] + worker.join(2) + assert not worker.is_alive() and not errors + assert b'game.example' in seen[0] and b'127.0.0.1' not in seen[0] + finally: + listener.close() + worker.join(2) + + +@pytest.mark.parametrize('change', ['removed', 'private', 'host_changed']) +def test_local_hostname_binding_changes_withhold_stale_snapshot_before_dns(tmp_path, change): + module = preflight() + cfg = published_config(tmp_path) + add_binding(cfg, expected=['8.8.8.8']) + snapshot = report_snapshot(cfg, runtime()) + if change == 'removed': + cfg.unbind('world') + elif change == 'private': + cfg.publication('world', False) + else: + add_binding(cfg, host='replacement.example') + outgoing, failures = module.preflight_report(snapshot, cfg.load(), + resolve=lambda *args: pytest.fail('changed local binding must not resolve old hostname')) + assert outgoing['bindings'][0]['publication'] is False and failures + + +def test_hostname_preflight_pool_is_bounded_and_does_not_leave_workers(tmp_path): + import threading + import time + module = preflight() + cfg = published_config(tmp_path) + for index in range(20): + add_binding(cfg, str(index), host='host-' + str(index) + '.example', expected=['8.8.8.8']) + lock = threading.Lock() + active = 0 + peak = 0 + def resolve(host, timeout): + nonlocal active, peak + with lock: + active += 1 + peak = max(peak, active) + try: + time.sleep(.005) + return ['8.8.8.8'] + finally: + with lock: + active -= 1 + for _ in range(2): + outgoing, failures = module.preflight_report(report_snapshot(cfg, runtime()), cfg.load(), resolve=resolve) + assert all(item['publication'] for item in outgoing['bindings']) and not failures + assert peak <= module.DNS_WORKERS and active == 0 + assert not any(worker.name.startswith('alphagsm-dns') for worker in threading.enumerate()) + + +@pytest.mark.parametrize('change', ['publication', 'reporting', 'endpoint', 'expected_ip']) +def test_service_rechecks_policy_after_dns_before_actual_post(tmp_path, monkeypatch, change): + module = preflight() + cfg = published_config(tmp_path) + add_binding(cfg, expected=['8.8.8.8']) + def resolve(host, timeout): + if change == 'publication': + cfg.publication('world', False) + elif change == 'reporting': + cfg.enable(reporting=False) + elif change == 'endpoint': + add_binding(cfg, host='changed.example') + else: + add_binding(cfg, expected=['8.8.4.4']) + return ['8.8.8.8'] + monkeypatch.setattr(module, 'resolve_host', resolve) + sent = [] + service = Service(cfg, runtime=runtime(), transport=SimpleNamespace( + request=lambda method, path, **kwargs: sent.append(kwargs))) + service.report_once() + assert len(sent) == 1 and sent[0]['payload']['bindings'][0]['publication'] is False + + +def test_service_uses_current_reporting_credential_after_dns_rotation(tmp_path, monkeypatch): + module = preflight() + cfg = published_config(tmp_path) + add_binding(cfg, expected=['8.8.8.8']) + def resolve(host, timeout): + cfg.replace_credential('reporting', 'rotated-key', 'n' * 43) + return ['8.8.8.8'] + monkeypatch.setattr(module, 'resolve_host', resolve) + sent = [] + service = Service(cfg, runtime=runtime(), transport=SimpleNamespace(request=lambda method, path, **kwargs: sent.append(kwargs))) + service.report_once() + assert sent[0]['credential'] == {'key_id': 'rotated-key', 'secret': 'n' * 43} + assert sent[0]['payload']['bindings'][0]['publication'] is True + + +def test_final_post_is_serialized_with_operator_policy_mutations(tmp_path, monkeypatch): + import threading + module = preflight() + cfg = published_config(tmp_path) + add_binding(cfg, expected=['8.8.8.8']) + monkeypatch.setattr(module, 'resolve_host', lambda host, timeout: ['8.8.8.8']) + attempted, changed = threading.Event(), threading.Event() + def revoke(): + attempted.set() + cfg.publication('world', False) + changed.set() + worker = threading.Thread(target=revoke) + def post(method, path, **kwargs): + worker.start() + assert attempted.wait(1) + assert not changed.wait(.05), 'Policy mutation was not serialized with the final POST' + assert kwargs['payload']['bindings'][0]['publication'] is True + return {'accepted': True} + service = Service(cfg, runtime=runtime(), transport=SimpleNamespace(request=post)) + try: + service.report_once() + finally: + worker.join(2) + assert changed.is_set() and cfg.load()['bindings']['world']['publication'] is False + + +def test_service_never_sends_new_relationship_credentials_through_old_transport(tmp_path): + cfg = published_config(tmp_path) + add_binding(cfg, host='8.8.8.8') + sent = [] + service = Service(cfg, runtime=runtime(), transport=SimpleNamespace(request=lambda *args, **kwargs: sent.append(kwargs) or {})) + cfg.enable(reporting=False) + cfg.forget() + cfg.enroll('https://replacement.example', dict(schema_version=1, installation_id='new-install', credential_generation=1, + reporting=dict(key_id='new-report', secret='n' * 43), management=dict(key_id='new-management', secret='m' * 43))) + add_binding(cfg, host='8.8.8.8') + cfg.enable(reporting=True, management=True) + assert service.report_once() is None + assert service.management_once() is None + assert sent == [] diff --git a/tests/unit_tests/agent/test_prove.py b/tests/unit_tests/agent/test_prove.py new file mode 100644 index 00000000..e3549c17 --- /dev/null +++ b/tests/unit_tests/agent/test_prove.py @@ -0,0 +1,118 @@ +"""The name proof shows the website token, waits for the check, and restores the old name.""" + +import json + +import pytest + +from agent.prove import QueryBusy, prove_name, temporary_name + + +def test_long_team_speak_name_is_replaced_by_the_token_and_then_restored(tmp_path): + token = "SA-" + "a" * 43 + current = "Sector Alpha - ts.sector-alpha.net" + assert temporary_name(current, token) == token + assert temporary_name("Voice", token) == "Voice " + token + names = [] + pending = tmp_path / "prove-name.json" + + def write_name(name): + names.append(name) + + state = prove_name( + "ts3", + token, + pending_path=pending, + read_name=lambda: current, + write_name=write_name, + request=lambda: {"job_id": "job-1"}, + poll=lambda job_id: {"state": "completed", "verified": True, "job_id": job_id}, + sleep=lambda seconds: None, + attempts=2, + ) + assert state["verified"] is True + assert names == [token, current] + assert not pending.exists() + assert token not in json.dumps(names[1]) + + +def test_failure_still_restores_the_previous_name(tmp_path): + token = "SA-" + "b" * 43 + names = [] + pending = tmp_path / "prove-name.json" + + def request(): + saved = json.loads(pending.read_text()) + assert saved == {"tag": "ts3", "name": "Original"} + assert token not in pending.read_text() + raise RuntimeError("offline") + + with pytest.raises(RuntimeError): + prove_name( + "ts3", + token, + pending_path=pending, + read_name=lambda: "Original", + write_name=names.append, + request=request, + poll=lambda job_id: {"state": "completed", "verified": False}, + sleep=lambda seconds: None, + ) + assert names == ["Original " + token, "Original"] + assert not pending.exists() + + +def test_interrupted_proof_restores_before_another_attempt(tmp_path): + token = "SA-" + "c" * 43 + pending = tmp_path / "prove-name.json" + pending.write_text(json.dumps({"tag": "ts3", "name": "Original"})) + seen = [] + prove_name( + "ts3", + token, + pending_path=pending, + read_name=lambda: seen.append("read") or "Original", + write_name=seen.append, + request=lambda: {"job_id": "job-1"}, + poll=lambda job_id: {"state": "completed", "verified": True}, + sleep=lambda seconds: None, + ) + assert seen[0] == "Original" + with pytest.raises(ValueError, match="other"): + pending.write_text(json.dumps({"tag": "other", "name": "Kept"})) + prove_name( + "ts3", + token, + pending_path=pending, + read_name=lambda: "Original", + write_name=seen.append, + request=lambda: {"job_id": "job-1"}, + poll=lambda job_id: {"state": "completed", "verified": True}, + sleep=lambda seconds: None, + ) + + +def test_busy_query_is_retried_and_the_previous_name_returns(tmp_path): + token = "SA-" + "d" * 43 + names = [] + calls = {"n": 0} + + def request(): + calls["n"] += 1 + if calls["n"] < 3: + raise QueryBusy("waiting") + return {"job_id": "job-1"} + + state = prove_name( + "ts3", + token, + pending_path=tmp_path / "prove-name.json", + read_name=lambda: "Original", + write_name=names.append, + request=request, + poll=lambda job_id: {"state": "completed", "verified": True}, + sleep=lambda seconds: None, + request_attempts=4, + ) + assert state["verified"] is True + assert calls["n"] == 3 + assert names == ["Original " + token, "Original"] diff --git a/tests/unit_tests/agent/test_report_deadline.py b/tests/unit_tests/agent/test_report_deadline.py new file mode 100644 index 00000000..fbd65d8a --- /dev/null +++ b/tests/unit_tests/agent/test_report_deadline.py @@ -0,0 +1,181 @@ +"""A final report cannot hold local policy mutations behind slow HTTPS headers.""" +import shutil +import socket +import ssl +import subprocess +import threading +import time + +import pytest + +from agent.config import Config +from agent.service import Service +from agent.transport import Transport, TransportError +import agent.transport as transport_module +from test_preflight import runtime + + +@pytest.fixture +def slow_report_peer(tmp_path): + openssl = shutil.which('openssl') + if not openssl: + pytest.skip('Loopback TLS regression needs openssl') + key, cert = tmp_path / 'peer.key', tmp_path / 'peer.crt' + subprocess.run([openssl, 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1', + '-subj', '/CN=127.0.0.1', '-addext', 'subjectAltName=IP:127.0.0.1', '-keyout', str(key), '-out', str(cert)], + check=True, capture_output=True, timeout=5) + try: + listener = socket.socket() + listener.bind(('127.0.0.1', 0)) + except PermissionError: + pytest.skip('Loopback TLS regression needs local socket permission') + listener.listen(1) + listener.settimeout(2) + server_tls = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + server_tls.load_cert_chain(cert, key) + client_tls = ssl.create_default_context(cafile=str(cert)) + seen, finished, stopped = threading.Event(), threading.Event(), threading.Event() + def peer(): + try: + raw, _ = listener.accept() + with server_tls.wrap_socket(raw, server_side=True) as connection: + connection.settimeout(2) + connection.recv(4096) + seen.set() + connection.sendall(b'HTTP/1.1 200 OK\r\n') + for _ in range(9): + if stopped.wait(.1): + return + connection.sendall(b'X-Slow: yes\r\n') + connection.sendall(b'Content-Length: 17\r\n\r\n{"accepted":true}') + except OSError: + pass + finally: + finished.set() + worker = threading.Thread(target=peer, daemon=True) + worker.start() + try: + yield 'https://127.0.0.1:' + str(listener.getsockname()[1]), client_tls, seen, finished + finally: + stopped.set() + listener.close() + worker.join(2) + + +def test_report_deadline_interrupts_real_tls_dripping_headers(slow_report_peer): + origin, context, _, finished = slow_report_peer + transport = Transport(origin, context=context) + started = time.monotonic() + with pytest.raises(TransportError): + transport.request('POST', '/v1/agent/reports', payload={}, timeout=.3) + assert time.monotonic() - started < .55, 'Report header drip escaped its total .3s deadline' + assert finished.wait(.3), 'Timed-out report did not close active TLS I/O' + + +def test_final_report_policy_lock_is_released_at_hard_network_budget(tmp_path, slow_report_peer, monkeypatch): + origin, context, seen, _ = slow_report_peer + monkeypatch.setattr(transport_module, 'REPORT_TIMEOUT', .3, raising=False) + cfg = Config(tmp_path / 'agent') + cfg.enroll(origin, dict(schema_version=1, installation_id='i', credential_generation=1, + reporting=dict(key_id='r', secret='r' * 43), management=None)) + cfg.bind('world', 'world', 1, actions=[], publication=True, + metadata=dict(game_id='minecraft.vanilla', host='8.8.8.8', port=25565, name='World', capacity=20)) + cfg.enable(reporting=True) + service = Service(cfg, runtime=runtime(), transport=Transport(origin, context=context)) + failures = [] + def report(): + try: + service.report_once() + except TransportError as exc: + failures.append(exc) + worker = threading.Thread(target=report) + worker.start() + try: + assert seen.wait(2) + started = time.monotonic() + cfg.publication('world', False) + assert time.monotonic() - started < .55, 'Slow report blocked an operator revocation beyond its budget' + worker.join(1) + assert not worker.is_alive() and failures + assert cfg.load()['bindings']['world']['publication'] is False + finally: + worker.join(2) + + +def test_reports_keep_fake_opener_boundary_and_only_reports_cap_timeout(): + from types import SimpleNamespace + context = ssl.create_default_context() + transport = Transport('https://control.example', context=context) + assert any(getattr(handler, '_context', None) is context for handler in transport.opener.handlers) + seen = [] + class Response: + def __init__(self, request): + self.request = request + self.body = b'{"accepted":true}' + self.fp = SimpleNamespace(raw=SimpleNamespace(_sock=SimpleNamespace(settimeout=lambda value: None))) + def __enter__(self): + return self + def __exit__(self, *args): + return False + def geturl(self): + return self.request.full_url + def read1(self, limit): + chunk, self.body = self.body[:limit], self.body[limit:] + return chunk + def open_request(request, timeout): + seen.append((request, timeout)) + return Response(request) + transport.opener = SimpleNamespace(open=open_request) + assert transport.request('POST', '/v1/agent/reports', payload={}, timeout=99) == {'accepted': True} + assert 0 < seen[0][1] <= 10 + assert seen[0][0].agent_deadline.family == socket.AF_UNSPEC + assert transport.request('GET', '/v1/agent/jobs', timeout=35) == {'accepted': True} + assert seen[1][1] == 35 and not hasattr(seen[1][0], 'agent_deadline') + + +def test_generic_deadline_connection_preserves_provider_ipv6(monkeypatch): + from agent import http_deadline + seen = [] + def resolve(host, port, **kwargs): + assert kwargs['family'] == socket.AF_UNSPEC + return [(socket.AF_INET6, socket.SOCK_STREAM, socket.IPPROTO_TCP, '', ('::1', port, 0, 0))] + class Connection: + def __init__(self, family, kind, protocol): + assert family == socket.AF_INET6 + def settimeout(self, timeout): + assert 0 < timeout <= 1 + def connect(self, destination): + seen.append(destination) + def shutdown(self, how): + assert how == socket.SHUT_RDWR + def close(self): + pass + monkeypatch.setattr(http_deadline.socket, 'getaddrinfo', resolve) + monkeypatch.setattr(http_deadline.socket, 'socket', Connection) + budget = http_deadline.DeadlineBudget(1) + budget.connect(('control.example', 443)) + budget.cancel() + assert seen == [('::1', 443, 0, 0)] + + +@pytest.mark.parametrize('removed_legacy_attribute', [False, True]) +def test_deadline_https_handler_supports_python312_without_check_hostname_keyword(monkeypatch, removed_legacy_attribute): + from agent import http_deadline + import urllib.request + context = ssl.create_default_context() + budget = http_deadline.DeadlineBudget(1) + handler = http_deadline.DeadlineHTTPSHandler(budget, context=context) + if removed_legacy_attribute and hasattr(handler, '_check_hostname'): + del handler._check_hostname + calls = [] + class ModernConnection: + def __init__(self, supplied_budget, host, *, context=None, timeout=None): + calls.append((supplied_budget, host, context, timeout)) + monkeypatch.setattr(http_deadline, 'DeadlineHTTPSConnection', ModernConnection) + def do_open(connection, request, **kwargs): + assert request.full_url == 'https://control.example/v1/agent/reports' + return connection('control.example', timeout=1, **kwargs) + monkeypatch.setattr(handler, 'do_open', do_open) + handler.https_open(urllib.request.Request('https://control.example/v1/agent/reports')) + assert calls == [(budget, 'control.example', context, 1)] + assert context.check_hostname is True and context.verify_mode == ssl.CERT_REQUIRED diff --git a/tests/unit_tests/agent/test_service.py b/tests/unit_tests/agent/test_service.py new file mode 100644 index 00000000..28bef53a --- /dev/null +++ b/tests/unit_tests/agent/test_service.py @@ -0,0 +1,63 @@ +"""Background reporting recovers from transient lifecycle lock contention.""" + +from contextlib import contextmanager, nullcontext +import threading +from types import SimpleNamespace + +import agent.config as config_module +import agent.service as service_module +from utils.state_io import state_lock +from test_agent import config + + +def test_reporting_retries_after_lifecycle_config_lock_timeout(tmp_path, monkeypatch): + cfg = config(tmp_path) + held, release = threading.Event(), threading.Event() + sent = [] + + def hold_config(): + with state_lock(cfg.path): + held.set() + assert release.wait(5) + + holder = threading.Thread(target=hold_config) + holder.start() + assert held.wait(2) + + @contextmanager + def short_lock(path): + with state_lock(path, timeout=.01): + yield + + class Stop: + waits = 0 + + def wait(self, _timeout): + self.waits += 1 + if self.waits == 2: + release.set() + holder.join(2) + assert not holder.is_alive() + return self.waits > 2 + + def request(_method, _path, **kwargs): + sent.append(kwargs['payload']) + return {'accepted': True} + + stop = Stop() + runtime = SimpleNamespace(lock=lambda _tag: nullcontext(), state=lambda _tag: 'running', + metadata=lambda _tag, metadata: metadata) + service = service_module.Service(cfg, runtime=runtime, transport=SimpleNamespace(request=request)) + service.stop = stop + monkeypatch.setattr(config_module, 'state_lock', short_lock) + monkeypatch.setattr(service_module, 'time', SimpleNamespace( + monotonic=lambda: stop.waits * 100, time=lambda: 1800000000)) + try: + service._report_loop() + finally: + release.set() + holder.join(2) + assert not holder.is_alive() + assert len(sent) == 1 + assert sent[0]['sequence'] == cfg.load()['sequence'] == 1 + assert sent[0]['bindings'][0]['runtime_state'] == 'running' diff --git a/tests/unit_tests/agent/test_user_service.py b/tests/unit_tests/agent/test_user_service.py new file mode 100644 index 00000000..9ddb8032 --- /dev/null +++ b/tests/unit_tests/agent/test_user_service.py @@ -0,0 +1,354 @@ +"""The optional agent service installs only the foreground run command.""" + +import os +import ssl +import subprocess +import sys +from pathlib import Path + +import pytest + +from agent.cli import run +from agent.config import Config +from agent.tls import client_context +from agent.user_service import ( + Account, + ServiceInstallError, + UNIT_NAME, + install, + install_hint, + lookup_account, + render_unit, + resolve_executable, + systemd_quote, + uninstall, +) +from core.main import main as dispatch_main + + +def _program(path): + path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + path.chmod(0o755) + return path + + +def _ok(argv, code=0, out="", err=""): + return subprocess.CompletedProcess(argv, code, out, err) + + +def _runner(calls, *, fail_at=None, linger=""): + def run_command(argv): + calls.append(list(argv)) + if fail_at is not None and list(argv) == fail_at: + return _ok(argv, 1, "", "permission denied") + if argv[:1] == ["loginctl"]: + return _ok(argv, 0, linger, "") + return _ok(argv) + + return run_command + + +def _which(_name): + return "/usr/bin/systemctl" + + +def test_client_context_refuses_tls_1_0_and_1_1(): + context = client_context() + assert context.minimum_version == ssl.TLSVersion.TLSv1_2 + assert context.verify_mode == ssl.CERT_REQUIRED + assert context.check_hostname is True + + +def test_unit_quotes_the_program_and_keeps_consent_out(tmp_path): + program = "/srv/AlphaGSM 1/alphagsm%bin" + text = render_unit(program, str(tmp_path / "my config.conf")) + assert "ExecStart=" + systemd_quote(program) + " agent run" in text + assert 'Environment=ALPHAGSM_CONFIG_LOCATION="' + str(tmp_path / "my config.conf") + '"' in text + assert "enable-reporting" not in text + assert "enable-management" not in text + assert "credential" not in text + assert render_unit(program).count("Environment=") == 0 + + +def test_install_writes_a_user_unit_and_restarts_it(tmp_path, capsys): + program = _program(tmp_path / "alphagsm") + calls = [] + install( + str(program), + runner=_runner(calls, linger="Linger=no\n"), + home=tmp_path / "home", + env={"ALPHAGSM_CONFIG_LOCATION": str(tmp_path / "alphagsm.conf")}, + platform="linux", + user="tester", + which=_which, + ) + unit = tmp_path / "home" / ".config" / "systemd" / "user" / UNIT_NAME + assert unit.read_text(encoding="utf-8").startswith("[Unit]\n") + assert oct(unit.stat().st_mode & 0o777) == "0o644" + assert calls[:3] == [ + ["systemctl", "--user", "daemon-reload"], + ["systemctl", "--user", "enable", UNIT_NAME], + ["systemctl", "--user", "restart", UNIT_NAME], + ] + assert calls[3] == ["loginctl", "show-user", "tester", "-p", "Linger"] + assert not any("enable-linger" in part for call in calls for part in call) + output = capsys.readouterr().out + assert "Installed the user service " + UNIT_NAME + " and started it." in output + assert "alphagsm-agent run" not in unit.read_text(encoding="utf-8") + assert "Reporting and management are unchanged." in output + assert "loginctl enable-linger" in output + assert not (tmp_path / "agent" / "config.json").exists() + + +def test_install_failure_leaves_consent_untouched(tmp_path): + program = _program(tmp_path / "alphagsm") + agent = tmp_path / "agent" + Config(agent) + before = list(agent.iterdir()) + calls = [] + with pytest.raises(ServiceInstallError, match="enable"): + install( + str(program), + runner=_runner(calls, fail_at=["systemctl", "--user", "enable", UNIT_NAME]), + home=tmp_path / "home", + env={}, + platform="linux", + user="", + which=_which, + ) + assert list(agent.iterdir()) == before + assert (tmp_path / "home" / ".config" / "systemd" / "user" / UNIT_NAME).is_file() + + +def test_install_refuses_other_platforms_and_missing_systemctl(tmp_path): + calls = [] + with pytest.raises(ServiceInstallError, match="Linux systemd"): + install("/bin/alphagsm", runner=_runner(calls), platform="windows", which=_which) + assert calls == [] + with pytest.raises(ServiceInstallError, match="systemctl is not available"): + install(str(_program(tmp_path / "alphagsm")), runner=_runner(calls), platform="linux", which=lambda _name: None) + assert calls == [] + assert not (tmp_path / "home").exists() + + +def test_install_refuses_a_symlinked_unit(tmp_path): + program = _program(tmp_path / "alphagsm") + unit_dir = tmp_path / "config" / "systemd" / "user" + unit_dir.mkdir(parents=True) + target = tmp_path / "other.service" + target.write_text("keep\n", encoding="utf-8") + (unit_dir / UNIT_NAME).symlink_to(target) + with pytest.raises(ServiceInstallError, match="symlink"): + install( + str(program), + runner=_runner([]), + env={"XDG_CONFIG_HOME": str(tmp_path / "config")}, + platform="linux", + which=_which, + ) + assert target.read_text(encoding="utf-8") == "keep\n" + + +def test_uninstall_removes_only_the_unit(tmp_path, capsys): + program = _program(tmp_path / "alphagsm") + env = {"XDG_CONFIG_HOME": str(tmp_path / "config")} + install(str(program), runner=_runner([]), env=env, platform="linux", user="", which=_which) + calls = [] + uninstall(runner=_runner(calls), env=env, platform="linux", which=_which) + assert calls == [ + ["systemctl", "--user", "disable", "--now", UNIT_NAME], + ["systemctl", "--user", "daemon-reload"], + ] + assert not (tmp_path / "config" / "systemd" / "user" / UNIT_NAME).exists() + assert "permissions are unchanged" in capsys.readouterr().out + uninstall(runner=_runner(calls), env=env, platform="linux", which=_which) + assert "No AlphaGSM user service is installed." in capsys.readouterr().out + + +def test_ordinary_import_does_not_load_linux_account_modules(): + env = os.environ.copy() + root = Path(__file__).resolve().parents[3] + env["PYTHONPATH"] = os.pathsep.join([str(root / "src"), env.get("PYTHONPATH", "")]) + completed = subprocess.run( + [ + sys.executable, + "-c", + "import sys\n" + "import agent.user_service\n" + "raise SystemExit('grp' in sys.modules or 'pwd' in sys.modules)\n", + ], + check=False, + capture_output=True, + text=True, + env=env, + ) + assert completed.returncode == 0, completed.stderr + + +def test_unsafe_program_paths_are_rejected(tmp_path): + with pytest.raises(ServiceInstallError, match="unsupported characters"): + resolve_executable(str(tmp_path / "alpha\ngsm")) + plain = tmp_path / "alphagsm" + plain.write_text("nope\n", encoding="utf-8") + with pytest.raises(ServiceInstallError, match="executable"): + resolve_executable(str(plain)) + + +def test_cli_install_does_not_create_agent_state(tmp_path, monkeypatch): + seen = {} + + def fake(executable, **_kwargs): + seen["executable"] = executable + + monkeypatch.setattr("agent.user_service.install", fake) + assert run(["install-service"], root=tmp_path / "agent", executable=str(tmp_path / "alphagsm")) == 0 + assert seen["executable"] == str(tmp_path / "alphagsm") + assert not (tmp_path / "agent").exists() + + +def test_cli_rejects_a_non_linux_service_install(tmp_path, monkeypatch, capsys): + monkeypatch.setattr("agent.user_service.PLATFORM", "macos") + assert run(["install-service"], root=tmp_path / "agent") == 1 + assert "Linux systemd" in capsys.readouterr().out + assert not (tmp_path / "agent").exists() + + +def test_main_dispatches_the_invoked_program(monkeypatch): + seen = {} + monkeypatch.setattr( + "agent.user_service.install", lambda executable, **_kwargs: seen.update(executable=executable) + ) + assert dispatch_main("/opt/AlphaGSM/alphagsm", ["agent", "install-service"]) == 0 + assert seen["executable"] == "/opt/AlphaGSM/alphagsm" + + +def test_hint_points_at_the_command_without_installing(tmp_path, monkeypatch): + absent = tmp_path / "no-system" + assert "foreground" in install_hint(platform="windows") + assert "install-service --system" in install_hint( + platform="linux", which=_which, home=tmp_path, env={}, system_dir=absent + ) + unit = tmp_path / ".config" / "systemd" / "user" + unit.mkdir(parents=True) + (unit / UNIT_NAME).write_text("[Unit]\n", encoding="utf-8") + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + assert install_hint( + platform="linux", which=_which, home=tmp_path, env={}, system_dir=absent + ).startswith("Restart the installed") + + +def test_percent_and_quotes_are_escaped(): + assert systemd_quote('/opt/a"b\\c%') == '"/opt/a\\"b\\\\c%%"' + + +def test_system_service_runs_alphagsm_agent_run_as_the_operator(tmp_path, capsys): + program = _program(tmp_path / "alphagsm") + account = Account("games", "games", str(tmp_path / "home" / "games")) + calls = [] + install( + str(program), + runner=_runner(calls), + home=tmp_path / "home", + env={"ALPHAGSM_CONFIG_LOCATION": str(tmp_path / "alphagsm.conf")}, + platform="linux", + which=_which, + system=True, + account=account, + system_dir=tmp_path / "systemd", + ) + text = (tmp_path / "systemd" / UNIT_NAME).read_text(encoding="utf-8") + assert "ExecStart=" + systemd_quote(str(program)) + " agent run" in text + assert "alphagsm-agent" not in text + assert "User=games" in text + assert "Group=games" in text + assert "Environment=HOME=" + systemd_quote(account.home) in text + assert "WantedBy=multi-user.target" in text + assert "WantedBy=default.target" not in text + assert calls == [ + ["systemctl", "daemon-reload"], + ["systemctl", "enable", UNIT_NAME], + ["systemctl", "restart", UNIT_NAME], + ] + output = capsys.readouterr().out + assert "system service" in output + assert "runs as games" in output + calls.clear() + uninstall( + runner=_runner(calls), + platform="linux", + which=_which, + system=True, + system_dir=tmp_path / "systemd", + ) + assert calls[0] == ["systemctl", "disable", "--now", UNIT_NAME] + assert not (tmp_path / "systemd" / UNIT_NAME).exists() + + +def test_system_and_user_services_are_not_installed_together(tmp_path): + program = _program(tmp_path / "alphagsm") + home = tmp_path / "home" + system_dir = tmp_path / "systemd" + install( + str(program), + runner=_runner([]), + home=home, + env={}, + platform="linux", + user="", + which=_which, + system_dir=system_dir, + ) + with pytest.raises(ServiceInstallError, match="user service is already installed"): + install( + str(program), + runner=_runner([]), + home=home, + env={}, + platform="linux", + which=_which, + system=True, + account=Account("games", "games", str(home / "games")), + system_dir=system_dir, + ) + assert not (system_dir / UNIT_NAME).exists() + + +def test_system_install_needs_a_writable_directory_and_refuses_root(tmp_path): + program = _program(tmp_path / "alphagsm") + system_dir = tmp_path / "systemd" + system_dir.mkdir() + system_dir.chmod(0o555) + try: + with pytest.raises(ServiceInstallError, match="needs root"): + install( + str(program), + runner=_runner([]), + platform="linux", + which=_which, + system=True, + account=Account("games", "games", "/home/games"), + system_dir=system_dir, + ) + finally: + system_dir.chmod(0o755) + with pytest.raises(ServiceInstallError, match="does not run as root"): + lookup_account("root") + with pytest.raises(ServiceInstallError, match="Unknown service account"): + lookup_account("no-such-alphagsm-user") + + +def test_cli_system_install_uses_the_service_account(tmp_path, monkeypatch): + seen = {} + + def fake(executable, system=False, user=None, **_kwargs): + seen.update(executable=executable, system=system, user=user) + + monkeypatch.setattr("agent.user_service.install", fake) + assert run( + ["install-service", "--system", "--service-user", "games"], + root=tmp_path / "agent", + executable="/opt/alphagsm", + ) == 0 + assert seen == {"executable": "/opt/alphagsm", "system": True, "user": "games"} + assert run(["install-service", "--service-user", "games"], root=tmp_path / "agent") == 1 + assert not (tmp_path / "agent").exists() diff --git a/tests/unit_tests/core/test_welcome.py b/tests/unit_tests/core/test_welcome.py new file mode 100644 index 00000000..41bc1897 --- /dev/null +++ b/tests/unit_tests/core/test_welcome.py @@ -0,0 +1,194 @@ +"""First-run guidance is optional and cannot silently opt into remote reporting.""" +from importlib import import_module +from io import StringIO +from pathlib import Path + +import pytest + +from utils.settings import settings + + +class Terminal(StringIO): + """An isolated interactive stream without touching the real terminal.""" + + def isatty(self): + return True + + +@pytest.fixture +def isolated(monkeypatch, tmp_path): + main = import_module('core.main') + root = tmp_path / 'operator' + monkeypatch.setattr(settings, '_user', settings.system, raising=False) + monkeypatch.setattr(settings._user, 'getsection', + lambda section: {'alphagsm_path': str(root)}) + monkeypatch.setattr(main.servermodule, 'DATAPATH', str(root / 'conf')) + monkeypatch.setattr(main, 'help', lambda *args, **kwargs: None) + return main, root + + +def replies(monkeypatch, values): + monkeypatch.setattr('sys.stdin', Terminal()) + monkeypatch.setattr('sys.stdout', Terminal()) + values = iter(values) + monkeypatch.setattr('builtins.input', lambda prompt: next(values)) + + +def test_explicit_skip_records_preference_without_server_or_agent_config(isolated): + main, root = isolated + assert main.main('alphagsm', ['--skip-welcome']) == 0 + assert (root / 'welcome-v1.done').read_text() == 'skipped\n' + assert not (root / 'agent').exists() and not (root / 'conf').exists() + + +def test_first_bare_interactive_launch_can_skip_and_does_not_repeat(isolated, monkeypatch): + main, root = isolated + replies(monkeypatch, ['']) + assert main.main('alphagsm', []) == 0 + assert (root / 'welcome-v1.done').is_file() + monkeypatch.setattr('builtins.input', lambda prompt: pytest.fail('tour repeated')) + assert main.main('alphagsm', []) == 2 + + +def test_replay_remains_available_after_skip(isolated, monkeypatch): + main, root = isolated + root.mkdir() + (root / 'welcome-v1.done').write_text('skipped\n') + replies(monkeypatch, ['']) + assert main.main('alphagsm', ['--welcome']) == 0 + assert (root / 'welcome-v1.done').read_text() == 'skipped\n' + + +@pytest.mark.parametrize('stream', ['stdin', 'stdout']) +def test_redirected_bare_launch_never_prompts_or_writes_state(isolated, monkeypatch, stream): + main, root = isolated + monkeypatch.setattr('sys.stdin', Terminal()) + monkeypatch.setattr('sys.stdout', Terminal()) + monkeypatch.setattr('sys.' + stream, StringIO()) + if stream == 'stdout': + monkeypatch.setattr(main, 'stdout', __import__('sys').stdout) + monkeypatch.setattr('builtins.input', lambda prompt: pytest.fail('script prompted')) + assert main.main('alphagsm', []) == 2 + assert not root.exists() + + +def test_named_commands_never_prompt_or_write_welcome_state(isolated, monkeypatch): + main, root = isolated + monkeypatch.setattr('builtins.input', lambda prompt: pytest.fail('named command prompted')) + monkeypatch.setattr(main, 'expand_server_star', lambda user, tag, cmd: [(user, tag)]) + monkeypatch.setattr(main, 'run_one', lambda *args: 7) + assert main.main('alphagsm', ['--version']) == 0 + assert main.main('alphagsm', ['--help']) == 0 + assert main.main('alphagsm', ['welcome', 'status']) == 7 + assert not root.exists() + + +def execute_tour(monkeypatch, values, execute): + replies(monkeypatch, values) + return import_module('core.welcome').dispatch('alphagsm', ['--welcome'], execute) + + +def test_tour_can_finish_without_commands_or_remote_config(isolated, monkeypatch): + _main, root = isolated + calls = [] + assert execute_tour(monkeypatch, ['2', ''], lambda *args: calls.append(args)) == 0 + assert calls == [] and not (root / 'agent').exists() + + +@pytest.mark.parametrize('approval,expected', [('', []), ('yes', [('alphagsm', ['agent', 'setup'])])]) +def test_remote_wizard_needs_its_own_confirmation(isolated, monkeypatch, approval, expected): + calls = [] + def execute(name, args): + calls.append((name, args)) + return 0 + assert execute_tour(monkeypatch, ['3', approval, ''], execute) == 0 + assert calls == expected + assert not any('enable-reporting' in args or 'enable-management' in args for _, args in calls) + + +@pytest.mark.parametrize('approval,expected', [('', []), ('y', [('alphagsm', ['world', 'create', 'teamfortress2'])])]) +def test_server_record_creation_requires_preview_approval(isolated, monkeypatch, approval, expected): + calls = [] + def execute(name, args): + calls.append((name, args)) + return 0 + values = ['1', 'tf2', 'world', approval] + (['n'] if approval else []) + [''] + assert execute_tour(monkeypatch, values, execute) == 0 + assert calls == expected + + +def test_setup_has_separate_approval_and_never_starts_the_game(isolated, monkeypatch): + calls = [] + def execute(name, args): + calls.append((name, args)) + return 0 + assert execute_tour(monkeypatch, ['1', 'minecraft', 'world', 'y', 'y', ''], execute) == 0 + assert calls == [('alphagsm', ['world', 'create', 'minecraft.vanilla']), + ('alphagsm', ['world', 'setup'])] + + +def test_existing_server_record_is_not_replaced(isolated, monkeypatch): + _main, root = isolated + (root / 'conf').mkdir(parents=True) + path = root / 'conf/world.json' + path.write_text('{"module":"minecraft.paper","keep":"existing world"}') + before = path.read_bytes() + assert execute_tour(monkeypatch, ['1', 'tf2', 'world', ''], + lambda *args: pytest.fail('existing server touched')) == 0 + assert path.read_bytes() == before + + +@pytest.mark.parametrize('tag', ['../world', 'someuser/world', 'world;rm', '.', 'x y', 'all', 'ALL', '1', '02', + 'agent', 'steam-auth', 'self-update', 'start', 'create', 'version']) +def test_unsafe_names_never_reach_dispatch(isolated, monkeypatch, tag): + assert execute_tour(monkeypatch, ['1', 'tf2', tag, ''], + lambda *args: pytest.fail('unsafe tag dispatched')) == 0 + + +def test_failed_create_never_runs_setup(isolated, monkeypatch): + calls = [] + def execute(name, args): + calls.append((name, args)) + return 7 + assert execute_tour(monkeypatch, ['1', 'tf2', 'world', 'y'], execute) == 7 + assert calls == [('alphagsm', ['world', 'create', 'teamfortress2'])] + + +@pytest.mark.parametrize('exception,expected', [(EOFError, 0), (KeyboardInterrupt, 130)]) +def test_cancel_does_not_run_commands_or_write_consent(isolated, monkeypatch, exception, expected): + _main, root = isolated + replies(monkeypatch, []) + def cancel(prompt): + raise exception() + monkeypatch.setattr('builtins.input', cancel) + assert import_module('core.welcome').dispatch('alphagsm', ['--welcome'], + lambda *args: pytest.fail('cancel dispatched a command')) == expected + assert not root.exists() + + +def test_skip_rejects_symlink_marker_without_touching_its_target(isolated): + main, root = isolated + root.mkdir(mode=0o700) + target = root.parent / 'untouched' + target.write_text('keep') + (root / 'welcome-v1.done').symlink_to(target) + assert main.main('alphagsm', ['--skip-welcome']) == 1 + assert target.read_text() == 'keep' + + +def test_skip_rejects_symlink_home(isolated): + main, root = isolated + target = root.parent / 'other' + target.mkdir() + root.symlink_to(target, target_is_directory=True) + assert main.main('alphagsm', ['--skip-welcome']) == 1 + assert not (target / 'welcome-v1.done').exists() + + +def test_existing_group_writable_operator_home_needs_no_permission_change(isolated): + main, root = isolated + root.mkdir(mode=0o775) + root.chmod(0o775) + assert main.main('alphagsm', ['--skip-welcome']) == 0 + assert root.stat().st_mode & 0o777 == 0o775 + assert (root / 'welcome-v1.done').stat().st_mode & 0o777 == 0o600 diff --git a/tests/unit_tests/gamemodules/test_etlegacyserver_cov.py b/tests/unit_tests/gamemodules/test_etlegacyserver_cov.py index 1297b845..50652ca8 100644 --- a/tests/unit_tests/gamemodules/test_etlegacyserver_cov.py +++ b/tests/unit_tests/gamemodules/test_etlegacyserver_cov.py @@ -38,6 +38,55 @@ def test_configure_ask_custom(tmp_path, monkeypatch): server.data["download_name"] = "test.zip" mod.configure(server, ask=True) +CURRENT_DOWNLOAD_PAGE = """ +

ET: Legacy stable release 2.86.2 - Taking Fire!!!

+Installer +Archive +""" + +LEGACY_DOWNLOAD_PAGE = """ +

stable release 2.83.1

+Linux x86_64 archive +""" + +RELEASE_LIST = """ + + 2.86.2 + +2.83.1 +""" + + +def test_parse_release_page_reads_the_current_linux_archive_title(): + version, url = mod._main._parse_release_page(CURRENT_DOWNLOAD_PAGE) + assert version == "2.86.2" + assert url == "https://www.etlegacy.com/download/file/785" + + +def test_parse_release_page_keeps_the_older_archive_label(): + version, url = mod._main._parse_release_page(LEGACY_DOWNLOAD_PAGE) + assert version == "2.83.1" + assert url == "https://www.etlegacy.com/download/file/100" + + +def test_parse_release_page_rejects_a_page_without_the_linux_archive(): + with pytest.raises(ServerError, match="Linux download"): + mod._main._parse_release_page("

stable release 2.86.2

") + + +def test_resolve_download_follows_the_version_label(monkeypatch): + pages = { + "https://www.etlegacy.com/download/release/list": RELEASE_LIST, + "https://www.etlegacy.com/download/release/2862": CURRENT_DOWNLOAD_PAGE, + } + monkeypatch.setattr(mod._main, "_read_page", lambda url: pages[url]) + version, url = mod._main.resolve_download("2.86.2") + assert version == "2.86.2" + assert url == "https://www.etlegacy.com/download/file/785" + + def test_configure_resolves_download(tmp_path): server = DummyServer() with patch.object(mod._main, 'resolve_download', return_value=('2.83', 'https://example.com/etl.tar.gz')): diff --git a/tests/unit_tests/gamemodules/test_ts3server_module.py b/tests/unit_tests/gamemodules/test_ts3server_module.py index 9d9442e0..46ea22df 100644 --- a/tests/unit_tests/gamemodules/test_ts3server_module.py +++ b/tests/unit_tests/gamemodules/test_ts3server_module.py @@ -122,3 +122,47 @@ def test_ts3_runtime_requirements_include_udp_and_tcp_ports(tmp_path): ] assert spec["working_dir"] == "/srv/server" assert spec["command"][0] == "./ts3server" + assert "network_mode" not in spec + + +def test_mariadb_command_keeps_password_out_of_argv_and_joins_private_network(tmp_path): + server = DummyServer("Voice One") + exe = tmp_path / "ts3server" + exe.write_text("") + server.data.update( + { + "dir": str(tmp_path) + "/", + "exe_name": "ts3server", + "port": 9987, + "queryport": "10011", + "filetransferport": "30033", + "license_accepted": True, + "db_mode": "mariadb", + "db_name": "sa_teamspeak", + "db_user": "root", + "db_password": "secret-value", + "runtime": "docker", + "licensepath": "/srv/server", + } + ) + + cmd, _cwd = ts3server.get_start_command(server) + spec = ts3server.get_container_spec(server) + parameter = (tmp_path / "ts3db_mariadb.ini").read_text() + + assert "dbplugin=ts3db_mariadb" in cmd + assert "dbpluginparameter=ts3db_mariadb.ini" in cmd + assert "licensepath=/srv/server" in cmd + assert all("secret-value" not in argument for argument in cmd) + assert "host=database" in parameter + assert "database=sa_teamspeak" in parameter + assert "password=secret-value" in parameter + assert oct((tmp_path / "ts3db_mariadb.ini").stat().st_mode & 0o777) == "0o600" + assert spec["network_mode"] == "alphagsm-ts3-voice-one" + assert spec["env"]["LD_LIBRARY_PATH"] == "/srv/server/redist" + assert spec["env"]["LANG"] == "C.UTF-8" + created = ts3server.managed_database_run_args(server, initialize=False) + assert "mariadb:11.5" in created + assert not any(argument == "-p" or argument.startswith("127.0.0.1:") or argument.startswith("0.0.0.0:") for argument in created) + fresh = ts3server.managed_database_run_args(server, initialize=True) + assert not any(argument.startswith("MYSQL_USER=") for argument in fresh) diff --git a/tests/unit_tests/server/test_port_manager.py b/tests/unit_tests/server/test_port_manager.py index 410abe1d..b0c68aaf 100644 --- a/tests/unit_tests/server/test_port_manager.py +++ b/tests/unit_tests/server/test_port_manager.py @@ -382,6 +382,9 @@ class FakeSocket: def __init__(self, *args): self.bound = None + def setsockopt(self, *args): + pass + def bind(self, sockaddr): raise OSError(errno.EADDRNOTAVAIL, "Cannot assign requested address") @@ -463,3 +466,36 @@ def test_iter_managed_servers_uses_alpha_resolution_path_and_skips_missing_modul assert len(managed) == 2 assert managed[0].module is resolved assert managed[1].module is None + + +def test_live_probe_ignores_closed_tcp_connections_but_detects_listeners(): + import socket + + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind(("127.0.0.1", 0)) + port = listener.getsockname()[1] + listener.listen(1) + client = socket.create_connection(("127.0.0.1", port)) + accepted, _ = listener.accept() + assert port_manager.probe_live_listener("127.0.0.1", port) is True + accepted.close() # The server side enters TIME_WAIT after this exchange. + assert client.recv(1) == b"" + client.close() + listener.close() + assert port_manager.probe_live_listener("127.0.0.1", port) is False + + +def test_windows_live_probe_keeps_exclusive_tcp_binding(monkeypatch): + class Socket: + def setsockopt(self, *args): + raise AssertionError("Windows reuse can hide an active listener") + def bind(self, address): + raise OSError(errno.EADDRINUSE, "busy") + def close(self): + pass + monkeypatch.setattr(port_manager.os, "name", "nt") + monkeypatch.setattr(port_manager.socket, "getaddrinfo", lambda *a: [ + (port_manager.socket.AF_INET, port_manager.socket.SOCK_STREAM, 0, "", ("127.0.0.1", 12345))]) + monkeypatch.setattr(port_manager.socket, "socket", lambda *a: Socket()) + assert port_manager.probe_live_listener("127.0.0.1", 12345) is True diff --git a/tests/unit_tests/server/test_public_protocols.py b/tests/unit_tests/server/test_public_protocols.py new file mode 100644 index 00000000..24097a1c --- /dev/null +++ b/tests/unit_tests/server/test_public_protocols.py @@ -0,0 +1,38 @@ +"""The public protocol map is anonymous status only and does not load the manager.""" + +import subprocess +import sys + +from server.public_protocols import STRUCTURED, protocol_for, queryable_games + + +def test_catalogue_covers_anonymous_status_and_refuses_reachability_checks(): + assert protocol_for("minecraft.vanilla") == "slp" + assert protocol_for("minecraft.paper") == "slp" + assert protocol_for("minecraft.bedrock") == "bedrock" + assert protocol_for("teamfortress2") == "a2s" + assert protocol_for("counterstrike2") == "a2s" + assert protocol_for("q3server") == "quake" + assert protocol_for("ts3server") == "ts3" + assert protocol_for("valheim") is None + assert protocol_for("wreckfestserver") is None + assert protocol_for("sevendaystodie") is None + games = queryable_games() + assert len(games) > 50 + assert {protocol for _game, protocol, _name in games} <= STRUCTURED + assert "Team Fortress 2" in {name for _game, _protocol, name in games} + assert "TeamSpeak 3" in {name for _game, _protocol, name in games} + + +def test_protocol_map_does_not_import_the_server_manager(): + subprocess.run( + [ + sys.executable, + "-c", + "from server.public_protocols import protocol_for; import sys; " + "assert protocol_for('rust') == 'a2s'; " + "assert 'server.server' not in sys.modules; assert 'core' not in sys.modules", + ], + check=True, + timeout=20, + ) diff --git a/tests/unit_tests/server/test_public_query.py b/tests/unit_tests/server/test_public_query.py new file mode 100644 index 00000000..77757dc2 --- /dev/null +++ b/tests/unit_tests/server/test_public_query.py @@ -0,0 +1,82 @@ +"""Public-only dispatch must not load the manager or accept private protocols.""" +import bz2 +import json +import struct +import sys +import subprocess +import pytest +from server.public_query import query_public, PROFILES +from utils import query + + +class Socket: + def __init__(self, packets): + self.packets = iter(packets) + self.sent = [] + def __enter__(self): + return self + def __exit__(self, *_): + pass + def connect(self, address): + self.address = address + def settimeout(self, timeout): + assert timeout > 0 + def sendto(self, data, address): + self.sent.append((data, address)) + def recv(self, size): + return next(self.packets) + + +def fragment(message, total, index, payload): + return b'\xfe\xff\xff\xff' + struct.pack('= 0.3 for earlier, later in zip(sent, sent[1:])) + + +def _scripted_server(monkeypatch, *, channel_error=False, client_error=False): + sent = [] + monkeypatch.setattr(query.time, "sleep", lambda _seconds: None) + + class ServerQueryConnection: + def __init__(self): + self.output = deque(b"TS3\nWelcome\n") + + def sendall(self, data): + command = data.decode().strip() + sent.append(command) + if command == "channellist" and channel_error: + self.output.extend(b"error id=2568 msg=insufficient\\sclient\\spermissions\n") + return + if command == "clientlist" and client_error: + self.output.extend(b"error id=2568 msg=insufficient\\sclient\\spermissions\n") + return + if command == "serverinfo": + self.output.extend( + b"virtualserver_name=Fixture virtualserver_clientsonline=3 " + b"virtualserver_queryclientsonline=1 virtualserver_maxclients=32 " + b"virtualserver_version=3.13.7\n" + ) + elif command == "channellist": + self.output.extend( + b"cid=1 pid=0 channel_order=0 channel_name=Lobby|" + b"cid=2 pid=1 channel_order=0 channel_name=Raid\n" + ) + elif command == "clientlist": + self.output.extend( + b"clid=1 cid=1 client_nickname=Ada client_type=0 " + b"client_unique_identifier=SECRET client_database_id=4 connection_client_ip=203.0.113.5|" + b"clid=2 cid=1 client_nickname=Query client_type=1 " + b"client_unique_identifier=QUERY connection_client_ip=198.51.100.5\n" + ) + self.output.extend(b"error id=0 msg=ok\n") + + def recv(self, _size): + return bytes([self.output.popleft()]) if self.output else b"" + + def close(self): + return None + + monkeypatch.setattr(query.socket, "create_connection", lambda *_args, **_kwargs: ServerQueryConnection()) + return sent + + +def test_detail_query_returns_parent_order_and_clients_without_identities(monkeypatch): + sent = _scripted_server(monkeypatch) + info = query.ts3_serverinfo("192.0.2.1", 10011, details=True) + assert sent == ["use 1", "serverinfo", "channellist", "clientlist", "quit"] + assert info["clients_online"] == 2 and info["query_clients"] == 1 + assert info["channels"] == [ + {"id": 1, "name": "Lobby", "parent_id": 0, "order": 0}, + {"id": 2, "name": "Raid", "parent_id": 1, "order": 0}, + ] + assert info["clients"] == [ + {"nickname": "Ada", "channel_id": 1, "client_type": 0}, + {"nickname": "Query", "channel_id": 1, "client_type": 1}, + ] + assert info["roster_error"] is False + rendered = str(info) + assert "SECRET" not in rendered and "203.0.113.5" not in rendered and "client_database_id" not in rendered + + +def test_refused_detail_list_keeps_the_count_and_marks_roster_error(monkeypatch): + sent = _scripted_server(monkeypatch, channel_error=True) + info = query.ts3_serverinfo("192.0.2.1", 10011, details=True) + assert sent == ["use 1", "serverinfo", "channellist", "quit"] + assert info["clients_online"] == 2 and info["channels"] == [] and info["clients"] == [] + assert info["roster_error"] is True + + +def test_refused_client_list_keeps_channels_and_marks_roster_error(monkeypatch): + sent = _scripted_server(monkeypatch, client_error=True) + info = query.ts3_serverinfo("192.0.2.1", 10011, details=True) + assert "clientlist" in sent + assert info["channels"][0]["name"] == "Lobby" + assert info["clients"] == [] and info["roster_error"] is True diff --git a/tests/unit_tests/utils/test_ts3_set_name.py b/tests/unit_tests/utils/test_ts3_set_name.py new file mode 100644 index 00000000..4998bc8c --- /dev/null +++ b/tests/unit_tests/utils/test_ts3_set_name.py @@ -0,0 +1,38 @@ +"""A local ServerQuery login can rename the virtual server without printing the password.""" + +from collections import deque + +import pytest + +from utils.query import QueryError, ts3_escape, ts3_set_name + + +def test_server_name_edit_logs_in_and_escapes_the_previous_name(monkeypatch): + sent = [] + + class Connection: + def __init__(self): + self.output = deque(b"TS3\nWelcome\n") + + def sendall(self, data): + sent.append(data.decode().strip()) + self.output.extend(b"error id=0 msg=ok\n") + + def recv(self, _size): + return bytes([self.output.popleft()]) if self.output else b"" + + def close(self): + pass + + monkeypatch.setattr("utils.query.time.sleep", lambda _seconds: None) + monkeypatch.setattr("utils.query.socket.create_connection", lambda *args, **kwargs: Connection()) + ts3_set_name("127.0.0.1", 10011, "Sector Alpha", login=("serveradmin", "secret value")) + assert sent[0] == "login serveradmin secret\\svalue" + assert sent[1] == "use 1" + assert sent[2] == "serveredit virtualserver_name=Sector\\sAlpha" + assert sent[3] == "quit" + assert ts3_escape("a/b|c") == "a\\/b\\pc" + with pytest.raises(QueryError): + ts3_set_name("127.0.0.1", 10011, "x" * 65, login=("serveradmin", "secret")) + with pytest.raises(QueryError): + ts3_set_name("127.0.0.1", 10011, "Voice", login=None) diff --git a/tests/unit_tests/utils/test_ts3_voice_count.py b/tests/unit_tests/utils/test_ts3_voice_count.py new file mode 100644 index 00000000..d44fbf2e --- /dev/null +++ b/tests/unit_tests/utils/test_ts3_voice_count.py @@ -0,0 +1,67 @@ +"""TeamSpeak voice counts omit the ServerQuery connection that reads them.""" + +from collections import deque + +import pytest + +from utils import query +from utils.query import QueryError + + +def _connection(serverinfo): + class ServerQueryConnection: + def __init__(self): + self.output = deque(b"TS3\nWelcome\n") + + def sendall(self, data): + if data.startswith(b"serverinfo"): + self.output.extend(serverinfo) + elif data.startswith(b"channellist"): + self.output.extend(b"cid=1 channel_name=Lobby\n") + self.output.extend(b"error id=0 msg=ok\n") + + def recv(self, _size): + return bytes([self.output.popleft()]) if self.output else b"" + + def close(self): + return None + + return ServerQueryConnection + + +def _query(monkeypatch, serverinfo): + monkeypatch.setattr(query.time, "sleep", lambda _seconds: None) + monkeypatch.setattr(query.socket, "create_connection", lambda *_args, **_kwargs: _connection(serverinfo)()) + return query.ts3_serverinfo("192.0.2.1", 10011) + + +def test_the_query_connection_is_not_a_voice_client(monkeypatch): + info = _query( + monkeypatch, + b"virtualserver_name=Sector\\sAlpha " + b"virtualserver_clientsonline=1 " + b"virtualserver_queryclientsonline=1 " + b"virtualserver_maxclients=64 " + b"virtualserver_version=3.12.1\n", + ) + assert info["clients_online"] == 0 + assert info["query_clients"] == 1 + assert info["max_clients"] == 64 + assert info["name"] == "Sector Alpha" + + +def test_voice_clients_remain_after_query_clients_are_removed(monkeypatch): + info = _query( + monkeypatch, + b"virtualserver_name=Lobby virtualserver_clientsonline=4 virtualserver_queryclientsonline=1\n", + ) + assert info["clients_online"] == 3 + assert info["query_clients"] == 1 + + +def test_a_query_client_count_above_the_online_count_is_rejected(monkeypatch): + with pytest.raises(QueryError, match="query client count"): + _query( + monkeypatch, + b"virtualserver_name=Lobby virtualserver_clientsonline=1 virtualserver_queryclientsonline=2\n", + )