From ad845d9e47f6c2a3df429ea93aa5d4065006170e Mon Sep 17 00:00:00 2001 From: cosmosquark Date: Tue, 13 Oct 2026 22:06:58 +0100 Subject: [PATCH 01/22] Add opt-in Sector Alpha agent and bounded public queries --- DEVELOPERS.md | 32 ++ Makefile | 1 + README.md | 11 + alphagsm | 4 + changelog.txt | 15 + docs/sector-alpha-agent.md | 74 ++++ lint.sh | 1 + pyproject.toml | 2 +- scripts/build_binary.py | 2 +- src/agent/__init__.py | 1 + src/agent/cli.py | 139 ++++++++ src/agent/config.py | 214 ++++++++++++ src/agent/contracts.py | 175 ++++++++++ src/agent/journal.py | 73 ++++ src/agent/lifecycle.py | 184 ++++++++++ src/agent/reporting.py | 55 +++ src/agent/runtime_helper.py | 36 ++ src/agent/service.py | 112 ++++++ src/agent/transport.py | 84 +++++ src/core/main.py | 8 + src/server/port_manager.py | 5 + src/server/public_query.py | 52 +++ src/utils/query.py | 325 ++++++++++-------- src/utils/state_io.py | 42 +++ .../test_minecraft_vanilla.py | 37 ++ tests/smoke_tests/README.md | 9 +- tests/smoke_tests/run_sector_alpha_agent.sh | 7 + tests/unit_tests/agent/test_agent.py | 114 ++++++ tests/unit_tests/agent/test_cli.py | 15 + tests/unit_tests/agent/test_contracts.py | 40 +++ tests/unit_tests/agent/test_lock_handoff.py | 31 ++ tests/unit_tests/server/test_port_manager.py | 36 ++ tests/unit_tests/server/test_public_query.py | 63 ++++ tests/unit_tests/test_binary_packaging.py | 2 +- tests/unit_tests/utils/test_query.py | 3 + 35 files changed, 1851 insertions(+), 153 deletions(-) create mode 100644 docs/sector-alpha-agent.md create mode 100644 src/agent/__init__.py create mode 100644 src/agent/cli.py create mode 100644 src/agent/config.py create mode 100644 src/agent/contracts.py create mode 100644 src/agent/journal.py create mode 100644 src/agent/lifecycle.py create mode 100644 src/agent/reporting.py create mode 100644 src/agent/runtime_helper.py create mode 100644 src/agent/service.py create mode 100644 src/agent/transport.py create mode 100644 src/server/public_query.py create mode 100755 tests/smoke_tests/run_sector_alpha_agent.sh create mode 100644 tests/unit_tests/agent/test_agent.py create mode 100644 tests/unit_tests/agent/test_cli.py create mode 100644 tests/unit_tests/agent/test_contracts.py create mode 100644 tests/unit_tests/agent/test_lock_handoff.py create mode 100644 tests/unit_tests/server/test_public_query.py diff --git a/DEVELOPERS.md b/DEVELOPERS.md index 470d59b4..11d14ea4 100644 --- a/DEVELOPERS.md +++ b/DEVELOPERS.md @@ -1129,3 +1129,35 @@ When upgrading an existing source environment that installed the obsolete `python -m pip uninstall crontab`, then install `requirements-runtime.txt` again. 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 uses outbound HTTPS long polling, +rechecks exact local bindings and policy under the normal CLI lock, obtains +final online authorization and persists executing before lifecycle effects. +`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. + +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. diff --git a/Makefile b/Makefile index eef0acfa..5522b281 100644 --- a/Makefile +++ b/Makefile @@ -185,6 +185,7 @@ test: skills-check 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 e4a891a9..4fb737dd 100644 --- a/README.md +++ b/README.md @@ -534,3 +534,14 @@ 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. + +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/. 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 4c90f000..bab0e61a 100644 --- a/changelog.txt +++ b/changelog.txt @@ -7,6 +7,21 @@ 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-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. +- 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. + 2026-10-16 - CI: replay complete native smoke configuration blocks in the unit contract, preserving multiline writes and excluding runtime JSON output. diff --git a/docs/sector-alpha-agent.md b/docs/sector-alpha-agent.md new file mode 100644 index 00000000..13f51851 --- /dev/null +++ b/docs/sector-alpha-agent.md @@ -0,0 +1,74 @@ +# 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. +The initial service supports Minecraft Java vanilla/Paper and Team Fortress 2. + +On a compatible Sector Alpha 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 status +``` + +Enter the fixed HTTPS origin and code at the prompts. The code/credentials must +not be placed in shell arguments or history. Enrollment leaves both capabilities +disabled. Create one server binding in the owner's website workspace, then bind +that opaque ID to exactly one existing local tag (replace the example values): + +```sh +alphagsm agent bind BINDING_ID my-minecraft --epoch 1 \ + --game minecraft.vanilla --host game.example.net --port 25565 \ + --name 'Our Minecraft world' --capacity 20 --publish \ + --actions status start stop restart +alphagsm agent enable-reporting +alphagsm agent report-once +alphagsm agent enable-management +alphagsm agent run +``` + +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. Run remains an explicit foreground +service; install it under a user service manager if continuous operation is wanted. +No background service is installed automatically. Reporting reconciles approved +local name/capacity after successful local changes and every roughly 60 seconds. +Advertised host/port remain an explicit local choice. The compatible service +requires a separate public-name challenge before granting endpoint verification. + +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. 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/scripts/build_binary.py b/scripts/build_binary.py index e4253208..1527a2fc 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(): 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/cli.py b/src/agent/cli.py new file mode 100644 index 00000000..4f8f0e8a --- /dev/null +++ b/src/agent/cli.py @@ -0,0 +1,139 @@ +"""Local interactive enrollment and explicit opt-in; no secret CLI arguments.""" + +import argparse +import getpass +import json +from .config import Config, ConfigError, local_tag +from .contracts import ACTIONS +from .service import Service +from .transport import Transport, TransportError + + +def run(args, *, root=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", + ): + commands.add_parser(command) + bind = commands.add_parser("bind") + bind.add_argument("binding_id") + bind.add_argument("local_tag") + bind.add_argument("--epoch", type=int, default=1) + bind.add_argument("--actions", nargs="*", choices=sorted(ACTIONS), default=[]) + bind.add_argument("--publish", action="store_true") + bind.add_argument("--game", required=True, choices=("teamfortress2", "minecraft.vanilla", "minecraft.paper")) + bind.add_argument("--host", required=True) + bind.add_argument("--port", required=True, type=int) + bind.add_argument("--name", required=True) + bind.add_argument("--capacity", type=int, default=0) + 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")) + try: + options = parser.parse_args(args) + except SystemExit as exc: + return exc.code + try: + cfg = Config(root) + if options.command == "enroll": + api_origin = input("Compatible HTTPS origin: ").strip() + code = getpass.getpass("Single-use enrollment code: ").strip() + response = Transport(api_origin).request("POST", "/v1/agent/enroll", payload={"code": code}) + cfg.enroll(api_origin, response) + print("Enrolled. Reporting and management remain disabled until you enable each locally.") + elif options.command == "bind": + # Verify that the local target exists under this user's normal AlphaGSM namespace. + from server.server import Server + from .lifecycle import AlphaRuntime + + with AlphaRuntime().lock(local_tag(options.local_tag)): + server = Server(options.local_tag) + if server.data["module"] != options.game: + raise ConfigError("Selected game does not match the local server module") + cfg.bind( + options.binding_id, + options.local_tag, + options.epoch, + actions=options.actions, + publication=options.publish, + metadata={ + "game_id": options.game, + "host": options.host, + "port": options.port, + "name": options.name, + "capacity": options.capacity, + }, + ) + print("Exact local binding saved. Only the selected operations are permitted.") + if "stop" in options.actions or "restart" in options.actions: + print("Normal AlphaGSM stop may force termination after graceful shutdown attempts.") + elif options.command == "unbind": + cfg.unbind(options.binding_id) + elif options.command == "publication": + cfg.publication(options.binding_id, options.selection == "on") + 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, ValueError, OSError, RuntimeError): + print("Agent operation rejected; check local policy, permissions and provider availability.") + return 1 + except KeyboardInterrupt: + return 130 + + +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..76a43580 --- /dev/null +++ b/src/agent/config.py @@ -0,0 +1,214 @@ +# 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 + + +class ConfigError(ValueError): + """Invalid local configuration or unsupported permission boundary.""" + + +def origin(value): + parsed = urlsplit(value) + invalid = ( + parsed.scheme != "https", + not parsed.hostname, + parsed.username, + parsed.password, + parsed.path, + 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 + + +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") + 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): + 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 bind(self, binding_id, tag, epoch, *, actions, publication, metadata): + 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") + value["bindings"][binding_id] = { + "tag": tag, + "epoch": epoch, + "actions": actions, + "publication": publication, + "metadata": metadata, + } + 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): + 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") + 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..c0675d69 --- /dev/null +++ b/src/agent/contracts.py @@ -0,0 +1,175 @@ +# 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): + exact( + value, + ( + "binding_id", + "binding_epoch", + "publication", + "name", + "game_id", + "host", + "port", + "capacity", + "runtime_state", + "allowed_actions", + ), + ) + 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) + 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/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/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..71bea3df --- /dev/null +++ b/src/agent/service.py @@ -0,0 +1,112 @@ +"""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 .transport import Transport, TransportError + + +class Service: + def __init__(self, config, *, runtime=None, transport=None): + self.config = config + self.runtime = runtime or AlphaRuntime() + cfg = config.load() + 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() + + def report_once(self): + cfg = self.config.load() + if not cfg["reporting"]: + return None + payload = report_snapshot(self.config, self.runtime) + return self.transport.request("POST", "/v1/agent/reports", credential=cfg["reporting"], payload=payload) + + def management_once(self): + cfg = self.config.load() + if not cfg["management_enabled"]: + return None + credential = cfg["management"] + self.transport.request("POST", "/v1/agent/policy", credential=credential, payload=policy_snapshot(cfg)) + response = self.transport.request( + "GET", "/v1/agent/jobs", credential=credential, session_id=self.session_id, timeout=35 + ) + # 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"]: + 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): + failures = min(failures + 1, 6) + next_report = time.monotonic() + min(300, 2**failures) + secrets.randbelow(5) + # An outage never changes the result of a successful local CLI command. + + def _management_loop(self): + failures = 0 + while not self.stop.is_set(): + cfg = self.config.load() + if not cfg["management_enabled"]: + self.stop.wait(1) + continue + try: + self.management_once() + failures = 0 + except (TransportError, ValueError): + failures = min(failures + 1, 6) + self.stop.wait(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/transport.py b/src/agent/transport.py new file mode 100644 index 00000000..2be0b0da --- /dev/null +++ b/src/agent/transport.py @@ -0,0 +1,84 @@ +# pylint: disable=unidiomatic-typecheck +"""Fixed-origin verified HTTPS without proxies, redirects or unbounded responses.""" + +import http.client +import json +import ssl +import time +import urllib.error +import urllib.request +from .config import origin + + +class TransportError(OSError): + """Bounded category-only network failures; credentials are never in errors.""" + + +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 = origin(api_origin) + self.opener = urllib.request.build_opener( + urllib.request.ProxyHandler({}), + NoRedirect(), + urllib.request.HTTPSHandler(context=context or ssl.create_default_context()), + ) + + def request(self, method, path, *, credential=None, payload=None, session_id=None, timeout=10): + if path not in ( + "/v1/agent/enroll", + "/v1/agent/reports", + "/v1/agent/policy", + "/v1/agent/jobs", + "/v1/agent/results", + ) 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) + ): + 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 + request = urllib.request.Request(self.origin + path, method=method, data=data, headers=headers) + deadline = time.monotonic() + timeout + try: + with self.opener.open(request, timeout=timeout) as response: + if response.geturl() != self.origin + path: + 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 + except urllib.error.HTTPError as exc: + 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 diff --git a/src/core/main.py b/src/core/main.py index d3710420..7cfbb4ca 100644 --- a/src/core/main.py +++ b/src/core/main.py @@ -58,6 +58,10 @@ def main(name, args): The main role of this function is to sanitize the command input from the user. """ + if args and args[0].lower() == "agent": + from agent.cli import run as run_agent + return run_agent(args[1:]) + if len(args) == 1 and args[0].lower() in ("-v", "--version", "version"): print("AlphaGSM %s" % (get_version(),)) return 0 @@ -101,6 +105,7 @@ def main(name, args): "compose", "ps", "self-update", + "agent", ) + Server.default_commands ) @@ -322,6 +327,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 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_query.py b/src/server/public_query.py new file mode 100644 index 00000000..c3c93406 --- /dev/null +++ b/src/server/public_query.py @@ -0,0 +1,52 @@ +# pylint: disable=unidiomatic-typecheck +"""Reviewed public profiles; no Server instances, discovery, credentials or fallback.""" + +from types import MappingProxyType +import ipaddress +from utils import query + +PROFILES = MappingProxyType({"teamfortress2": "a2s", "minecraft.vanilla": "slp", "minecraft.paper": "slp"}) + + +def _bounded_text(value): + if not isinstance(value, str): + return "" + return "".join(c for c in value if ord(c) >= 32)[:256] + + +def query_public(game_id, address, port, *, virtual_host=None, timeout=5.0): + """Connect to one caller-validated numerical address, preserving SLP virtual host.""" + if type(game_id) is not str or game_id not in PROFILES: + 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 PROFILES[game_id] == "a2s": + if ip.version != 4: + raise query.QueryError("A2S public profile supports IPv4 only") + raw = query.parse_a2s_info(query.a2s_info(str(ip), port, timeout=timeout)) + if raw is None: + raise query.QueryError("Invalid A2S statistics") + result = { + "name": _bounded_text(raw["name"]), + "map": _bounded_text(raw["map"]), + "players": raw["players"], + "capacity": raw["max_players"], + } + else: + raw = query.slp_info(str(ip), port, timeout=timeout, virtual_host=virtual_host) + result = { + "name": _bounded_text(raw["description"]), + "players": raw["players_online"], + "capacity": raw["players_max"], + "version": _bounded_text(raw["version"]), + } + 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..7a0ba72c 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): 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_minecraft_vanilla.py b/tests/integration_tests/test_minecraft_vanilla.py index 56822a77..352dd1eb 100644 --- a/tests/integration_tests/test_minecraft_vanilla.py +++ b/tests/integration_tests/test_minecraft_vanilla.py @@ -256,6 +256,43 @@ 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.journal import Journal + from agent.lifecycle import AlphaRuntime, Executor + 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}, + }) + cfg.bind("integration-binding", server_name, 1, actions=["restart", "status"], + publication=False, metadata={"name": "integration", "game_id": module_name, + "host": "game.example", "port": port, "capacity": 20}) + cfg.enable(management=True) + runtime = AlphaRuntime() + runtime.lock_path = lambda tag: home_dir / "conf" / (tag + ".json") + 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/smoke_tests/README.md b/tests/smoke_tests/README.md index 24153ab0..ffb72210 100644 --- a/tests/smoke_tests/README.md +++ b/tests/smoke_tests/README.md @@ -6,7 +6,14 @@ 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 independent opt-ins, private credential storage, strict contracts, + local revocation, no-replay journaling, bounded lifecycle helper timeout and + hostile public protocol parsing. No enrollment or public destination is contacted. + The Minecraft integration test also proves 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_sector_alpha_agent.sh b/tests/smoke_tests/run_sector_alpha_agent.sh new file mode 100755 index 00000000..ddb696e3 --- /dev/null +++ b/tests/smoke_tests/run_sector_alpha_agent.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Offline opt-in, strict contract, hostile protocol and no-replay 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..8bf3d4ae --- /dev/null +++ b/tests/unit_tests/agent/test_agent.py @@ -0,0 +1,114 @@ +"""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_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 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_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_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/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_query.py b/tests/unit_tests/server/test_public_query.py new file mode 100644 index 00000000..aebe34b1 --- /dev/null +++ b/tests/unit_tests/server/test_public_query.py @@ -0,0 +1,63 @@ +"""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(' Date: Tue, 13 Oct 2026 22:23:29 +0100 Subject: [PATCH 02/22] Support independent capability credential rotation --- DEVELOPERS.md | 5 +++++ README.md | 3 +++ changelog.txt | 2 ++ docs/sector-alpha-agent.md | 9 +++++++++ src/agent/cli.py | 8 ++++++++ src/agent/config.py | 21 +++++++++++++++++++++ tests/unit_tests/agent/test_agent.py | 21 +++++++++++++++++++++ 7 files changed, 69 insertions(+) diff --git a/DEVELOPERS.md b/DEVELOPERS.md index 11d14ea4..49ed1435 100644 --- a/DEVELOPERS.md +++ b/DEVELOPERS.md @@ -1161,3 +1161,8 @@ 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. diff --git a/README.md b/README.md index 4fb737dd..35c5ed97 100644 --- a/README.md +++ b/README.md @@ -545,3 +545,6 @@ start, stop and restart it permits. Local AlphaGSM works without a connection. 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/changelog.txt b/changelog.txt index bab0e61a..6325d0e1 100644 --- a/changelog.txt +++ b/changelog.txt @@ -17,6 +17,8 @@ Historical detail lives in git history. Keep this file focused on release-facing - 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 diff --git a/docs/sector-alpha-agent.md b/docs/sector-alpha-agent.md index 13f51851..54b89930 100644 --- a/docs/sector-alpha-agent.md +++ b/docs/sector-alpha-agent.md @@ -72,3 +72,12 @@ helper when `ALPHAGSM_VALIDATE_AGENT=1`, using its selected process/Docker runti 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/src/agent/cli.py b/src/agent/cli.py index 4f8f0e8a..0b4a6d1c 100644 --- a/src/agent/cli.py +++ b/src/agent/cli.py @@ -24,6 +24,8 @@ def run(args, *, root=None): "forget", ): commands.add_parser(command) + replacement = commands.add_parser("replace-credential") + replacement.add_argument("scope", choices=("reporting", "management")) bind = commands.add_parser("bind") bind.add_argument("binding_id") bind.add_argument("local_tag") @@ -52,6 +54,12 @@ def run(args, *, root=None): response = Transport(api_origin).request("POST", "/v1/agent/enroll", payload={"code": code}) cfg.enroll(api_origin, response) print("Enrolled. Reporting and management remain disabled until you enable each locally.") + 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 == "bind": # Verify that the local target exists under this user's normal AlphaGSM namespace. from server.server import Server diff --git a/src/agent/config.py b/src/agent/config.py index 76a43580..94c5da6d 100644 --- a/src/agent/config.py +++ b/src/agent/config.py @@ -121,6 +121,27 @@ def enroll(self, api_origin, response): 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): identifier(binding_id) local_tag(tag) diff --git a/tests/unit_tests/agent/test_agent.py b/tests/unit_tests/agent/test_agent.py index 8bf3d4ae..289e3320 100644 --- a/tests/unit_tests/agent/test_agent.py +++ b/tests/unit_tests/agent/test_agent.py @@ -112,3 +112,24 @@ def test_runtime_deadline_kills_helper_and_journals_unknown(tmp_path, monkeypatc 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) From 59a513c6f1bca318437ff8104030413a4610935a Mon Sep 17 00:00:00 2001 From: cosmosquark Date: Thu, 15 Oct 2026 00:21:12 +0100 Subject: [PATCH 03/22] Keep agent reporting alive through lifecycle lock contention --- changelog.txt | 4 ++ docs/sector-alpha-agent.md | 4 ++ src/agent/service.py | 4 +- tests/smoke_tests/run_sector_alpha_agent.sh | 2 +- tests/unit_tests/agent/test_service.py | 63 +++++++++++++++++++++ 5 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 tests/unit_tests/agent/test_service.py diff --git a/changelog.txt b/changelog.txt index 6325d0e1..9bdf748a 100644 --- a/changelog.txt +++ b/changelog.txt @@ -7,6 +7,10 @@ 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-10-15 +- Sector Alpha: retry reporting after temporary configuration-lock contention + during long lifecycle operations, keeping the reporting thread alive. + 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, diff --git a/docs/sector-alpha-agent.md b/docs/sector-alpha-agent.md index 54b89930..c55a2b10 100644 --- a/docs/sector-alpha-agent.md +++ b/docs/sector-alpha-agent.md @@ -37,6 +37,10 @@ No background service is installed automatically. Reporting reconciles approved local name/capacity after successful local changes and every roughly 60 seconds. Advertised host/port remain an explicit local choice. 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, diff --git a/src/agent/service.py b/src/agent/service.py index 71bea3df..632e0f22 100644 --- a/src/agent/service.py +++ b/src/agent/service.py @@ -82,9 +82,11 @@ def _report_loop(self): failures = 0 last_dirty = dirty next_report = time.monotonic() + 55 + secrets.randbelow(11) - except (TransportError, ValueError): + 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 _management_loop(self): diff --git a/tests/smoke_tests/run_sector_alpha_agent.sh b/tests/smoke_tests/run_sector_alpha_agent.sh index ddb696e3..3138b2a3 100755 --- a/tests/smoke_tests/run_sector_alpha_agent.sh +++ b/tests/smoke_tests/run_sector_alpha_agent.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Offline opt-in, strict contract, hostile protocol and no-replay gates. +# Offline opt-in, strict contract, hostile protocol, no-replay and reporting-retry gates. set -euo pipefail cd "$(dirname "$0")/../.." PYTHONPATH=.:src "${PYTHON_BIN:-python3}" -m pytest \ 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' From a42a6f9ff877776c75facd5503ec48513ebfbd4d Mon Sep 17 00:00:00 2001 From: cosmosquark Date: Thu, 15 Oct 2026 01:13:17 +0100 Subject: [PATCH 04/22] Guide agent enrollment and bind all owned local servers --- DEVELOPERS.md | 17 + README.md | 10 + changelog.txt | 6 + docs/sector-alpha-agent.md | 45 +- src/agent/choices.py | 63 +++ src/agent/cli.py | 66 ++- src/agent/config.py | 21 +- src/agent/inventory.py | 83 ++++ src/agent/network.py | 177 ++++++++ src/agent/onboarding.py | 220 +++++++++ src/agent/transport.py | 1 + .../test_minecraft_vanilla.py | 16 +- tests/smoke_tests/README.md | 5 +- tests/smoke_tests/run_sector_alpha_agent.sh | 2 +- tests/unit_tests/agent/test_discovery.py | 266 +++++++++++ tests/unit_tests/agent/test_onboarding.py | 423 ++++++++++++++++++ 16 files changed, 1377 insertions(+), 44 deletions(-) create mode 100644 src/agent/choices.py create mode 100644 src/agent/inventory.py create mode 100644 src/agent/network.py create mode 100644 src/agent/onboarding.py create mode 100644 tests/unit_tests/agent/test_discovery.py create mode 100644 tests/unit_tests/agent/test_onboarding.py diff --git a/DEVELOPERS.md b/DEVELOPERS.md index 49ed1435..b300a490 100644 --- a/DEVELOPERS.md +++ b/DEVELOPERS.md @@ -1147,6 +1147,23 @@ final online authorization and persists executing before lifecycle effects. reconciles unknown and never repeats effects. A global foreground-service lock prevents competing pollers. No secrets are accepted as CLI arguments. +Guided enrollment defaults to `https://sector-alpha.net`; alternate origins must +be HTTPS roots and normalize 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, +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. Local tags and datastore secrets never +cross that boundary. 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. + 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 diff --git a/README.md b/README.md index 35c5ed97..7cfc0583 100644 --- a/README.md +++ b/README.md @@ -542,6 +542,16 @@ 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 '*' +``` + +Enrollment defaults to `https://sector-alpha.net`. 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, publication and remote actions before saving. + 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/. diff --git a/changelog.txt b/changelog.txt index 9bdf748a..404cbc9b 100644 --- a/changelog.txt +++ b/changelog.txt @@ -8,6 +8,12 @@ Historical detail lives in git history. Keep this file focused on release-facing high-level milestones. 2026-10-15 +- Sector Alpha: default enrollment to https://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. diff --git a/docs/sector-alpha-agent.md b/docs/sector-alpha-agent.md index c55a2b10..7dab6d54 100644 --- a/docs/sector-alpha-agent.md +++ b/docs/sector-alpha-agent.md @@ -3,32 +3,57 @@ 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. -The initial service supports Minecraft Java vanilla/Paper and Team Fortress 2. +Public player-stat queries support Minecraft Java vanilla/Paper and Team Fortress 2. +Other managed games can use private reporting and permitted remote actions. On a compatible Sector Alpha 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 status +alphagsm agent bind '*' ``` -Enter the fixed HTTPS origin and code at the prompts. The code/credentials must -not be placed in shell arguments or history. Enrollment leaves both capabilities -disabled. Create one server binding in the owner's website workspace, then bind -that opaque ID to exactly one existing local tag (replace the example values): +Enter the enrollment code at its hidden prompt. The default website is +`https://sector-alpha.net`; use `agent enroll --origin https://your-site.example` +for another compatible service. Never put codes or credentials in shell arguments. + +`agent bind '*'` discovers all eligible servers owned by your local AlphaGSM user, +registers their website bindings, 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, query/game port, name and +capacity. It suggests a public numerical IP from configured networking details, +the outbound interface, or a bounded HTTPS address lookup. Confirm the advertised +host and port: NAT, VPNs and port forwarding can make the suggested endpoint wrong. +Use `--host game.example.net` to override the host and avoid address discovery; +use `--port 25566` when binding one server with a different public 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. + +New bindings default to private with no allowed remote actions. The prompts let +you choose publication and actions; repeat runs preserve your previous choices. +Enrollment and binding leave reporting and management disabled. Enable the +capabilities you want once the preview is correct: ```sh -alphagsm agent bind BINDING_ID my-minecraft --epoch 1 \ - --game minecraft.vanilla --host game.example.net --port 25565 \ - --name 'Our Minecraft world' --capacity 20 --publish \ - --actions status start stop restart 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 \ + --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. Run remains an explicit foreground diff --git a/src/agent/choices.py b/src/agent/choices.py new file mode 100644 index 00000000..6d6b8be7 --- /dev/null +++ b/src/agent/choices.py @@ -0,0 +1,63 @@ +"""Capability-aware permission prompts for exact local binding previews.""" + +from server.public_query import PROFILES +from .config import ConfigError +from .contracts import ACTIONS + + +def publication_choice(game, approved, explicit): + 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 index 0b4a6d1c..e12d2688 100644 --- a/src/agent/cli.py +++ b/src/agent/cli.py @@ -7,6 +7,7 @@ from .contracts import ACTIONS from .service import Service from .transport import Transport, TransportError +from . import onboarding def run(args, *, root=None): @@ -23,20 +24,30 @@ def run(args, *, root=None): "report-once", "forget", ): - commands.add_parser(command) + command_parser = commands.add_parser(command) + if command == "enroll": + command_parser.add_argument("--origin") replacement = commands.add_parser("replace-credential") replacement.add_argument("scope", choices=("reporting", "management")) - bind = commands.add_parser("bind") - bind.add_argument("binding_id") - bind.add_argument("local_tag") - bind.add_argument("--epoch", type=int, default=1) - bind.add_argument("--actions", nargs="*", choices=sorted(ACTIONS), default=[]) - bind.add_argument("--publish", action="store_true") - bind.add_argument("--game", required=True, choices=("teamfortress2", "minecraft.vanilla", "minecraft.paper")) - bind.add_argument("--host", required=True) - bind.add_argument("--port", required=True, type=int) - bind.add_argument("--name", required=True) - bind.add_argument("--capacity", type=int, default=0) + 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("--port", type=int) + bind.add_argument("--name") + bind.add_argument("--capacity", type=int) unbind = commands.add_parser("unbind") unbind.add_argument("binding_id") publication = commands.add_parser("publication") @@ -49,18 +60,22 @@ def run(args, *, root=None): try: cfg = Config(root) if options.command == "enroll": - api_origin = input("Compatible HTTPS origin: ").strip() - code = getpass.getpass("Single-use enrollment code: ").strip() - response = Transport(api_origin).request("POST", "/v1/agent/enroll", payload={"code": code}) - cfg.enroll(api_origin, response) - print("Enrolled. Reporting and management remain disabled until you enable each locally.") + 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 == "bind": + 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 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 server.server import Server from .lifecycle import AlphaRuntime @@ -70,21 +85,21 @@ def run(args, *, root=None): if server.data["module"] != options.game: raise ConfigError("Selected game does not match the local server module") cfg.bind( - options.binding_id, + options.target, options.local_tag, options.epoch, - actions=options.actions, - publication=options.publish, + actions=options.actions or [], + publication=options.publish is True, metadata={ "game_id": options.game, "host": options.host, "port": options.port, "name": options.name, - "capacity": options.capacity, + "capacity": options.capacity or 0, }, ) print("Exact local binding saved. Only the selected operations are permitted.") - if "stop" in options.actions or "restart" in options.actions: + 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 == "unbind": cfg.unbind(options.binding_id) @@ -125,7 +140,10 @@ def run(args, *, root=None): elif options.command == "forget": cfg.forget() return 0 - except (ConfigError, ValueError, OSError, RuntimeError): + except ConfigError 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: diff --git a/src/agent/config.py b/src/agent/config.py index 94c5da6d..ff8770ce 100644 --- a/src/agent/config.py +++ b/src/agent/config.py @@ -15,13 +15,15 @@ class ConfigError(ValueError): 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, + parsed.path not in ("", "/"), parsed.query, parsed.fragment, ) @@ -29,7 +31,7 @@ def origin(value): 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 + return value[:-1] if parsed.path == "/" else value def local_tag(value): @@ -98,7 +100,7 @@ def _write(self, value): atomic_write_text(self.path, json.dumps(value, sort_keys=True), mode=0o600) def enroll(self, api_origin, response): - origin(api_origin) + api_origin = origin(api_origin) exact(response, ("schema_version", "installation_id", "credential_generation", "reporting", "management")) integer(response["schema_version"], 1, 1) identifier(response["installation_id"]) @@ -143,6 +145,10 @@ def replace_credential(self, scope, key_id, secret, *, generation=None): self._write(value) def bind(self, binding_id, tag, epoch, *, actions, publication, metadata): + from server.public_query import PROFILES + + 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) @@ -167,13 +173,16 @@ def bind(self, binding_id, tag, epoch, *, actions, publication, metadata): 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") - value["bindings"][binding_id] = { + binding = { "tag": tag, "epoch": epoch, "actions": actions, "publication": publication, "metadata": metadata, } + 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) @@ -203,6 +212,8 @@ def unbind(self, binding_id): 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") @@ -210,6 +221,8 @@ def publication(self, binding_id, enabled): value = self.load() if binding_id not in value["bindings"]: raise ConfigError("Unknown local binding") + if enabled and value["bindings"][binding_id]["metadata"]["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) diff --git a/src/agent/inventory.py b/src/agent/inventory.py new file mode 100644 index 00000000..d1ad5fb5 --- /dev/null +++ b/src/agent/inventory.py @@ -0,0 +1,83 @@ +"""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 + + +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), + "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/network.py b/src/agent/network.py new file mode 100644 index 00000000..4221ab13 --- /dev/null +++ b/src/agent/network.py @@ -0,0 +1,177 @@ +"""Bounded public-address suggestions without credentials, proxies or redirects.""" + +import http.client +import ipaddress +import queue +import socket +import ssl +import threading +import time +import urllib.request + +from .transport import NoRedirect + +LOOKUP_URL = "https://api.ipify.org" +LOOKUP_TIMEOUT = 5 +LOOKUP_BYTES = 64 + + +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: + """One deadline owns every socket, including connect, handshake and headers.""" + + def __init__(self): + self.deadline = time.monotonic() + LOOKUP_TIMEOUT + 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("Public-address lookup 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=socket.AF_INET, 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): + super().__init__(context=ssl.create_default_context()) + self.budget = budget + + def https_open(self, req): + def connection(host, **kwargs): + return _DeadlineHTTPSConnection(self.budget, host, **kwargs) + return self.do_open(connection, req, context=self._context, check_hostname=self._check_hostname) + + +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.""" + 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) + + 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..54558ce7 --- /dev/null +++ b/src/agent/onboarding.py @@ -0,0 +1,220 @@ +"""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 ConfigError, 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 + +DEFAULT_ORIGIN = "https://sector-alpha.net" + + +def enroll(cfg, api_origin, transport): + api_origin = 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) + print("Enrolled. Reporting and management remain disabled until you enable each locally.") + + +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) + port = options.port if options.port is not None else approved.get('port', local['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) + "]: ").strip() + address, port = endpoint(response, address, port) + publication, actions = permissions(game, previous, value, options) + metadata = {"game_id": game, "host": address, "port": port, + "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} + + +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() + require_capabilities(value, options.publish is True, options.actions or []) + 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 = 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 and len(locals_) > 1: + raise ConfigError("Use per-server public endpoint prompts or bind one tag when overriding --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'] + + " | 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']) + + " | 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')) + 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) + 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) + 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']} + if previous != planned: + cfg.bind(binding_id, item['tag'], epoch, actions=item['actions'], publication=item['publication'], + metadata=item['metadata']) + 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/transport.py b/src/agent/transport.py index 2be0b0da..f6be9dd0 100644 --- a/src/agent/transport.py +++ b/src/agent/transport.py @@ -31,6 +31,7 @@ def __init__(self, api_origin, *, context=None): def request(self, method, path, *, credential=None, payload=None, session_id=None, timeout=10): if path not in ( "/v1/agent/enroll", + "/v1/agent/bindings", "/v1/agent/reports", "/v1/agent/policy", "/v1/agent/jobs", diff --git a/tests/integration_tests/test_minecraft_vanilla.py b/tests/integration_tests/test_minecraft_vanilla.py index 352dd1eb..37f19768 100644 --- a/tests/integration_tests/test_minecraft_vanilla.py +++ b/tests/integration_tests/test_minecraft_vanilla.py @@ -265,8 +265,10 @@ def test_minecraft_vanilla_download_install_and_start(tmp_path): # 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") @@ -275,9 +277,17 @@ def test_minecraft_vanilla_download_install_and_start(tmp_path): "credential_generation": 1, "reporting": None, "management": {"key_id": "integration-key", "secret": "x" * 43}, }) - cfg.bind("integration-binding", server_name, 1, actions=["restart", "status"], - publication=False, metadata={"name": "integration", "game_id": module_name, - "host": "game.example", "port": port, "capacity": 20}) + # 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") diff --git a/tests/smoke_tests/README.md b/tests/smoke_tests/README.md index ffb72210..fbaa23a3 100644 --- a/tests/smoke_tests/README.md +++ b/tests/smoke_tests/README.md @@ -11,8 +11,9 @@ Current smoke tests: - `run_sector_alpha_agent.sh` Exercises independent opt-ins, private credential storage, strict contracts, local revocation, no-replay journaling, bounded lifecycle helper timeout and - hostile public protocol parsing. No enrollment or public destination is contacted. - The Minecraft integration test also proves agent restart and duplicate delivery + hostile public protocol parsing. Guided single/bulk binding uses isolated local + inventories and mocked registration/address discovery; no public destination is contacted. + The Minecraft integration test also proves inferred binding, agent restart and duplicate delivery against the selected process/Docker runtime. - `run_minecraft_vanilla.sh` diff --git a/tests/smoke_tests/run_sector_alpha_agent.sh b/tests/smoke_tests/run_sector_alpha_agent.sh index 3138b2a3..a97e0c20 100755 --- a/tests/smoke_tests/run_sector_alpha_agent.sh +++ b/tests/smoke_tests/run_sector_alpha_agent.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Offline opt-in, strict contract, hostile protocol, no-replay and reporting-retry gates. +# Offline guided/bulk binding, consent, hostile protocol and no-replay gates. set -euo pipefail cd "$(dirname "$0")/../.." PYTHONPATH=.:src "${PYTHON_BIN:-python3}" -m pytest \ diff --git a/tests/unit_tests/agent/test_discovery.py b/tests/unit_tests/agent/test_discovery.py new file mode 100644 index 00000000..ab4b8418 --- /dev/null +++ b/tests/unit_tests/agent/test_discovery.py @@ -0,0 +1,266 @@ +"""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', '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) diff --git a/tests/unit_tests/agent/test_onboarding.py b/tests/unit_tests/agent/test_onboarding.py new file mode 100644 index 00000000..26e6bc74 --- /dev/null +++ b/tests/unit_tests/agent/test_onboarding.py @@ -0,0 +1,423 @@ +"""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)) + 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://sector-alpha.net' + assert Transport('https://sector-alpha.net/').origin == 'https://sector-alpha.net' + + +def test_enroll_default_only_prompts_for_hidden_code(tmp_path, provider, monkeypatch): + monkeypatch.setattr('builtins.input', lambda prompt: pytest.fail('unexpected visible prompt')) + assert cli.run(['enroll'], root=tmp_path / 'agent') == 0 + assert provider == [('https://sector-alpha.net', 'POST', '/v1/agent/enroll', {'payload': {'code': 'hidden-code'}})] + assert not Config(tmp_path / 'agent').load()['management_enabled'] + + +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, + 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, 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 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_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 == [] From 76a284eb7e840ae48273bb55dcb3f99730ca1295 Mon Sep 17 00:00:00 2001 From: cosmosquark Date: Thu, 15 Oct 2026 01:42:37 +0100 Subject: [PATCH 05/22] Validate hostname publication against local machine addresses --- DEVELOPERS.md | 30 +- README.md | 5 + changelog.txt | 6 + docs/sector-alpha-agent.md | 26 +- requirements-runtime.txt | 1 + scripts/build_binary.py | 2 +- src/agent/cli.py | 17 +- src/agent/config.py | 4 +- src/agent/dns_lookup.py | 51 ++ src/agent/endpoint_check.py | 52 ++ src/agent/http_deadline.py | 119 +++++ src/agent/network.py | 93 +--- src/agent/onboarding.py | 12 +- src/agent/preflight.py | 140 ++++++ src/agent/publication_config.py | 38 ++ src/agent/service.py | 25 +- src/agent/transport.py | 69 ++- .../test_minecraft_vanilla.py | 4 + tests/smoke_tests/README.md | 2 + tests/smoke_tests/run_sector_alpha_agent.sh | 2 +- tests/unit_tests/agent/test_discovery.py | 24 + tests/unit_tests/agent/test_dns_lookup.py | 59 +++ tests/unit_tests/agent/test_preflight.py | 457 ++++++++++++++++++ .../unit_tests/agent/test_report_deadline.py | 181 +++++++ tests/unit_tests/test_binary_packaging.py | 8 +- 25 files changed, 1307 insertions(+), 120 deletions(-) create mode 100644 src/agent/dns_lookup.py create mode 100644 src/agent/endpoint_check.py create mode 100644 src/agent/http_deadline.py create mode 100644 src/agent/preflight.py create mode 100644 src/agent/publication_config.py create mode 100644 tests/unit_tests/agent/test_dns_lookup.py create mode 100644 tests/unit_tests/agent/test_preflight.py create mode 100644 tests/unit_tests/agent/test_report_deadline.py diff --git a/DEVELOPERS.md b/DEVELOPERS.md index b300a490..1b9e1874 100644 --- a/DEVELOPERS.md +++ b/DEVELOPERS.md @@ -1159,11 +1159,37 @@ and sends no agent credentials. Explicit `--host` bypasses discovery. 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. Local tags and datastore secrets never -cross that boundary. Registration grants no actions or publication. Existing +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. +`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 diff --git a/README.md b/README.md index 7cfc0583..e1d3c08b 100644 --- a/README.md +++ b/README.md @@ -552,6 +552,11 @@ eligible local servers, inferring their settings and registering website IDs automatically. Use `agent bind my-minecraft` for one server. Review the suggested public address, publication and remote actions before saving. +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 game port separately. + 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/. diff --git a/changelog.txt b/changelog.txt index 404cbc9b..ce908003 100644 --- a/changelog.txt +++ b/changelog.txt @@ -8,6 +8,12 @@ Historical detail lives in git history. Keep this file focused on release-facing high-level milestones. 2026-10-15 +- 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://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; diff --git a/docs/sector-alpha-agent.md b/docs/sector-alpha-agent.md index 7dab6d54..e21a62a4 100644 --- a/docs/sector-alpha-agent.md +++ b/docs/sector-alpha-agent.md @@ -28,11 +28,35 @@ The guided preview reads each server's configured game, query/game port, name an capacity. It suggests a public numerical IP from configured networking details, the outbound interface, or a bounded HTTPS address lookup. Confirm the advertised host and port: NAT, VPNs and port forwarding can make the suggested endpoint wrong. -Use `--host game.example.net` to override the host and avoid address discovery; +Use `--host game.example.net` to override the host and avoid address discovery during binding; use `--port 25566` when binding one server with a different public 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. +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 port through the supported +native game 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. + New bindings default to private with no allowed remote actions. The prompts let you choose publication and actions; repeat runs preserve your previous choices. Enrollment and binding leave reporting and management disabled. Enable the 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 1527a2fc..83ca78a4 100644 --- a/scripts/build_binary.py +++ b/scripts/build_binary.py @@ -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/src/agent/cli.py b/src/agent/cli.py index e12d2688..b7bea93c 100644 --- a/src/agent/cli.py +++ b/src/agent/cli.py @@ -8,6 +8,9 @@ from .service import Service from .transport import Transport, TransportError from . import onboarding +from .publication_config import PublicationConfigError, local_publication +from .dns_lookup import PreflightError +from . import endpoint_check def run(args, *, root=None): @@ -45,9 +48,13 @@ def run(args, *, root=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) 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") @@ -97,10 +104,18 @@ def run(args, *, root=None): "name": options.name, "capacity": options.capacity or 0, }, + **local_publication(options.public_ip), ) print("Exact local binding saved. Only the selected operations are permitted.") 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": @@ -140,7 +155,7 @@ def run(args, *, root=None): elif options.command == "forget": cfg.forget() return 0 - except ConfigError as exc: + except (ConfigError, PreflightError, PublicationConfigError) as exc: print("Agent operation rejected: " + str(exc)) return 1 except (ValueError, OSError, RuntimeError, EOFError): diff --git a/src/agent/config.py b/src/agent/config.py index ff8770ce..3f2e45fa 100644 --- a/src/agent/config.py +++ b/src/agent/config.py @@ -144,8 +144,9 @@ def replace_credential(self, scope, key_id, secret, *, generation=None): value[scope] = {"key_id": key_id, "secret": secret} self._write(value) - def bind(self, binding_id, tag, epoch, *, actions, publication, metadata): + 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") not in PROFILES: raise ConfigError("This game has no supported public query profile") @@ -179,6 +180,7 @@ def bind(self, binding_id, tag, epoch, *, actions, publication, metadata): "actions": actions, "publication": publication, "metadata": metadata, + **local_publication(expected_public_ips, value["bindings"].get(binding_id)), } if value["bindings"].get(binding_id) == binding: return 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..bd77046c --- /dev/null +++ b/src/agent/endpoint_check.py @@ -0,0 +1,52 @@ +"""Explicit native endpoint diagnostics; never run as a publication renewal hook.""" + +import ipaddress +import time + +from server.public_query import PROFILES, 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'] + if game not in PROFILES: + raise PreflightError("This game has no supported public query profile; private binding remains available") + if 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 PROFILES[game] == '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") + 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..bb0016e5 --- /dev/null +++ b/src/agent/http_deadline.py @@ -0,0 +1,119 @@ +"""Reusable total HTTPS deadlines with active socket cancellation and no late connect.""" + +import http.client +import queue +import socket +import ssl +import threading +import time +import urllib.request + + +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 ssl.create_default_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/network.py b/src/agent/network.py index 4221ab13..505acdb4 100644 --- a/src/agent/network.py +++ b/src/agent/network.py @@ -1,6 +1,5 @@ """Bounded public-address suggestions without credentials, proxies or redirects.""" -import http.client import ipaddress import queue import socket @@ -10,10 +9,12 @@ 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): @@ -40,92 +41,14 @@ def routing_ip(): return None -class _LookupBudget: - """One deadline owns every socket, including connect, handshake and headers.""" - +class _LookupBudget(DeadlineBudget): def __init__(self): - self.deadline = time.monotonic() + LOOKUP_TIMEOUT - 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("Public-address lookup 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=socket.AF_INET, 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): - super().__init__(context=ssl.create_default_context()) - self.budget = budget - - def https_open(self, req): - def connection(host, **kwargs): - return _DeadlineHTTPSConnection(self.budget, host, **kwargs) - return self.do_open(connection, req, context=self._context, check_hostname=self._check_hostname) + super().__init__(LOOKUP_TIMEOUT, family=socket.AF_INET) def _lookup_exchange(budget): opener = urllib.request.build_opener( - urllib.request.ProxyHandler({}), NoRedirect(), _DeadlineHTTPSHandler(budget), + 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: @@ -146,6 +69,10 @@ def _lookup_exchange(budget): 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) @@ -154,6 +81,8 @@ def exchange(): 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() diff --git a/src/agent/onboarding.py b/src/agent/onboarding.py index 54558ce7..1f99c1b1 100644 --- a/src/agent/onboarding.py +++ b/src/agent/onboarding.py @@ -14,6 +14,7 @@ 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 DEFAULT_ORIGIN = "https://sector-alpha.net" @@ -93,7 +94,7 @@ def candidate(local, value, options, suggest=suggest_public_host): 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} + 'publication': publication, 'metadata': metadata, **local_publication(options.public_ip, previous)} def register(transport, value, tag): @@ -111,6 +112,7 @@ def register(transport, value, tag): 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.host is not None: host(options.host) @@ -165,7 +167,8 @@ def suggest(local): " | 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')) + " | 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 @@ -208,10 +211,11 @@ def save_candidates(cfg, value, candidates, transport): 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']} + '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']) + metadata=item['metadata'], **local_publication(item.get('expected_public_ips'), previous)) value = cfg.load() print("Bound " + item['tag'] + " (" + binding_id + ").") except (OSError, ValueError, RuntimeError): diff --git a/src/agent/preflight.py b/src/agent/preflight.py new file mode 100644 index 00000000..66735975 --- /dev/null +++ b/src/agent/preflight.py @@ -0,0 +1,140 @@ +"""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(binding['metadata'][key] != item[key] for key in ('game_id', 'host', '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/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/service.py b/src/agent/service.py index 632e0f22..cc26bb44 100644 --- a/src/agent/service.py +++ b/src/agent/service.py @@ -7,6 +7,7 @@ from .journal import Journal from .lifecycle import AlphaRuntime, Executor from .reporting import policy_snapshot, report_snapshot +from .preflight import final_report, preflight_report from .transport import Transport, TransportError @@ -15,6 +16,7 @@ def __init__(self, config, *, runtime=None, transport=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) @@ -22,14 +24,28 @@ def __init__(self, config, *, runtime=None, transport=None): def report_once(self): cfg = self.config.load() - if not cfg["reporting"]: + if not cfg["reporting"] or (cfg["origin"], cfg["installation_id"]) != self.relationship: return None payload = report_snapshot(self.config, self.runtime) - return self.transport.request("POST", "/v1/agent/reports", credential=cfg["reporting"], payload=payload) + 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): cfg = self.config.load() - if not cfg["management_enabled"]: + 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)) @@ -50,7 +66,8 @@ def management_once(self): def authorize(value): latest = self.config.load() - if not latest["management_enabled"]: + if (not latest["management_enabled"] + or (latest["origin"], latest["installation_id"]) != self.relationship): return False result = self.transport.request( "POST", diff --git a/src/agent/transport.py b/src/agent/transport.py index f6be9dd0..6d27a742 100644 --- a/src/agent/transport.py +++ b/src/agent/transport.py @@ -5,9 +5,13 @@ import json import ssl import time +import threading import urllib.error import urllib.request from .config import origin +from .http_deadline import DeadlineBudget, DeadlineHTTPSHandler, bounded_exchange + +REPORT_TIMEOUT = 10 class TransportError(OSError): @@ -22,10 +26,11 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): class Transport: def __init__(self, api_origin, *, context=None): self.origin = origin(api_origin) + self.report_lock = threading.Lock() self.opener = urllib.request.build_opener( urllib.request.ProxyHandler({}), NoRedirect(), - urllib.request.HTTPSHandler(context=context or ssl.create_default_context()), + DeadlineHTTPSHandler(context=context or ssl.create_default_context()), ) def request(self, method, path, *, credential=None, payload=None, session_id=None, timeout=10): @@ -55,31 +60,51 @@ def request(self, method, path, *, credential=None, payload=None, session_id=Non if session_id is not None: headers["X-Agent-Session"] = session_id request = urllib.request.Request(self.origin + path, method=method, data=data, headers=headers) - deadline = time.monotonic() + timeout try: - with self.opener.open(request, timeout=timeout) as response: - if response.geturl() != self.origin + path: - 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 + 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: 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/tests/integration_tests/test_minecraft_vanilla.py b/tests/integration_tests/test_minecraft_vanilla.py index 37f19768..6b17cec2 100644 --- a/tests/integration_tests/test_minecraft_vanilla.py +++ b/tests/integration_tests/test_minecraft_vanilla.py @@ -291,6 +291,10 @@ def test_minecraft_vanilla_download_install_and_start(tmp_path): 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", diff --git a/tests/smoke_tests/README.md b/tests/smoke_tests/README.md index fbaa23a3..b5c04ef2 100644 --- a/tests/smoke_tests/README.md +++ b/tests/smoke_tests/README.md @@ -13,6 +13,8 @@ Current smoke tests: 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. The Minecraft integration test also proves inferred binding, agent restart and duplicate delivery against the selected process/Docker runtime. diff --git a/tests/smoke_tests/run_sector_alpha_agent.sh b/tests/smoke_tests/run_sector_alpha_agent.sh index a97e0c20..faaceeb0 100755 --- a/tests/smoke_tests/run_sector_alpha_agent.sh +++ b/tests/smoke_tests/run_sector_alpha_agent.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Offline guided/bulk binding, consent, hostile protocol and no-replay gates. +# Offline binding, hostname preflight, consent, protocol and no-replay gates. set -euo pipefail cd "$(dirname "$0")/../.." PYTHONPATH=.:src "${PYTHON_BIN:-python3}" -m pytest \ diff --git a/tests/unit_tests/agent/test_discovery.py b/tests/unit_tests/agent/test_discovery.py index ab4b8418..9ccb579a 100644 --- a/tests/unit_tests/agent/test_discovery.py +++ b/tests/unit_tests/agent/test_discovery.py @@ -264,3 +264,27 @@ def connect(self, address): 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_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_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/test_binary_packaging.py b/tests/unit_tests/test_binary_packaging.py index 62ed0dd6..ca3acfe4 100644 --- a/tests/unit_tests/test_binary_packaging.py +++ b/tests/unit_tests/test_binary_packaging.py @@ -75,7 +75,7 @@ def test_binary_command_covers_all_packages_and_only_excludes_placeholder(): args = build_binary.pyinstaller_arguments() packages = [args[index + 1] for index, arg in enumerate(args) if arg == "--collect-submodules"] - assert set(packages) == {"agent", "core", "downloader", "downloadermodules", "gamemodules", + assert set(packages) == {"dns", "agent", "core", "downloader", "downloadermodules", "gamemodules", "screen", "server", "utils"} excluded = [args[index + 1] for index, arg in enumerate(args) if arg == "--exclude-module"] @@ -122,3 +122,9 @@ def test_integration_source_default_is_preserved(monkeypatch, tmp_path): env = helpers.alphagsm_env(tmp_path / "alphagsm.conf") assert env["PYTHONPATH"] == str(helpers.REPO_ROOT / "src") assert helpers.alphagsm_command(env) == [sys.executable, str(helpers.ALPHAGSM_SCRIPT)] + + +def test_binary_collects_dns_record_types_for_hostname_publication(): + args = build_binary.pyinstaller_arguments() + packages = [args[index + 1] for index, arg in enumerate(args) if arg == '--collect-submodules'] + assert 'dns' in packages From ff0e704199e4c6fca873b356a078c4b15b4f7cfa Mon Sep 17 00:00:00 2001 From: cosmosquark Date: Thu, 15 Oct 2026 08:28:02 +0100 Subject: [PATCH 06/22] Verify separate game subdomains on one machine --- DEVELOPERS.md | 8 ++++ README.md | 10 +++++ changelog.txt | 3 ++ docs/sector-alpha-agent.md | 23 ++++++++++ tests/smoke_tests/README.md | 2 + tests/smoke_tests/run_sector_alpha_agent.sh | 2 +- tests/unit_tests/agent/test_onboarding.py | 48 +++++++++++++++++++++ 7 files changed, 95 insertions(+), 1 deletion(-) diff --git a/DEVELOPERS.md b/DEVELOPERS.md index 1b9e1874..4783f7ff 100644 --- a/DEVELOPERS.md +++ b/DEVELOPERS.md @@ -1165,6 +1165,14 @@ Datastore secrets stay local. Registration grants no actions or publication. Exi bindings retain their approved values on repeat runs; capabilities require separate local enablement. Games outside the public query registry remain private. +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, public/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 diff --git a/README.md b/README.md index e1d3c08b..1ddc4892 100644 --- a/README.md +++ b/README.md @@ -557,6 +557,16 @@ that all its DNS addresses match this machine's expected public IPs; `--public-i provides an explicit override. `agent check-endpoint my-minecraft` tests the advertised game 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 configured port and permissions. Bulk binding also accepts +a different hostname at each server's endpoint prompt. + 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/. diff --git a/changelog.txt b/changelog.txt index ce908003..17f4aa05 100644 --- a/changelog.txt +++ b/changelog.txt @@ -8,6 +8,9 @@ Historical detail lives in git history. Keep this file focused on release-facing high-level milestones. 2026-10-15 +- 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 diff --git a/docs/sector-alpha-agent.md b/docs/sector-alpha-agent.md index e21a62a4..7d6bfc4c 100644 --- a/docs/sector-alpha-agent.md +++ b/docs/sector-alpha-agent.md @@ -57,6 +57,29 @@ port, firewall, or missing NAT loopback support; an external test is still neede when the router cannot reach its own public address. The website's separate ownership challenge and isolated query checks remain required. +For different games on the same machine, each local server tag has its own +hostname, public/query port and permissions. +Point your subdomains at the same machine's public IP, then bind each game: + +```sh +alphagsm agent bind my-minecraft --host minecraft.mygameserver.com +alphagsm agent bind my-tf2 --host tf2.mygameserver.com +alphagsm agent check-endpoint my-minecraft +alphagsm agent check-endpoint my-tf2 +``` + +The ports are inferred independently from each server's local settings. Use +`--port` on an individual bind if its public port differs. For `agent bind '*'`, +enter a different `hostname:port` at each server's endpoint prompt; the final +preview lists every mapping. Repeating the command preserves these mappings. + +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. + New bindings default to private with no allowed remote actions. The prompts let you choose publication and actions; repeat runs preserve your previous choices. Enrollment and binding leave reporting and management disabled. Enable the diff --git a/tests/smoke_tests/README.md b/tests/smoke_tests/README.md index b5c04ef2..f4360f50 100644 --- a/tests/smoke_tests/README.md +++ b/tests/smoke_tests/README.md @@ -15,6 +15,8 @@ Current smoke tests: 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. The Minecraft integration test also proves inferred binding, agent restart and duplicate delivery against the selected process/Docker runtime. diff --git a/tests/smoke_tests/run_sector_alpha_agent.sh b/tests/smoke_tests/run_sector_alpha_agent.sh index faaceeb0..62e4a88a 100755 --- a/tests/smoke_tests/run_sector_alpha_agent.sh +++ b/tests/smoke_tests/run_sector_alpha_agent.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Offline binding, hostname preflight, consent, protocol and no-replay gates. +# Offline per-game subdomain binding, hostname preflight, consent and no-replay gates. set -euo pipefail cd "$(dirname "$0")/../.." PYTHONPATH=.:src "${PYTHON_BIN:-python3}" -m pytest \ diff --git a/tests/unit_tests/agent/test_onboarding.py b/tests/unit_tests/agent/test_onboarding.py index 26e6bc74..675e4d96 100644 --- a/tests/unit_tests/agent/test_onboarding.py +++ b/tests/unit_tests/agent/test_onboarding.py @@ -140,6 +140,54 @@ def test_setup_enrolls_once_then_binds_all_private_without_starting(tmp_path, lo 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) From fea4dfc712aef972b69c34e35c4e94db3efade60 Mon Sep 17 00:00:00 2001 From: cosmosquark Date: Thu, 15 Oct 2026 08:50:17 +0100 Subject: [PATCH 07/22] Document same-machine subdomain setup step by step --- README.md | 2 + changelog.txt | 2 + docs/sector-alpha-agent.md | 81 ++++++++++++++++++++++++++++++++------ 3 files changed, 74 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 1ddc4892..d92f459a 100644 --- a/README.md +++ b/README.md @@ -566,6 +566,8 @@ alphagsm agent bind my-tf2 --host tf2.mygameserver.com Each keeps its own configured port 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 diff --git a/changelog.txt b/changelog.txt index 17f4aa05..d71381c5 100644 --- a/changelog.txt +++ b/changelog.txt @@ -8,6 +8,8 @@ Historical detail lives in git history. Keep this file focused on release-facing high-level milestones. 2026-10-15 +- 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. diff --git a/docs/sector-alpha-agent.md b/docs/sector-alpha-agent.md index 7d6bfc4c..668246df 100644 --- a/docs/sector-alpha-agent.md +++ b/docs/sector-alpha-agent.md @@ -57,21 +57,78 @@ port, firewall, or missing NAT loopback support; an external test is still neede when the router cannot reach its own public address. The website's separate ownership challenge and isolated query checks remain required. -For different games on the same machine, each local server tag has its own -hostname, public/query port and permissions. -Point your subdomains at the same machine's public IP, then bind each game: +## 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 public/query port, 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, port, publication and allowed remote actions in each preview + before saving. If a forwarded public port differs from the local setting, add + `--port 25566` to that individual server's command, using its actual public port. + 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 + ``` + + `run` stays in the foreground; use your user service manager for continuous + operation. Complete each endpoint's ownership challenge in the website to + obtain verified public statistics. Reporting does not enable remote management. + +You can also configure all local games in one guided pass: ```sh -alphagsm agent bind my-minecraft --host minecraft.mygameserver.com -alphagsm agent bind my-tf2 --host tf2.mygameserver.com -alphagsm agent check-endpoint my-minecraft -alphagsm agent check-endpoint my-tf2 +alphagsm agent bind '*' ``` -The ports are inferred independently from each server's local settings. Use -`--port` on an individual bind if its public port differs. For `agent bind '*'`, -enter a different `hostname:port` at each server's endpoint prompt; the final -preview lists every mapping. Repeating the command preserves these mappings. +Enter a different `hostname:port` at each server's endpoint prompt, such as +`minecraft.mygameserver.com:25565` and `tf2.mygameserver.com:27015`, replacing the +ports with your configured public/query ports. Choose publication and actions for +each game and review every mapping in the final preview. Repeating the command +preserves existing hostnames, ports and approvals. 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 @@ -80,6 +137,8 @@ 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. Enrollment and binding leave reporting and management disabled. Enable the From 7012e532073c8f593de54a4bd75c9abad07787ee Mon Sep 17 00:00:00 2001 From: cosmosquark Date: Thu, 15 Oct 2026 10:39:46 +0100 Subject: [PATCH 08/22] Route all official agent traffic through the game-server API --- DEVELOPERS.md | 15 ++- README.md | 8 +- changelog.txt | 8 +- docs/sector-alpha-agent.md | 20 +++- src/agent/config.py | 15 ++- src/agent/onboarding.py | 8 +- src/agent/transport.py | 4 +- tests/smoke_tests/README.md | 4 +- tests/smoke_tests/run_sector_alpha_agent.sh | 2 +- tests/unit_tests/agent/test_onboarding.py | 6 +- tests/unit_tests/agent/test_origins.py | 118 ++++++++++++++++++++ 11 files changed, 187 insertions(+), 21 deletions(-) create mode 100644 tests/unit_tests/agent/test_origins.py diff --git a/DEVELOPERS.md b/DEVELOPERS.md index 4783f7ff..3a958be0 100644 --- a/DEVELOPERS.md +++ b/DEVELOPERS.md @@ -1147,8 +1147,19 @@ final online authorization and persists executing before lifecycle effects. reconciles unknown and never repeats effects. A global foreground-service lock prevents competing pollers. No secrets are accepted as CLI arguments. -Guided enrollment defaults to `https://sector-alpha.net`; alternate origins must -be HTTPS roots and normalize an optional trailing slash. `agent.inventory` reads +The single default agent origin is `https://gameserver-api.sector-alpha.net` for +enrollment, registration, reports and every management route. Public website, +account and directory traffic remains at `https://sector-alpha.net`. +`canonical_agent_origin()` applies an exact trusted local authority mapping from +the old official HTTPS website origin (default/443 port, case and root-slash +variants) before enrollment-code or credential submission. It does not follow +network redirects or use domain suffix matching. `Config.load()` returns this +normalized origin for existing installations; the next ordinary write persists +it without changing IDs, keys, bindings or policy. Restart existing agent +processes after upgrading. Explicit custom origins, including non-default ports, +remain unchanged. There is one stored origin and one transport for all scopes. +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, port, name, capacity and networking hints. `agent.network` accepts only globally diff --git a/README.md b/README.md index d92f459a..cab674a8 100644 --- a/README.md +++ b/README.md @@ -547,7 +547,13 @@ alphagsm agent enroll alphagsm agent bind '*' ``` -Enrollment defaults to `https://sector-alpha.net`. The quoted `'*'` binds all +Enrollment, binding registration, reports and management use +`https://gameserver-api.sector-alpha.net`. Public accounts and the directory stay +at `https://sector-alpha.net`. Existing official connections normalize to the API +origin on upgrade, preserving IDs, credentials and permissions; restart the agent +after updating. Custom provider origins stay unchanged. + +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, publication and remote actions before saving. diff --git a/changelog.txt b/changelog.txt index d71381c5..109412dc 100644 --- a/changelog.txt +++ b/changelog.txt @@ -8,6 +8,12 @@ Historical detail lives in git history. Keep this file focused on release-facing high-level milestones. 2026-10-15 +- 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, @@ -19,7 +25,7 @@ Historical detail lives in git history. Keep this file focused on release-facing 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://sector-alpha.net, register bindings +- 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. diff --git a/docs/sector-alpha-agent.md b/docs/sector-alpha-agent.md index 668246df..5dd1ae8e 100644 --- a/docs/sector-alpha-agent.md +++ b/docs/sector-alpha-agent.md @@ -6,7 +6,8 @@ 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. -On a compatible Sector Alpha website, sign into the verified operator account +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 @@ -14,9 +15,20 @@ alphagsm agent enroll alphagsm agent bind '*' ``` -Enter the enrollment code at its hidden prompt. The default website is -`https://sector-alpha.net`; use `agent enroll --origin https://your-site.example` -for another compatible service. Never put codes or credentials in shell arguments. +Enter the enrollment code at its hidden prompt. Enrollment, binding registration, +reporting and remote management all use the single default API origin +`https://gameserver-api.sector-alpha.net`. Public accounts and the server directory +remain at `https://sector-alpha.net`. Use `agent enroll --origin +https://your-site.example` for another compatible service. Never put codes or +credentials in shell arguments. + +After upgrading AlphaGSM, restart the foreground agent (or its user service). +Existing connections using the old official `https://sector-alpha.net` origin +normalize locally to the API origin, including equivalent host casing, `:443` +and a root trailing slash. Enrollment IDs, binding IDs, credentials and local +permissions are preserved. This requires no network redirect or re-enrollment; +the normalized origin is saved on the next ordinary configuration write. +Explicitly configured custom provider origins remain unchanged. `agent bind '*'` discovers all eligible servers owned by your local AlphaGSM user, registers their website bindings, and saves the returned IDs automatically. Quote diff --git a/src/agent/config.py b/src/agent/config.py index 3f2e45fa..8030d4e5 100644 --- a/src/agent/config.py +++ b/src/agent/config.py @@ -9,6 +9,8 @@ from utils.state_io import atomic_write_text, state_lock from .contracts import ACTIONS, identifier, integer, validate_metadata, exact +DEFAULT_ORIGIN = "https://gameserver-api.sector-alpha.net" + class ConfigError(ValueError): """Invalid local configuration or unsupported permission boundary.""" @@ -34,6 +36,15 @@ def origin(value): 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', 'gameserver-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") @@ -94,13 +105,15 @@ def load(self): 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 = origin(api_origin) + 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"]) diff --git a/src/agent/onboarding.py b/src/agent/onboarding.py index 1f99c1b1..6db08144 100644 --- a/src/agent/onboarding.py +++ b/src/agent/onboarding.py @@ -9,18 +9,16 @@ from server.public_query import PROFILES from utils.state_io import state_lock from .lifecycle import AlphaRuntime -from .config import ConfigError, origin +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 -DEFAULT_ORIGIN = "https://sector-alpha.net" - def enroll(cfg, api_origin, transport): - api_origin = origin(api_origin or DEFAULT_ORIGIN) + api_origin = canonical_agent_origin(api_origin or DEFAULT_ORIGIN) try: with warnings.catch_warnings(): warnings.simplefilter("error", getpass.GetPassWarning) @@ -119,7 +117,7 @@ def guided(cfg, options, transport): if options.binding_id is not None: identifier(options.binding_id) if options.origin is not None: - selected_origin = origin(options.origin) + 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 == '*' diff --git a/src/agent/transport.py b/src/agent/transport.py index 6d27a742..37b15afa 100644 --- a/src/agent/transport.py +++ b/src/agent/transport.py @@ -8,7 +8,7 @@ import threading import urllib.error import urllib.request -from .config import origin +from .config import canonical_agent_origin from .http_deadline import DeadlineBudget, DeadlineHTTPSHandler, bounded_exchange REPORT_TIMEOUT = 10 @@ -25,7 +25,7 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): class Transport: def __init__(self, api_origin, *, context=None): - self.origin = origin(api_origin) + self.origin = canonical_agent_origin(api_origin) self.report_lock = threading.Lock() self.opener = urllib.request.build_opener( urllib.request.ProxyHandler({}), diff --git a/tests/smoke_tests/README.md b/tests/smoke_tests/README.md index f4360f50..300bb26a 100644 --- a/tests/smoke_tests/README.md +++ b/tests/smoke_tests/README.md @@ -9,7 +9,9 @@ directly to the terminal or CI log as they run. Current smoke tests: - `run_sector_alpha_agent.sh` - Exercises independent opt-ins, private credential storage, strict contracts, + Exercises the single `gameserver-api.sector-alpha.net` agent origin, 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. diff --git a/tests/smoke_tests/run_sector_alpha_agent.sh b/tests/smoke_tests/run_sector_alpha_agent.sh index 62e4a88a..72296aeb 100755 --- a/tests/smoke_tests/run_sector_alpha_agent.sh +++ b/tests/smoke_tests/run_sector_alpha_agent.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Offline per-game subdomain binding, hostname preflight, consent and no-replay gates. +# Offline trusted agent-API origin upgrade, hostname preflight, consent and no-replay gates. set -euo pipefail cd "$(dirname "$0")/../.." PYTHONPATH=.:src "${PYTHON_BIN:-python3}" -m pytest \ diff --git a/tests/unit_tests/agent/test_onboarding.py b/tests/unit_tests/agent/test_onboarding.py index 675e4d96..c148aa0f 100644 --- a/tests/unit_tests/agent/test_onboarding.py +++ b/tests/unit_tests/agent/test_onboarding.py @@ -65,14 +65,14 @@ def test_root_origin_slash_is_normalized_in_storage_and_transport(tmp_path): 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://sector-alpha.net' - assert Transport('https://sector-alpha.net/').origin == 'https://sector-alpha.net' + assert cfg.load()['origin'] == 'https://gameserver-api.sector-alpha.net' + assert Transport('https://sector-alpha.net/').origin == 'https://gameserver-api.sector-alpha.net' def test_enroll_default_only_prompts_for_hidden_code(tmp_path, provider, monkeypatch): monkeypatch.setattr('builtins.input', lambda prompt: pytest.fail('unexpected visible prompt')) assert cli.run(['enroll'], root=tmp_path / 'agent') == 0 - assert provider == [('https://sector-alpha.net', 'POST', '/v1/agent/enroll', {'payload': {'code': 'hidden-code'}})] + assert provider == [('https://gameserver-api.sector-alpha.net', 'POST', '/v1/agent/enroll', {'payload': {'code': 'hidden-code'}})] assert not Config(tmp_path / 'agent').load()['management_enabled'] diff --git a/tests/unit_tests/agent/test_origins.py b/tests/unit_tests/agent/test_origins.py new file mode 100644 index 00000000..650e377c --- /dev/null +++ b/tests/unit_tests/agent/test_origins.py @@ -0,0 +1,118 @@ +"""The official agent API upgrade is a local exact alias, never a 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://gameserver-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/'] + + +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_rewrites_known_alias_before_enrollment_code_submission(tmp_path, provider, old): + 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://gameserver-api.sector-alpha.net.attacker.example', 'https://sector-alpha%2Enet']) +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_official_registration_uses_api_and_keeps_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 From cd8fd035d1973bd82b0d98a0a218c6c70f5c30ad Mon Sep 17 00:00:00 2001 From: cosmosquark Date: Thu, 15 Oct 2026 14:03:05 +0100 Subject: [PATCH 09/22] Report approved player connection ports separately from query ports --- DEVELOPERS.md | 23 +- README.md | 14 +- changelog.txt | 7 + docs/sector-alpha-agent.md | 53 +++- src/agent/binding_ports.py | 18 ++ src/agent/cli.py | 14 +- src/agent/contracts.py | 5 +- src/agent/inventory.py | 1 + src/agent/onboarding.py | 20 +- src/agent/preflight.py | 3 +- tests/smoke_tests/README.md | 2 + tests/smoke_tests/run_sector_alpha_agent.sh | 2 +- .../unit_tests/agent/test_connection_ports.py | 230 ++++++++++++++++++ tests/unit_tests/agent/test_discovery.py | 2 +- tests/unit_tests/agent/test_onboarding.py | 6 +- 15 files changed, 365 insertions(+), 35 deletions(-) create mode 100644 src/agent/binding_ports.py create mode 100644 tests/unit_tests/agent/test_connection_ports.py diff --git a/DEVELOPERS.md b/DEVELOPERS.md index 3a958be0..f7f3c473 100644 --- a/DEVELOPERS.md +++ b/DEVELOPERS.md @@ -1162,7 +1162,7 @@ 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, -port, name, capacity and networking hints. `agent.network` accepts only globally +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. @@ -1176,9 +1176,28 @@ Datastore secrets stay local. Registration grants no actions or publication. Exi 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, public/query port, expected-IP policy, +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 diff --git a/README.md b/README.md index cab674a8..a205e234 100644 --- a/README.md +++ b/README.md @@ -556,12 +556,20 @@ after updating. Custom provider origins stay unchanged. 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, publication and remote actions before saving. +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 game port separately. +advertised query port separately. Multiple games can share one machine and public IP with separate subdomains: @@ -570,7 +578,7 @@ alphagsm agent bind my-minecraft --host minecraft.mygameserver.com alphagsm agent bind my-tf2 --host tf2.mygameserver.com ``` -Each keeps its own configured port and permissions. Bulk binding also accepts +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. diff --git a/changelog.txt b/changelog.txt index 109412dc..9cfe6cb1 100644 --- a/changelog.txt +++ b/changelog.txt @@ -8,6 +8,13 @@ Historical detail lives in git history. Keep this file focused on release-facing high-level milestones. 2026-10-15 +- 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. diff --git a/docs/sector-alpha-agent.md b/docs/sector-alpha-agent.md index 5dd1ae8e..134ef13f 100644 --- a/docs/sector-alpha-agent.md +++ b/docs/sector-alpha-agent.md @@ -36,14 +36,35 @@ registers their website bindings, and saves the returned IDs automatically. Quot 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, query/game port, name and -capacity. It suggests a public numerical IP from configured networking details, +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 and port: NAT, VPNs and port forwarding can make the suggested endpoint wrong. +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 25566` when binding one server with a different public port. +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. 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: @@ -62,7 +83,7 @@ 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 port through the supported +`check-endpoint` additionally tests the advertised query port through the supported native game 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 @@ -91,7 +112,7 @@ the enrollment instructions above and run `alphagsm agent enroll` first. the new records resolve correctly before checking the endpoints. 2. **Bind each local game to its subdomain.** Each command infers that server's - configured public/query port, name and capacity. Replace the example tags with + configured player and query ports, name and capacity. Replace the example tags with your existing local server tags: ```sh @@ -99,9 +120,10 @@ the enrollment instructions above and run `alphagsm agent enroll` first. alphagsm agent bind my-tf2 --host tf2.mygameserver.com --publish ``` - Review the host, port, publication and allowed remote actions in each preview - before saving. If a forwarded public port differs from the local setting, add - `--port 25566` to that individual server's command, using its actual public port. + 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. @@ -137,10 +159,14 @@ alphagsm agent bind '*' ``` Enter a different `hostname:port` at each server's endpoint prompt, such as -`minecraft.mygameserver.com:25565` and `tf2.mygameserver.com:27015`, replacing the -ports with your configured public/query ports. Choose publication and actions for +`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 @@ -167,7 +193,7 @@ 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 \ + --game minecraft.vanilla --host game.example.net --port 25565 --connect-port 25565 \ --name 'Our Minecraft world' --capacity 20 --publish \ --actions status start stop restart ``` @@ -178,7 +204,8 @@ can be managed without a directory listing. Run remains an explicit foreground service; install it under a user service manager if continuous operation is wanted. No background service is installed automatically. Reporting reconciles approved local name/capacity after successful local changes and every roughly 60 seconds. -Advertised host/port remain an explicit local choice. The compatible service +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 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/cli.py b/src/agent/cli.py index b7bea93c..9262a5d0 100644 --- a/src/agent/cli.py +++ b/src/agent/cli.py @@ -11,6 +11,7 @@ from .publication_config import PublicationConfigError, local_publication from .dns_lookup import PreflightError from . import endpoint_check +from .binding_ports import selected_ports def run(args, *, root=None): @@ -49,7 +50,8 @@ def run(args, *, root=None): 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) + 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") @@ -84,13 +86,13 @@ def run(args, *, root=None): 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 server.server import Server from .lifecycle import AlphaRuntime with AlphaRuntime().lock(local_tag(options.local_tag)): - server = Server(options.local_tag) - if server.data["module"] != options.game: + 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, @@ -100,13 +102,15 @@ def run(args, *, root=None): metadata={ "game_id": options.game, "host": options.host, - "port": options.port, + **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": diff --git a/src/agent/contracts.py b/src/agent/contracts.py index c0675d69..cf4712b7 100644 --- a/src/agent/contracts.py +++ b/src/agent/contracts.py @@ -93,6 +93,7 @@ def validate_job(value): def validate_metadata(value): + optional = ("connect_port",) if type(value) is dict and "connect_port" in value else () exact( value, ( @@ -106,7 +107,7 @@ def validate_metadata(value): "capacity", "runtime_state", "allowed_actions", - ), + ) + optional, ) identifier(value["binding_id"]) integer(value["binding_epoch"]) @@ -115,6 +116,8 @@ def validate_metadata(value): 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"] diff --git a/src/agent/inventory.py b/src/agent/inventory.py index d1ad5fb5..eecf65fc 100644 --- a/src/agent/inventory.py +++ b/src/agent/inventory.py @@ -49,6 +49,7 @@ def read_local(tag): return { "tag": tag, "game_id": game, "name": name, "port": number(data.get("queryport"), 1, 65535) or number(data.get("port"), 1, 65535), + "connect_port": 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")}, diff --git a/src/agent/onboarding.py b/src/agent/onboarding.py index 6db08144..3ae82329 100644 --- a/src/agent/onboarding.py +++ b/src/agent/onboarding.py @@ -15,6 +15,7 @@ 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 def enroll(cfg, api_origin, transport): @@ -78,14 +79,17 @@ def candidate(local, value, options, suggest=suggest_public_host): address = options.host if options.host is not None else approved.get('host') if address is None: address = suggest(local) - port = options.port if options.port is not None else approved.get('port', local['port']) + 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) + "]: ").strip() + 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, "port": port, + 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 @@ -112,6 +116,8 @@ 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: @@ -127,8 +133,8 @@ def guided(cfg, options, transport): locals_, failures = inventory() else: locals_, failures = [read_local(options.target)], [] - if bulk and options.port is not None and len(locals_) > 1: - raise ConfigError("Use per-server public endpoint prompts or bind one tag when overriding --port") + 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_: @@ -149,7 +155,8 @@ def suggest(local): candidates = [] for local in locals_: print("Local server: " + local['tag'] + " | " + local['name'] + " | " + local['game_id'] + - " | port=" + str(local['port']) + " | capacity=" + str(local['capacity'])) + " | 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')) @@ -161,6 +168,7 @@ def suggest(local): 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') + diff --git a/src/agent/preflight.py b/src/agent/preflight.py index 66735975..3bdb5cae 100644 --- a/src/agent/preflight.py +++ b/src/agent/preflight.py @@ -54,7 +54,8 @@ 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(binding['metadata'][key] != item[key] for key in ('game_id', 'host', 'port'))): + 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") diff --git a/tests/smoke_tests/README.md b/tests/smoke_tests/README.md index 300bb26a..5ea641cc 100644 --- a/tests/smoke_tests/README.md +++ b/tests/smoke_tests/README.md @@ -19,6 +19,8 @@ Current smoke tests: 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. diff --git a/tests/smoke_tests/run_sector_alpha_agent.sh b/tests/smoke_tests/run_sector_alpha_agent.sh index 72296aeb..bf2e6214 100755 --- a/tests/smoke_tests/run_sector_alpha_agent.sh +++ b/tests/smoke_tests/run_sector_alpha_agent.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Offline trusted agent-API origin upgrade, hostname preflight, consent and no-replay gates. +# Offline approved player/query ports, API origin upgrade, hostname preflight and consent gates. set -euo pipefail cd "$(dirname "$0")/../.." PYTHONPATH=.:src "${PYTHON_BIN:-python3}" -m pytest \ 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..7ffe3a1f --- /dev/null +++ b/tests/unit_tests/agent/test_connection_ports.py @@ -0,0 +1,230 @@ +"""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 diff --git a/tests/unit_tests/agent/test_discovery.py b/tests/unit_tests/agent/test_discovery.py index 9ccb579a..8f911e7a 100644 --- a/tests/unit_tests/agent/test_discovery.py +++ b/tests/unit_tests/agent/test_discovery.py @@ -30,7 +30,7 @@ def test_inventory_excludes_secrets_links_invalid_and_cross_user(tmp_path, monke 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', 'capacity', 'publicip', 'externalip', 'hostip'} + 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) diff --git a/tests/unit_tests/agent/test_onboarding.py b/tests/unit_tests/agent/test_onboarding.py index c148aa0f..e00599b5 100644 --- a/tests/unit_tests/agent/test_onboarding.py +++ b/tests/unit_tests/agent/test_onboarding.py @@ -83,7 +83,7 @@ def test_guided_bind_infers_metadata_and_registers_only_scoped_hash(tmp_path, lo 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, - name='Local world', capacity=20)) + 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()}})] @@ -93,7 +93,8 @@ def test_guided_bind_infers_metadata_and_registers_only_scoped_hash(tmp_path, lo 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, name='Approved', capacity=30)) + 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 @@ -136,6 +137,7 @@ def test_setup_enrolls_once_then_binds_all_private_without_starting(tmp_path, lo 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'] From a6ce19716e7a1486ad5ba8752da0c766ea294373 Mon Sep 17 00:00:00 2001 From: cosmosquark Date: Thu, 15 Oct 2026 15:36:03 +0100 Subject: [PATCH 10/22] Do not infer player listeners from administration or query-only ports --- changelog.txt | 3 +++ docs/sector-alpha-agent.md | 7 +++++++ src/agent/inventory.py | 8 +++++++- tests/smoke_tests/run_sector_alpha_agent.sh | 2 +- .../unit_tests/agent/test_connection_ports.py | 19 +++++++++++++++++++ 5 files changed, 37 insertions(+), 2 deletions(-) diff --git a/changelog.txt b/changelog.txt index 9cfe6cb1..559988af 100644 --- a/changelog.txt +++ b/changelog.txt @@ -8,6 +8,9 @@ Historical detail lives in git history. Keep this file focused on release-facing high-level milestones. 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. diff --git a/docs/sector-alpha-agent.md b/docs/sector-alpha-agent.md index 134ef13f..bd27c6c3 100644 --- a/docs/sector-alpha-agent.md +++ b/docs/sector-alpha-agent.md @@ -55,6 +55,13 @@ 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 diff --git a/src/agent/inventory.py b/src/agent/inventory.py index eecf65fc..3bb89af5 100644 --- a/src/agent/inventory.py +++ b/src/agent/inventory.py @@ -10,6 +10,11 @@ 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): @@ -49,7 +54,8 @@ def read_local(tag): return { "tag": tag, "game_id": game, "name": name, "port": number(data.get("queryport"), 1, 65535) or number(data.get("port"), 1, 65535), - "connect_port": 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")}, diff --git a/tests/smoke_tests/run_sector_alpha_agent.sh b/tests/smoke_tests/run_sector_alpha_agent.sh index bf2e6214..688eda39 100755 --- a/tests/smoke_tests/run_sector_alpha_agent.sh +++ b/tests/smoke_tests/run_sector_alpha_agent.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Offline approved player/query ports, API origin upgrade, hostname preflight and consent gates. +# Offline non-player-port rejection, approved player/query ports, API origin upgrade, hostname preflight and consent gates. set -euo pipefail cd "$(dirname "$0")/../.." PYTHONPATH=.:src "${PYTHON_BIN:-python3}" -m pytest \ diff --git a/tests/unit_tests/agent/test_connection_ports.py b/tests/unit_tests/agent/test_connection_ports.py index 7ffe3a1f..8fc45c2c 100644 --- a/tests/unit_tests/agent/test_connection_ports.py +++ b/tests/unit_tests/agent/test_connection_ports.py @@ -228,3 +228,22 @@ def resolve(host, timeout): 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 From ae31a674f918717ea82be7ae0c7b0c58c7b6f7fd Mon Sep 17 00:00:00 2001 From: cosmosquark Date: Fri, 16 Oct 2026 22:14:47 +0100 Subject: [PATCH 11/22] Add skippable first-run setup tour --- DEVELOPERS.md | 36 ++++ README.md | 7 + changelog.txt | 9 + docs/README.md | 1 + docs/commands.md | 10 + docs/first-run-setup.md | 62 ++++++ docs/sector-alpha-agent.md | 5 + scripts/ci_game_test_routing.py | 2 + src/core/main.py | 26 +-- src/core/server_names.py | 19 ++ src/core/welcome.py | 159 ++++++++++++++ src/core/welcome_state.py | 47 +++++ .../integration_tests/test_first_run_tour.py | 150 ++++++++++++++ tests/smoke_tests/run_first_run_tour.sh | 6 + tests/unit_tests/core/test_welcome.py | 194 ++++++++++++++++++ .../test_integration_test_contract_static.py | 3 +- 16 files changed, 719 insertions(+), 17 deletions(-) create mode 100644 docs/first-run-setup.md create mode 100644 src/core/server_names.py create mode 100644 src/core/welcome.py create mode 100644 src/core/welcome_state.py create mode 100644 tests/integration_tests/test_first_run_tour.py create mode 100755 tests/smoke_tests/run_first_run_tour.sh create mode 100644 tests/unit_tests/core/test_welcome.py diff --git a/DEVELOPERS.md b/DEVELOPERS.md index f7f3c473..b8a53ee1 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: diff --git a/README.md b/README.md index a205e234..066ebfd2 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). + You can also ask an AI agent with terminal access to do this work. Start with “Use alphagsm-agent-operations to run my server,” and give it the game, server name and desired result. See [Run AlphaGSM With An AI Agent](docs/agent-operations.md) diff --git a/changelog.txt b/changelog.txt index 559988af..b3361138 100644 --- a/changelog.txt +++ b/changelog.txt @@ -7,6 +7,15 @@ 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-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. + 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 diff --git a/docs/README.md b/docs/README.md index f23bf9db..6f723534 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 84404c96..9aa13ce9 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -7,6 +7,16 @@ 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. 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..e33a5b3c --- /dev/null +++ b/docs/first-run-setup.md @@ -0,0 +1,62 @@ +# 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. It contacts `https://gameserver-api.sector-alpha.net`; + obtain the enrollment code from your account at `https://sector-alpha.net`. + That wizard previews the servers/endpoints and asks for binding permissions. + 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. 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 index bd27c6c3..cd5af296 100644 --- a/docs/sector-alpha-agent.md +++ b/docs/sector-alpha-agent.md @@ -6,6 +6,11 @@ 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: 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/src/core/main.py b/src/core/main.py index 7cfbb4ca..5b948e36 100644 --- a/src/core/main.py +++ b/src/core/main.py @@ -11,6 +11,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 @@ -58,6 +59,12 @@ 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:]) @@ -94,23 +101,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", - "agent", - ) - + 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", @@ -704,6 +696,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(""" diff --git a/src/core/server_names.py b/src/core/server_names.py new file mode 100644 index 00000000..51934450 --- /dev/null +++ b/src/core/server_names.py @@ -0,0 +1,19 @@ +"""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", + "agent", + ) + + Server.default_commands +) diff --git a/src/core/welcome.py b/src/core/welcome.py new file mode 100644 index 00000000..be6347ee --- /dev/null +++ b/src/core/welcome.py @@ -0,0 +1,159 @@ +"""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: enrollment and binding contact') + print('https://gameserver-api.sector-alpha.net; get an enrollment code from https://sector-alpha.net.') + print('The existing wizard previews servers/endpoints and asks for binding permissions.') + print('This tour never enables reporting or management. Existing choices are preserved.') + print('After binding, 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/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/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/unit_tests/core/test_welcome.py b/tests/unit_tests/core/test_welcome.py new file mode 100644 index 00000000..18ab0fe3 --- /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', '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/test_integration_test_contract_static.py b/tests/unit_tests/test_integration_test_contract_static.py index 89eea059..7ba9cff6 100644 --- a/tests/unit_tests/test_integration_test_contract_static.py +++ b/tests/unit_tests/test_integration_test_contract_static.py @@ -10,7 +10,8 @@ INTEGRATION_TEST_DIR = Path("tests/integration_tests") DISABLED_SERVERS_PATH = Path("disabled_servers.conf") -SPECIAL_CASES = {"test_archive_backed_installs.py"} +# Integration checks that do not exercise a game-server runtime. +SPECIAL_CASES = {"test_archive_backed_installs.py", "test_first_run_tour.py"} SS14_INTEGRATION_TEST = INTEGRATION_TEST_DIR / "test_ss14server.py" RUNTIME_AWARE_PROTOCOL_TESTS = { "test_arksurvivalascended.py": "source_rcon", From 175dbaa35910247b20b672067cd1759dbac24802 Mon Sep 17 00:00:00 2001 From: cosmosquark Date: Sat, 17 Oct 2026 23:12:44 +0100 Subject: [PATCH 12/22] Group Sector Alpha changelog notes with the Workshop history Keep each date together after rebasing onto the Workshop release notes. --- changelog.txt | 46 ++++++++++++++++++++-------------------------- 1 file changed, 20 insertions(+), 26 deletions(-) diff --git a/changelog.txt b/changelog.txt index b3361138..e639addc 100644 --- a/changelog.txt +++ b/changelog.txt @@ -15,6 +15,11 @@ Historical detail lives in git history. Keep this file focused on release-facing 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. +- CI: replay complete native smoke configuration blocks in the unit contract, + preserving multiline writes and excluding runtime JSON output. +- CS2 CI: apply empty-server rules after native competitive defaults and require + live A2S readiness directly instead of waiting for Source 1 log markers. + 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 @@ -52,32 +57,6 @@ Historical detail lives in git history. Keep this file focused on release-facing 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. - -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. - -2026-10-16 -- CI: replay complete native smoke configuration blocks in the unit contract, - preserving multiline writes and excluding runtime JSON output. -- CS2 CI: apply empty-server rules after native competitive defaults and require - live A2S readiness directly instead of waiting for Source 1 log markers. - Smoke and integration still require the selected map and zero players/bots. - -2026-10-15 - 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. @@ -186,6 +165,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. From 4474aedc18c8c733f7ff4d87b806d77174b23425 Mon Sep 17 00:00:00 2001 From: cosmosquark Date: Sun, 18 Oct 2026 08:08:52 +0100 Subject: [PATCH 13/22] Separate website onboarding from game-server execution API --- DEVELOPERS.md | 28 +++++++++------- README.md | 10 +++--- changelog.txt | 8 +++++ docs/first-run-setup.md | 3 +- docs/sector-alpha-agent.md | 29 ++++++++++------- src/agent/config.py | 1 + src/agent/transport.py | 8 +++-- src/core/welcome.py | 3 +- tests/smoke_tests/README.md | 2 +- tests/smoke_tests/run_sector_alpha_agent.sh | 2 +- tests/unit_tests/agent/test_origins.py | 36 +++++++++++++++++++-- 11 files changed, 93 insertions(+), 37 deletions(-) diff --git a/DEVELOPERS.md b/DEVELOPERS.md index b8a53ee1..bf2bd81f 100644 --- a/DEVELOPERS.md +++ b/DEVELOPERS.md @@ -1183,17 +1183,23 @@ final online authorization and persists executing before lifecycle effects. reconciles unknown and never repeats effects. A global foreground-service lock prevents competing pollers. No secrets are accepted as CLI arguments. -The single default agent origin is `https://gameserver-api.sector-alpha.net` for -enrollment, registration, reports and every management route. Public website, -account and directory traffic remains at `https://sector-alpha.net`. -`canonical_agent_origin()` applies an exact trusted local authority mapping from -the old official HTTPS website origin (default/443 port, case and root-slash -variants) before enrollment-code or credential submission. It does not follow -network redirects or use domain suffix matching. `Config.load()` returns this -normalized origin for existing installations; the next ordinary write persists -it without changing IDs, keys, bindings or policy. Restart existing agent -processes after upgrading. Explicit custom origins, including non-default ports, -remain unchanged. There is one stored origin and one transport for all scopes. +The official provider uses `https://sector-alpha.net` for only agent enrollment +and binding registration, while `https://gameserver-api.sector-alpha.net` receives +POST stats and carries the outbound execution protocol (policy acknowledgement, +command polling, final authorization and results). Owners queue commands and view +data on the website. `Transport` selects the official website only for its two +approved onboarding paths. It retains one canonical API provider origin in local +configuration and derives the fixed website authority in memory, without redirects +or endpoint discovery. The same verified TLS/no-proxy/no-redirect transport applies +to both hosts. 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. Stage website onboarding routes +before client upgrades; restrict the machine API after clients are updated. 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 diff --git a/README.md b/README.md index 066ebfd2..1ad5301c 100644 --- a/README.md +++ b/README.md @@ -554,11 +554,11 @@ alphagsm agent enroll alphagsm agent bind '*' ``` -Enrollment, binding registration, reports and management use -`https://gameserver-api.sector-alpha.net`. Public accounts and the directory stay -at `https://sector-alpha.net`. Existing official connections normalize to the API -origin on upgrade, preserving IDs, credentials and permissions; restart the agent -after updating. Custom provider origins stay unchanged. +Enrollment, binding registration, accounts and the directory use +`https://sector-alpha.net`. Stats and outbound remote command delivery/results use +`https://gameserver-api.sector-alpha.net`. Updated clients select the correct +host locally without redirects. Existing IDs, keys and permissions are preserved; +restart the agent after updating. Custom providers keep their configured origin. The quoted `'*'` binds all eligible local servers, inferring their settings and registering website IDs diff --git a/changelog.txt b/changelog.txt index e639addc..1cc920c4 100644 --- a/changelog.txt +++ b/changelog.txt @@ -7,6 +7,14 @@ 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-10-18 +- 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-16 - First run: offer a skippable setup tour on bare interactive launches, with --welcome to replay and --skip-welcome for noninteractive opt-out. diff --git a/docs/first-run-setup.md b/docs/first-run-setup.md index e33a5b3c..feb1c40d 100644 --- a/docs/first-run-setup.md +++ b/docs/first-run-setup.md @@ -36,7 +36,8 @@ status of 2. 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. It contacts `https://gameserver-api.sector-alpha.net`; + enrollment/binding wizard. It contacts `https://sector-alpha.net` for onboarding; + later stats and execution use `https://gameserver-api.sector-alpha.net`; obtain the enrollment code from your account at `https://sector-alpha.net`. That wizard previews the servers/endpoints and asks for binding permissions. See [Optional Sector Alpha connection](sector-alpha-agent.md). diff --git a/docs/sector-alpha-agent.md b/docs/sector-alpha-agent.md index cd5af296..3327deeb 100644 --- a/docs/sector-alpha-agent.md +++ b/docs/sector-alpha-agent.md @@ -20,20 +20,25 @@ alphagsm agent enroll alphagsm agent bind '*' ``` -Enter the enrollment code at its hidden prompt. Enrollment, binding registration, -reporting and remote management all use the single default API origin -`https://gameserver-api.sector-alpha.net`. Public accounts and the server directory -remain at `https://sector-alpha.net`. Use `agent enroll --origin -https://your-site.example` for another compatible service. Never put codes or -credentials in shell arguments. +Enter the enrollment code at its hidden prompt. Enrollment and binding +registration use `https://sector-alpha.net`. POSTed stats and the outbound +remote-execution channel use `https://gameserver-api.sector-alpha.net`. Owners +request actions on the website; the agent polls for permitted jobs and posts +results to the API. 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` for another compatible +service. Custom providers keep their exact single configured origin. Never put +codes or credentials in shell arguments. The official two-host selection happens +locally by approved route, without redirects or fallback to another host. After upgrading AlphaGSM, restart the foreground agent (or its user service). -Existing connections using the old official `https://sector-alpha.net` origin -normalize locally to the API origin, including equivalent host casing, `:443` -and a root trailing slash. Enrollment IDs, binding IDs, credentials and local -permissions are preserved. This requires no network redirect or re-enrollment; -the normalized origin is saved on the next ordinary configuration write. -Explicitly configured custom provider origins remain unchanged. +Existing official connections keep their stored API provider origin, IDs, +credentials, bindings and local permissions. Old official website-origin aliases +still normalize locally; onboarding requests now select the website separately. +No re-enrollment is needed. Deploy the website onboarding routes before upgrading +clients, then restrict the API routes. Older clients cannot enroll/bind through +the restricted API hostname. See the provider's deployment instructions. `agent bind '*'` discovers all eligible servers owned by your local AlphaGSM user, registers their website bindings, and saves the returned IDs automatically. Quote diff --git a/src/agent/config.py b/src/agent/config.py index 8030d4e5..22515572 100644 --- a/src/agent/config.py +++ b/src/agent/config.py @@ -10,6 +10,7 @@ from .contracts import ACTIONS, identifier, integer, validate_metadata, exact DEFAULT_ORIGIN = "https://gameserver-api.sector-alpha.net" +CONTROL_ORIGIN = "https://sector-alpha.net" class ConfigError(ValueError): diff --git a/src/agent/transport.py b/src/agent/transport.py index 37b15afa..fcc025dd 100644 --- a/src/agent/transport.py +++ b/src/agent/transport.py @@ -8,7 +8,7 @@ import threading import urllib.error import urllib.request -from .config import canonical_agent_origin +from .config import CONTROL_ORIGIN, DEFAULT_ORIGIN, canonical_agent_origin from .http_deadline import DeadlineBudget, DeadlineHTTPSHandler, bounded_exchange REPORT_TIMEOUT = 10 @@ -26,6 +26,7 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): class Transport: def __init__(self, api_origin, *, context=None): self.origin = canonical_agent_origin(api_origin) + self.control_origin = CONTROL_ORIGIN if self.origin == DEFAULT_ORIGIN else self.origin self.report_lock = threading.Lock() self.opener = urllib.request.build_opener( urllib.request.ProxyHandler({}), @@ -59,7 +60,10 @@ def request(self, method, path, *, credential=None, payload=None, session_id=Non headers["Authorization"] = "Bearer " + credential["key_id"] + "." + credential["secret"] if session_id is not None: headers["X-Agent-Session"] = session_id - request = urllib.request.Request(self.origin + path, method=method, data=data, headers=headers) + # Official onboarding stays on the website; reports and command execution + # use the machine API. Custom providers retain their exact configured origin. + destination = self.control_origin if path in ("/v1/agent/enroll", "/v1/agent/bindings") else self.origin + request = urllib.request.Request(destination + path, method=method, data=data, headers=headers) try: if method == "POST" and path == "/v1/agent/reports": return self._report_request(request, min(timeout, REPORT_TIMEOUT)) diff --git a/src/core/welcome.py b/src/core/welcome.py index be6347ee..8ccc6e15 100644 --- a/src/core/welcome.py +++ b/src/core/welcome.py @@ -92,7 +92,8 @@ def _create(name, execute): def _connect(name, execute): print('\nOptional Sector Alpha connection: enrollment and binding contact') - print('https://gameserver-api.sector-alpha.net; get an enrollment code from https://sector-alpha.net.') + print('https://sector-alpha.net; get your enrollment code there too.') + print('Later stats and remote execution use https://gameserver-api.sector-alpha.net.') print('The existing wizard previews servers/endpoints and asks for binding permissions.') print('This tour never enables reporting or management. Existing choices are preserved.') print('After binding, reporting needs a separate explicit agent enable-reporting command.') diff --git a/tests/smoke_tests/README.md b/tests/smoke_tests/README.md index 5ea641cc..5c6972a4 100644 --- a/tests/smoke_tests/README.md +++ b/tests/smoke_tests/README.md @@ -9,7 +9,7 @@ directly to the terminal or CI log as they run. Current smoke tests: - `run_sector_alpha_agent.sh` - Exercises the single `gameserver-api.sector-alpha.net` agent origin, trusted + 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 diff --git a/tests/smoke_tests/run_sector_alpha_agent.sh b/tests/smoke_tests/run_sector_alpha_agent.sh index 688eda39..1ca7fd63 100755 --- a/tests/smoke_tests/run_sector_alpha_agent.sh +++ b/tests/smoke_tests/run_sector_alpha_agent.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Offline non-player-port rejection, approved player/query ports, API origin upgrade, hostname preflight and consent gates. +# Offline non-player-port rejection, approved player/query ports, website onboarding/API execution routing, hostname preflight and consent gates. set -euo pipefail cd "$(dirname "$0")/../.." PYTHONPATH=.:src "${PYTHON_BIN:-python3}" -m pytest \ diff --git a/tests/unit_tests/agent/test_origins.py b/tests/unit_tests/agent/test_origins.py index 650e377c..7cfc44bd 100644 --- a/tests/unit_tests/agent/test_origins.py +++ b/tests/unit_tests/agent/test_origins.py @@ -1,4 +1,4 @@ -"""The official agent API upgrade is a local exact alias, never a redirect.""" +"""Official onboarding and execution origins are selected locally, never by redirect.""" from contextlib import nullcontext import importlib import json @@ -52,7 +52,7 @@ def test_legacy_official_origin_is_normalized_locally_without_changing_relations @pytest.mark.parametrize('old', ALIASES) -def test_enroll_rewrites_known_alias_before_enrollment_code_submission(tmp_path, provider, old): +def test_enroll_normalizes_provider_before_transport_submission(tmp_path, provider, old): 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 @@ -104,7 +104,7 @@ def request(self, method, path, **kwargs): assert cfg.load()['bindings'] == before['bindings'] and cfg.load()['policy_version'] == before['policy_version'] -def test_legacy_official_registration_uses_api_and_keeps_existing_bindings(tmp_path, local_servers, provider, monkeypatch): +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']) @@ -116,3 +116,33 @@ def test_legacy_official_registration_uses_api_and_keeps_existing_bindings(tmp_p def test_direct_transport_canonicalizes_old_official_origin_before_credentials(): assert Transport('https://SECTOR-ALPHA.NET:443/').origin == API + + +@pytest.mark.parametrize('provider,control,execution', [ + *[(alias, 'https://sector-alpha.net', API) for alias in [API, API + ':443/', *ALIASES]], + ('https://control.example', 'https://control.example', 'https://control.example'), + ('https://sector-alpha.net:8443', 'https://sector-alpha.net:8443', 'https://sector-alpha.net:8443'), + ('https://gameserver-api.sector-alpha.net.attacker.example', + 'https://gameserver-api.sector-alpha.net.attacker.example', + 'https://gameserver-api.sector-alpha.net.attacker.example'), +]) +def test_transport_routes_official_onboarding_to_website_and_execution_to_api(monkeypatch, provider, control, execution): + 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, destination in ( + ('POST', '/v1/agent/enroll', control), + ('POST', '/v1/agent/bindings', control), + ('POST', '/v1/agent/reports', execution), + ('POST', '/v1/agent/policy', execution), + ('GET', '/v1/agent/jobs', execution), + ('POST', '/v1/agent/jobs/job-1/authorize', execution), + ('POST', '/v1/agent/results', execution), + ): + transport.request(method, path, credential=credential) + assert sent[-1] == (destination + path, method, 'Bearer test-key.' + 's' * 43) + assert transport.origin == execution From aa8e29e24f8b348a6ce154c590e84c330f9c2d06 Mon Sep 17 00:00:00 2001 From: cosmosquark Date: Sun, 18 Oct 2026 20:51:50 +0100 Subject: [PATCH 14/22] Add TeamSpeak MariaDB mode and the alphagsm-api origin. An empty db_host starts a private MariaDB 11.5 container and points LD_LIBRARY_PATH at TeamSpeak's bundled client library. Official agent stats and remote execution use alphagsm-api.sector-alpha.net. --- DEVELOPERS.md | 2 +- README.md | 2 +- changelog.txt | 7 + docker/service-console/Dockerfile | 1 + docs/first-run-setup.md | 2 +- docs/sector-alpha-agent.md | 2 +- docs/servers/ts3server.md | 12 + src/agent/config.py | 4 +- src/core/welcome.py | 2 +- src/gamemodules/ts3server/main.py | 237 +++++++++++++++++- tests/unit_tests/agent/test_onboarding.py | 6 +- tests/unit_tests/agent/test_origins.py | 14 +- .../gamemodules/test_ts3server_module.py | 44 ++++ 13 files changed, 306 insertions(+), 29 deletions(-) diff --git a/DEVELOPERS.md b/DEVELOPERS.md index bf2bd81f..46dc8bc8 100644 --- a/DEVELOPERS.md +++ b/DEVELOPERS.md @@ -1184,7 +1184,7 @@ reconciles unknown and never repeats effects. A global foreground-service lock prevents competing pollers. No secrets are accepted as CLI arguments. The official provider uses `https://sector-alpha.net` for only agent enrollment -and binding registration, while `https://gameserver-api.sector-alpha.net` receives +and binding registration, while `https://alphagsm-api.sector-alpha.net` receives POST stats and carries the outbound execution protocol (policy acknowledgement, command polling, final authorization and results). Owners queue commands and view data on the website. `Transport` selects the official website only for its two diff --git a/README.md b/README.md index 1ad5301c..352f8bc6 100644 --- a/README.md +++ b/README.md @@ -556,7 +556,7 @@ alphagsm agent bind '*' Enrollment, binding registration, accounts and the directory use `https://sector-alpha.net`. Stats and outbound remote command delivery/results use -`https://gameserver-api.sector-alpha.net`. Updated clients select the correct +`https://alphagsm-api.sector-alpha.net`. Updated clients select the correct host locally without redirects. Existing IDs, keys and permissions are preserved; restart the agent after updating. Custom providers keep their configured origin. diff --git a/changelog.txt b/changelog.txt index 1cc920c4..9074d9d4 100644 --- a/changelog.txt +++ b/changelog.txt @@ -8,6 +8,13 @@ Historical detail lives in git history. Keep this file focused on release-facing high-level milestones. 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 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/first-run-setup.md b/docs/first-run-setup.md index feb1c40d..26bbc0e6 100644 --- a/docs/first-run-setup.md +++ b/docs/first-run-setup.md @@ -37,7 +37,7 @@ status of 2. running any commands. See [Everyday Commands](commands.md) for more details. 3. **Optional Sector Alpha connection.** Confirm before opening the existing enrollment/binding wizard. It contacts `https://sector-alpha.net` for onboarding; - later stats and execution use `https://gameserver-api.sector-alpha.net`; + later stats and execution use `https://alphagsm-api.sector-alpha.net`; obtain the enrollment code from your account at `https://sector-alpha.net`. That wizard previews the servers/endpoints and asks for binding permissions. See [Optional Sector Alpha connection](sector-alpha-agent.md). diff --git a/docs/sector-alpha-agent.md b/docs/sector-alpha-agent.md index 3327deeb..ba3742c0 100644 --- a/docs/sector-alpha-agent.md +++ b/docs/sector-alpha-agent.md @@ -22,7 +22,7 @@ alphagsm agent bind '*' Enter the enrollment code at its hidden prompt. Enrollment and binding registration use `https://sector-alpha.net`. POSTed stats and the outbound -remote-execution channel use `https://gameserver-api.sector-alpha.net`. Owners +remote-execution channel use `https://alphagsm-api.sector-alpha.net`. Owners request actions on the website; the agent polls for permitted jobs and posts results to the API. No inbound management listener is opened on your machine. The website also serves public accounts and the directory. 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/src/agent/config.py b/src/agent/config.py index 22515572..86b881e7 100644 --- a/src/agent/config.py +++ b/src/agent/config.py @@ -9,7 +9,7 @@ from utils.state_io import atomic_write_text, state_lock from .contracts import ACTIONS, identifier, integer, validate_metadata, exact -DEFAULT_ORIGIN = "https://gameserver-api.sector-alpha.net" +DEFAULT_ORIGIN = "https://alphagsm-api.sector-alpha.net" CONTROL_ORIGIN = "https://sector-alpha.net" @@ -41,7 +41,7 @@ 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', 'gameserver-api.sector-alpha.net') and parsed.port in (None, 443): + if parsed.hostname in ('sector-alpha.net', 'alphagsm-api.sector-alpha.net') and parsed.port in (None, 443): return DEFAULT_ORIGIN return selected diff --git a/src/core/welcome.py b/src/core/welcome.py index 8ccc6e15..9fc82950 100644 --- a/src/core/welcome.py +++ b/src/core/welcome.py @@ -93,7 +93,7 @@ def _create(name, execute): def _connect(name, execute): print('\nOptional Sector Alpha connection: enrollment and binding contact') print('https://sector-alpha.net; get your enrollment code there too.') - print('Later stats and remote execution use https://gameserver-api.sector-alpha.net.') + print('Later stats and remote execution use https://alphagsm-api.sector-alpha.net.') print('The existing wizard previews servers/endpoints and asks for binding permissions.') print('This tour never enables reporting or management. Existing choices are preserved.') print('After binding, reporting needs a separate explicit agent enable-reporting command.') 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/tests/unit_tests/agent/test_onboarding.py b/tests/unit_tests/agent/test_onboarding.py index e00599b5..1b29e622 100644 --- a/tests/unit_tests/agent/test_onboarding.py +++ b/tests/unit_tests/agent/test_onboarding.py @@ -65,14 +65,14 @@ def test_root_origin_slash_is_normalized_in_storage_and_transport(tmp_path): 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://gameserver-api.sector-alpha.net' - assert Transport('https://sector-alpha.net/').origin == 'https://gameserver-api.sector-alpha.net' + 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): monkeypatch.setattr('builtins.input', lambda prompt: pytest.fail('unexpected visible prompt')) assert cli.run(['enroll'], root=tmp_path / 'agent') == 0 - assert provider == [('https://gameserver-api.sector-alpha.net', 'POST', '/v1/agent/enroll', {'payload': {'code': 'hidden-code'}})] + assert provider == [('https://alphagsm-api.sector-alpha.net', 'POST', '/v1/agent/enroll', {'payload': {'code': 'hidden-code'}})] assert not Config(tmp_path / 'agent').load()['management_enabled'] diff --git a/tests/unit_tests/agent/test_origins.py b/tests/unit_tests/agent/test_origins.py index 7cfc44bd..109f5567 100644 --- a/tests/unit_tests/agent/test_origins.py +++ b/tests/unit_tests/agent/test_origins.py @@ -13,9 +13,10 @@ import agent.service as service_module from test_onboarding import answers, local_servers, provider -API = 'https://gameserver-api.sector-alpha.net' +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://SECTOR-ALPHA.NET:443/', 'HTTPS://Sector-Alpha.Net/', + 'https://ALPHAGSM-API.SECTOR-ALPHA.NET:443/'] def enrollment(): @@ -60,7 +61,8 @@ def test_enroll_normalizes_provider_before_transport_submission(tmp_path, provid @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://gameserver-api.sector-alpha.net.attacker.example', 'https://sector-alpha%2Enet']) + '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) @@ -122,9 +124,9 @@ def test_direct_transport_canonicalizes_old_official_origin_before_credentials() *[(alias, 'https://sector-alpha.net', API) for alias in [API, API + ':443/', *ALIASES]], ('https://control.example', 'https://control.example', 'https://control.example'), ('https://sector-alpha.net:8443', 'https://sector-alpha.net:8443', 'https://sector-alpha.net:8443'), - ('https://gameserver-api.sector-alpha.net.attacker.example', - 'https://gameserver-api.sector-alpha.net.attacker.example', - 'https://gameserver-api.sector-alpha.net.attacker.example'), + ('https://alphagsm-api.sector-alpha.net.attacker.example', + 'https://alphagsm-api.sector-alpha.net.attacker.example', + 'https://alphagsm-api.sector-alpha.net.attacker.example'), ]) def test_transport_routes_official_onboarding_to_website_and_execution_to_api(monkeypatch, provider, control, execution): transport = Transport(provider) 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) From 02ab9efab3024f6f4c86e09d8076ae9c60c4ce5e Mon Sep 17 00:00:00 2001 From: cosmosquark Date: Mon, 19 Oct 2026 19:29:34 +0100 Subject: [PATCH 15/22] Query anonymous status protocols locally and prove TeamSpeak names. alphagsm agent query reads the same anonymous status protocols as the website and does not upload the result. alphagsm agent prove places a website challenge in the live TeamSpeak name and restores it. Agent publication stays Minecraft Java, Minecraft Paper, and Team Fortress 2. TeamSpeak directory listing remains a website choice. --- changelog.txt | 18 + docs/sector-alpha-agent.md | 25 +- skills/server-info-gathering/SKILL.md | 2 +- src/agent/choices.py | 10 + src/agent/cli.py | 102 ++++- src/agent/config.py | 11 +- src/agent/endpoint_check.py | 14 +- src/agent/local_query.py | 63 +++ src/agent/prove.py | 84 ++++ src/agent/transport.py | 22 ++ src/server/public_protocols.py | 370 ++++++++++++++++++ src/server/public_query.py | 71 +++- src/utils/query.py | 70 +++- tests/integration_tests/test_ts3server.py | 7 +- tests/unit_tests/agent/test_agent.py | 31 ++ tests/unit_tests/agent/test_local_query.py | 71 ++++ tests/unit_tests/agent/test_prove.py | 118 ++++++ .../server/test_public_protocols.py | 38 ++ tests/unit_tests/server/test_public_query.py | 19 + tests/unit_tests/utils/test_ts3_set_name.py | 38 ++ .../unit_tests/utils/test_ts3_voice_count.py | 67 ++++ 21 files changed, 1219 insertions(+), 32 deletions(-) create mode 100644 src/agent/local_query.py create mode 100644 src/agent/prove.py create mode 100644 src/server/public_protocols.py create mode 100644 tests/unit_tests/agent/test_local_query.py create mode 100644 tests/unit_tests/agent/test_prove.py create mode 100644 tests/unit_tests/server/test_public_protocols.py create mode 100644 tests/unit_tests/utils/test_ts3_set_name.py create mode 100644 tests/unit_tests/utils/test_ts3_voice_count.py diff --git a/changelog.txt b/changelog.txt index 9074d9d4..d4c84d23 100644 --- a/changelog.txt +++ b/changelog.txt @@ -7,6 +7,24 @@ 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-10-19 +- 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 diff --git a/docs/sector-alpha-agent.md b/docs/sector-alpha-agent.md index ba3742c0..80fad2e0 100644 --- a/docs/sector-alpha-agent.md +++ b/docs/sector-alpha-agent.md @@ -101,12 +101,18 @@ 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 -native game protocol, using only matched numerical addresses. It does not change +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 @@ -169,6 +175,23 @@ the enrollment instructions above and run `alphagsm agent enroll` first. operation. 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 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/choices.py b/src/agent/choices.py index 6d6b8be7..f9b433c2 100644 --- a/src/agent/choices.py +++ b/src/agent/choices.py @@ -4,8 +4,18 @@ from .config import ConfigError from .contracts import ACTIONS +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." +) + 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: diff --git a/src/agent/cli.py b/src/agent/cli.py index 9262a5d0..579c78bf 100644 --- a/src/agent/cli.py +++ b/src/agent/cli.py @@ -6,11 +6,13 @@ from .config import Config, ConfigError, local_tag from .contracts import ACTIONS from .service import Service -from .transport import Transport, TransportError +from .transport import QueryBusy, Transport, TransportError 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 .binding_ports import selected_ports @@ -62,6 +64,10 @@ def run(args, *, root=None): 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: @@ -83,6 +89,10 @@ def run(args, *, root=None): 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": + from .choices import TEAMSPEAK_DIRECTORY + + 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. @@ -124,6 +134,16 @@ def run(args, *, root=None): 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")}) @@ -169,6 +189,86 @@ def run(args, *, root=None): return 130 +def _prove(cfg, tag): + """Prompt for the website token, show it on TeamSpeak, and restore the old name.""" + token = getpass.getpass("Ownership challenge: ").strip() + 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] + 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 QueryError, 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) + + transport = Transport(value["origin"]) + + def request(): + try: + return transport.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 transport.request( + "GET", + "/v1/agent/verify/" + job_id, + credential=value["reporting"], + timeout=20, + ) + + try: + result = prove.prove_name( + tag, + token, + pending_path=cfg.root / "prove-name.json", + read_name=read_name, + write_name=write_name, + request=request, + poll=poll, + ) + 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: diff --git a/src/agent/config.py b/src/agent/config.py index 86b881e7..5c220296 100644 --- a/src/agent/config.py +++ b/src/agent/config.py @@ -162,6 +162,10 @@ def bind(self, binding_id, tag, epoch, *, actions, publication, metadata, expect from server.public_query import PROFILES from .publication_config import local_publication + if publication is True and metadata.get("game_id") == "ts3server": + from .choices import TEAMSPEAK_DIRECTORY + + 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) @@ -237,7 +241,12 @@ def publication(self, binding_id, enabled): value = self.load() if binding_id not in value["bindings"]: raise ConfigError("Unknown local binding") - if enabled and value["bindings"][binding_id]["metadata"]["game_id"] not in PROFILES: + game_id = value["bindings"][binding_id]["metadata"]["game_id"] + if enabled and game_id == "ts3server": + from .choices import TEAMSPEAK_DIRECTORY + + 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() diff --git a/src/agent/endpoint_check.py b/src/agent/endpoint_check.py index bd77046c..53d11e80 100644 --- a/src/agent/endpoint_check.py +++ b/src/agent/endpoint_check.py @@ -3,7 +3,7 @@ import ipaddress import time -from server.public_query import PROFILES, query_public +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 @@ -17,9 +17,13 @@ def check_endpoint(binding, expected_public_ips=None, *, resolve=None, probe=Non """Probe only validated pinned machine IPs, retaining SLP's original host.""" metadata = binding['metadata'] game, host, port = metadata['game_id'], metadata['host'], metadata['port'] - if game not in PROFILES: + protocol = protocol_for(game) + if protocol is None: raise PreflightError("This game has no supported public query profile; private binding remains available") - if type(port) is not int or not 1024 <= port <= 65535: # pylint: disable=unidiomatic-typecheck + 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: @@ -36,8 +40,8 @@ def check_endpoint(binding, expected_public_ips=None, *, resolve=None, probe=Non if time.monotonic() >= dns_deadline: raise PreflightError("DNS lookup timed out; no endpoint connection was attempted") addresses = matched_addresses(answers, expected) - if PROFILES[game] == '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") + 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() diff --git a/src/agent/local_query.py b/src/agent/local_query.py new file mode 100644 index 00000000..a789e985 --- /dev/null +++ b/src/agent/local_query.py @@ -0,0 +1,63 @@ +"""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 type(players) is not int or type(capacity) is not int or players < 0 or capacity < 0: + raise QueryError("TeamSpeak returned invalid player counts") + if capacity < players: + capacity = players + return {"name": info["name"], "players": players, "capacity": capacity, "version": info["version"]} + return query_public(game_id, host, port, timeout=timeout) + + +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/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/transport.py b/src/agent/transport.py index fcc025dd..3b00bddf 100644 --- a/src/agent/transport.py +++ b/src/agent/transport.py @@ -18,6 +18,21 @@ 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.""" + + +_QUERY_BUSY = b'{"detail":"Request rejected","code":"query_busy"}' + + +def _busy_body(exc): + try: + body = exc.read(len(_QUERY_BUSY) + 1) + except OSError: + return False + return body == _QUERY_BUSY + + class NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): raise TransportError("Credential redirects are forbidden") @@ -42,11 +57,16 @@ def request(self, method, path, *, credential=None, payload=None, session_id=Non "/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") headers = {"Accept": "application/json"} @@ -69,6 +89,8 @@ def request(self, method, path, *, credential=None, payload=None, session_id=Non 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 raise TransportError("HTTPS request rejected (%s)" % exc.code) from None except (OSError, ValueError, http.client.HTTPException, RecursionError) as exc: if isinstance(exc, TransportError): diff --git a/src/server/public_protocols.py b/src/server/public_protocols.py new file mode 100644 index 00000000..ca70e883 --- /dev/null +++ b/src/server/public_protocols.py @@ -0,0 +1,370 @@ +"""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 = None + + +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(): + global _CACHE + if _CACHE is None: + _CACHE = _build() + return _CACHE + + +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 type(node.value) is int 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 index c3c93406..4636efe3 100644 --- a/src/server/public_query.py +++ b/src/server/public_query.py @@ -1,11 +1,18 @@ # pylint: disable=unidiomatic-typecheck -"""Reviewed public profiles; no Server instances, discovery, credentials or fallback.""" +"""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): @@ -14,9 +21,26 @@ def _bounded_text(value): 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.""" - if type(game_id) is not str or game_id not in PROFILES: + protocol = protocol_for(game_id) + if protocol not in _OBSERVED: raise query.QueryError("Unsupported public profile") try: ip = ipaddress.ip_address(address) @@ -26,26 +50,39 @@ def query_public(game_id, address, port, *, virtual_host=None, timeout=5.0): raise query.QueryError("Destination port outside public profile policy") if not 0 < timeout <= 5: raise query.QueryError("Invalid public query budget") - if PROFILES[game_id] == "a2s": - if ip.version != 4: + 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 = { - "name": _bounded_text(raw["name"]), - "map": _bounded_text(raw["map"]), - "players": raw["players"], - "capacity": raw["max_players"], - } - else: + 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 = { - "name": _bounded_text(raw["description"]), - "players": raw["players_online"], - "capacity": raw["players_max"], - "version": _bounded_text(raw["version"]), - } + 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") diff --git a/src/utils/query.py b/src/utils/query.py index 7a0ba72c..011d2c94 100644 --- a/src/utils/query.py +++ b/src/utils/query.py @@ -947,6 +947,65 @@ def _ts3_unescape(value): ) +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): """Connect to a TeamSpeak 3 ServerQuery interface and return server info. @@ -955,7 +1014,8 @@ 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) @@ -1059,9 +1119,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", ""), 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/unit_tests/agent/test_agent.py b/tests/unit_tests/agent/test_agent.py index 289e3320..e9bfb542 100644 --- a/tests/unit_tests/agent/test_agent.py +++ b/tests/unit_tests/agent/test_agent.py @@ -21,6 +21,37 @@ def config(tmp_path): 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 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_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/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 index aebe34b1..77757dc2 100644 --- a/tests/unit_tests/server/test_public_query.py +++ b/tests/unit_tests/server/test_public_query.py @@ -56,6 +56,25 @@ def test_public_registry_and_normalization(monkeypatch): PROFILES['custom'] = 'http' +def test_anonymous_protocols_share_one_observation_and_drop_private_fields(monkeypatch): + monkeypatch.setattr(query, "quake_status", lambda *a, **k: dict( + name="Arena", map="q3dm1", players=1, max_players=8, players_list=["hidden"])) + assert query_public("q3server", "8.8.8.8", 27960) == dict(name="Arena", map="q3dm1", players=1, capacity=8) + monkeypatch.setattr(query, "bedrock_info", lambda *a, **k: dict( + name="Stone", map="world", players_online=0, players_max=10, version="1.21", server_guid=9)) + assert query_public("minecraft.bedrock", "8.8.8.8", 19132) == dict( + name="Stone", map="world", players=0, capacity=10, version="1.21") + monkeypatch.setattr(query, "a2s_info", lambda *a, **k: b"raw") + monkeypatch.setattr(query, "parse_a2s_info", lambda raw: dict( + name="Dust", map="de_dust2", players=2, max_players=16, folder="csgo", game="CS", appid=730, bots=4)) + assert query_public("counterstrike2", "8.8.8.8", 27015) == dict( + name="Dust", map="de_dust2", players=2, capacity=16) + with pytest.raises(query.QueryError, match="IPv4"): + query_public("counterstrike2", "2001:4860:4860::8888", 27015) + with pytest.raises(query.QueryError, match="Unsupported public profile"): + query_public("valheim", "8.8.8.8", 2456) + + def test_invalid_counts_fail(monkeypatch): monkeypatch.setattr(query, 'slp_info', lambda *a, **kw: dict(description='', players_online=-1, players_max=20, version='')) 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", + ) From de3de1952f4beb6e4551e4774a99dc16da3d9962 Mon Sep 17 00:00:00 2001 From: cosmosquark Date: Mon, 19 Oct 2026 21:58:54 +0100 Subject: [PATCH 16/22] Keep one outbound WebSocket for Sector Alpha presence and job wakes. Heartbeats run about every 30 seconds. If the socket cannot open, the agent uses the existing HTTPS job poll. Authorize and result posts stay on HTTPS. --- DEVELOPERS.md | 10 +- changelog.txt | 5 + docs/sector-alpha-agent.md | 6 +- src/agent/service.py | 97 +++++++- src/agent/socket_channel.py | 239 ++++++++++++++++++ src/agent/transport.py | 24 +- tests/unit_tests/agent/test_connection.py | 287 ++++++++++++++++++++++ 7 files changed, 645 insertions(+), 23 deletions(-) create mode 100644 src/agent/socket_channel.py create mode 100644 tests/unit_tests/agent/test_connection.py diff --git a/DEVELOPERS.md b/DEVELOPERS.md index 46dc8bc8..fb02e7a2 100644 --- a/DEVELOPERS.md +++ b/DEVELOPERS.md @@ -1176,9 +1176,11 @@ 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 uses outbound HTTPS long polling, -rechecks exact local bindings and policy under the normal CLI lock, obtains -final online authorization and persists executing before lifecycle effects. +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. `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. @@ -1186,7 +1188,7 @@ prevents competing pollers. No secrets are accepted as CLI arguments. The official provider uses `https://sector-alpha.net` for only agent enrollment and binding registration, while `https://alphagsm-api.sector-alpha.net` receives POST stats and carries the outbound execution protocol (policy acknowledgement, -command polling, final authorization and results). Owners queue commands and view +the agent session socket, command polling, final authorization and results). Owners queue commands and view data on the website. `Transport` selects the official website only for its two approved onboarding paths. It retains one canonical API provider origin in local configuration and derives the fixed website authority in memory, without redirects diff --git a/changelog.txt b/changelog.txt index d4c84d23..51fa9e76 100644 --- a/changelog.txt +++ b/changelog.txt @@ -8,6 +8,11 @@ Historical detail lives in git history. Keep this file focused on release-facing high-level milestones. 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 diff --git a/docs/sector-alpha-agent.md b/docs/sector-alpha-agent.md index 80fad2e0..8ce0e4a5 100644 --- a/docs/sector-alpha-agent.md +++ b/docs/sector-alpha-agent.md @@ -23,8 +23,10 @@ alphagsm agent bind '*' Enter the enrollment code at its hidden prompt. Enrollment and binding registration use `https://sector-alpha.net`. POSTed stats and the outbound remote-execution channel use `https://alphagsm-api.sector-alpha.net`. Owners -request actions on the website; the agent polls for permitted jobs and posts -results to the API. No inbound management listener is opened on your machine. +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` for another compatible diff --git a/src/agent/service.py b/src/agent/service.py index cc26bb44..a79d87a0 100644 --- a/src/agent/service.py +++ b/src/agent/service.py @@ -8,11 +8,12 @@ from .lifecycle import AlphaRuntime, Executor from .reporting import policy_snapshot, report_snapshot from .preflight import final_report, preflight_report -from .transport import Transport, TransportError +from .socket_channel import SocketChannel, SocketError +from .transport import AgentLimit, Transport, TransportError -class Service: - def __init__(self, config, *, runtime=None, transport=None): +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() @@ -21,6 +22,8 @@ def __init__(self, config, *, runtime=None, transport=None): 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() @@ -43,14 +46,18 @@ def report_once(self): print("Publication withheld for " + binding_id + ": " + reason) return self.transport.request("POST", "/v1/agent/reports", credential=current["reporting"], payload=payload) - def management_once(self): + 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, timeout=35 + "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(): @@ -106,19 +113,85 @@ def _report_loop(self): # 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(): - cfg = self.config.load() - if not cfg["management_enabled"]: + 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 - try: - self.management_once() + if outcome in ("rest", "fallback"): failures = 0 - except (TransportError, ValueError): - failures = min(failures + 1, 6) - self.stop.wait(min(300, 2**failures) + secrets.randbelow(5)) + 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): diff --git a/src/agent/socket_channel.py b/src/agent/socket_channel.py new file mode 100644 index 00000000..320c5011 --- /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 ssl +import time +from urllib.parse import urlsplit + +from .config import canonical_agent_origin +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 ssl.create_default_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/transport.py b/src/agent/transport.py index 3b00bddf..07ead064 100644 --- a/src/agent/transport.py +++ b/src/agent/transport.py @@ -22,15 +22,24 @@ 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 _busy_body(exc): +def _exact_body(exc, expected): try: - body = exc.read(len(_QUERY_BUSY) + 1) + body = exc.read(len(expected) + 1) except OSError: return False - return body == _QUERY_BUSY + return body == expected + + +def _busy_body(exc): + return _exact_body(exc, _QUERY_BUSY) class NoRedirect(urllib.request.HTTPRedirectHandler): @@ -49,7 +58,7 @@ def __init__(self, api_origin, *, context=None): DeadlineHTTPSHandler(context=context or ssl.create_default_context()), ) - def request(self, method, path, *, credential=None, payload=None, session_id=None, timeout=10): + 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", @@ -69,6 +78,8 @@ def request(self, method, path, *, credential=None, payload=None, session_id=Non 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: @@ -83,7 +94,8 @@ def request(self, method, path, *, credential=None, payload=None, session_id=Non # Official onboarding stays on the website; reports and command execution # use the machine API. Custom providers retain their exact configured origin. destination = self.control_origin if path in ("/v1/agent/enroll", "/v1/agent/bindings") else self.origin - request = urllib.request.Request(destination + path, method=method, data=data, headers=headers) + target = destination + path if query is None else destination + 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)) @@ -91,6 +103,8 @@ def request(self, method, path, *, credential=None, payload=None, session_id=Non 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): 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() From e1c6651d64843e3045cfe9d441fc3b3ef55060b6 Mon Sep 17 00:00:00 2001 From: cosmosquark Date: Mon, 19 Oct 2026 23:21:28 +0100 Subject: [PATCH 17/22] Add a TeamSpeak detail query for channel order and voice clients. The default count query still omits clientlist. A refused channel or client list keeps the server total and marks the roster unavailable. --- src/utils/query.py | 54 ++++++++++-- .../unit_tests/utils/test_ts3_query_pacing.py | 84 ++++++++++++++++++- 2 files changed, 131 insertions(+), 7 deletions(-) diff --git a/src/utils/query.py b/src/utils/query.py index 011d2c94..080d309a 100644 --- a/src/utils/query.py +++ b/src/utils/query.py @@ -1006,7 +1006,7 @@ def _read_until_ok(): conn.close() -def ts3_serverinfo(host, port, timeout=5.0, login=None): +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), @@ -1021,7 +1021,10 @@ def ts3_serverinfo(host, port, timeout=5.0, login=None): * ``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 @@ -1100,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: @@ -1140,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/tests/unit_tests/utils/test_ts3_query_pacing.py b/tests/unit_tests/utils/test_ts3_query_pacing.py index f5e9022a..5aea41b6 100644 --- a/tests/unit_tests/utils/test_ts3_query_pacing.py +++ b/tests/unit_tests/utils/test_ts3_query_pacing.py @@ -44,7 +44,89 @@ def connect(*_args, **_kwargs): for _ in range(5): info = query.ts3_serverinfo("192.0.2.1", 10011, login=("serveradmin", "fixture")) assert info["name"] == "Fixture" - assert info["channels"] == [{"id": 1, "name": "Lobby"}] + assert info["channels"] == [{"id": 1, "parent_id": 0, "order": 0, "name": "Lobby"}] assert len(sent) == 25 + assert all(command != "clientlist" for _, command in sent) assert all(connection.closed for connection in connections) assert all(later[0] - earlier[0] >= 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 From a2dea2a21908985762537bcc52d68f72f83f56eb Mon Sep 17 00:00:00 2001 From: cosmosquark Date: Tue, 20 Oct 2026 15:15:03 +0100 Subject: [PATCH 18/22] Enroll managed servers on the default API origin. alphagsm agent enroll binds managed servers that already have a public address, turns reporting on, and starts ownership challenges. Every agent operation uses https://alphagsm-api.sector-alpha.net unless the saved origin is set. Management stays off. Sector Alpha still verifies a challenge by querying the server. --- DEVELOPERS.md | 22 ++-- changelog.txt | 14 +++ docs/first-run-setup.md | 10 +- docs/sector-alpha-agent.md | 51 ++++---- src/agent/cli.py | 61 ++------- src/agent/config.py | 1 - src/agent/name_proof.py | 81 ++++++++++++ src/agent/onboarding.py | 129 +++++++++++++++++++- src/agent/transport.py | 11 +- src/core/welcome.py | 11 +- tests/smoke_tests/run_sector_alpha_agent.sh | 2 +- tests/unit_tests/agent/test_onboarding.py | 51 +++++++- tests/unit_tests/agent/test_origins.py | 36 +++--- 13 files changed, 358 insertions(+), 122 deletions(-) create mode 100644 src/agent/name_proof.py diff --git a/DEVELOPERS.md b/DEVELOPERS.md index fb02e7a2..fbbe415b 100644 --- a/DEVELOPERS.md +++ b/DEVELOPERS.md @@ -1185,23 +1185,23 @@ effects. A socket frame never skips those checks. Results are posted over HTTPS. reconciles unknown and never repeats effects. A global foreground-service lock prevents competing pollers. No secrets are accepted as CLI arguments. -The official provider uses `https://sector-alpha.net` for only agent enrollment -and binding registration, while `https://alphagsm-api.sector-alpha.net` receives -POST stats and carries the outbound execution protocol (policy acknowledgement, -the agent session socket, command polling, final authorization and results). Owners queue commands and view -data on the website. `Transport` selects the official website only for its two -approved onboarding paths. It retains one canonical API provider origin in local -configuration and derives the fixed website authority in memory, without redirects -or endpoint discovery. The same verified TLS/no-proxy/no-redirect transport applies -to both hosts. No new persisted secret/origin fields are introduced. +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. Stage website onboarding routes -before client upgrades; restrict the machine API after clients are updated. +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 diff --git a/changelog.txt b/changelog.txt index 51fa9e76..5a803d30 100644 --- a/changelog.txt +++ b/changelog.txt @@ -7,6 +7,20 @@ 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-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 diff --git a/docs/first-run-setup.md b/docs/first-run-setup.md index 26bbc0e6..0318a7a2 100644 --- a/docs/first-run-setup.md +++ b/docs/first-run-setup.md @@ -36,10 +36,12 @@ status of 2. 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. It contacts `https://sector-alpha.net` for onboarding; - later stats and execution use `https://alphagsm-api.sector-alpha.net`; - obtain the enrollment code from your account at `https://sector-alpha.net`. - That wizard previews the servers/endpoints and asks for binding permissions. + 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 diff --git a/docs/sector-alpha-agent.md b/docs/sector-alpha-agent.md index 8ce0e4a5..cc5e51a4 100644 --- a/docs/sector-alpha-agent.md +++ b/docs/sector-alpha-agent.md @@ -17,33 +17,41 @@ with MFA, choose capabilities and create a ten-minute enrollment code. Locally: ```sh alphagsm agent enroll -alphagsm agent bind '*' +alphagsm agent run ``` -Enter the enrollment code at its hidden prompt. Enrollment and binding -registration use `https://sector-alpha.net`. POSTed stats and the outbound -remote-execution channel use `https://alphagsm-api.sector-alpha.net`. 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` for another compatible -service. Custom providers keep their exact single configured origin. Never put -codes or credentials in shell arguments. The official two-host selection happens -locally by approved route, without redirects or fallback to another host. +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; onboarding requests now select the website separately. -No re-enrollment is needed. Deploy the website onboarding routes before upgrading -clients, then restrict the API routes. Older clients cannot enroll/bind through -the restricted API hostname. See the provider's deployment instructions. +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 website bindings, and saves the returned IDs automatically. Quote +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. @@ -221,8 +229,9 @@ requires a game-compatible proxy; configuring one is outside agent binding. New bindings default to private with no allowed remote actions. The prompts let you choose publication and actions; repeat runs preserve your previous choices. -Enrollment and binding leave reporting and management disabled. Enable the -capabilities you want once the preview is correct: +`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 diff --git a/src/agent/cli.py b/src/agent/cli.py index 579c78bf..24cd4809 100644 --- a/src/agent/cli.py +++ b/src/agent/cli.py @@ -6,13 +6,14 @@ from .config import Config, ConfigError, local_tag from .contracts import ACTIONS from .service import Service -from .transport import QueryBusy, Transport, TransportError +from .transport import Transport, TransportError 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 @@ -190,66 +191,20 @@ def run(args, *, root=None): def _prove(cfg, tag): - """Prompt for the website token, show it on TeamSpeak, and restore the old name.""" - token = getpass.getpass("Ownership challenge: ").strip() + """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] - 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 QueryError, 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) - - transport = Transport(value["origin"]) - - def request(): - try: - return transport.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 transport.request( - "GET", - "/v1/agent/verify/" + job_id, - credential=value["reporting"], - timeout=20, - ) + 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 = prove.prove_name( - tag, - token, - pending_path=cfg.root / "prove-name.json", - read_name=read_name, - write_name=write_name, - request=request, - poll=poll, - ) + 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 diff --git a/src/agent/config.py b/src/agent/config.py index 5c220296..268ebcc8 100644 --- a/src/agent/config.py +++ b/src/agent/config.py @@ -10,7 +10,6 @@ from .contracts import ACTIONS, identifier, integer, validate_metadata, exact DEFAULT_ORIGIN = "https://alphagsm-api.sector-alpha.net" -CONTROL_ORIGIN = "https://sector-alpha.net" class ConfigError(ValueError): 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/onboarding.py b/src/agent/onboarding.py index 3ae82329..4896e7c7 100644 --- a/src/agent/onboarding.py +++ b/src/agent/onboarding.py @@ -16,9 +16,10 @@ from .network import global_ip, suggest_public_host from .publication_config import local_publication from .binding_ports import selected_ports +from .transport import TransportError -def enroll(cfg, api_origin, transport): +def enroll(cfg, api_origin, transport, *, bind_local=True): api_origin = canonical_agent_origin(api_origin or DEFAULT_ORIGIN) try: with warnings.catch_warnings(): @@ -30,7 +31,129 @@ def enroll(cfg, api_origin, transport): raise ConfigError("An enrollment code is required") response = transport(api_origin).request("POST", "/v1/agent/enroll", payload={"code": code}) cfg.enroll(api_origin, response) - print("Enrolled. Reporting and management remain disabled until you enable each locally.") + 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) + + +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): @@ -184,7 +307,7 @@ def suggest(local): with state_lock(cfg.path): unchanged(cfg, value) if value['installation_id'] is None: - enroll(cfg, options.origin, transport) + enroll(cfg, options.origin, transport, bind_local=False) value = cfg.load() for item in candidates: require_capabilities(value, item['publication'], item['actions']) diff --git a/src/agent/transport.py b/src/agent/transport.py index 07ead064..d7f64598 100644 --- a/src/agent/transport.py +++ b/src/agent/transport.py @@ -8,7 +8,7 @@ import threading import urllib.error import urllib.request -from .config import CONTROL_ORIGIN, DEFAULT_ORIGIN, canonical_agent_origin +from .config import canonical_agent_origin from .http_deadline import DeadlineBudget, DeadlineHTTPSHandler, bounded_exchange REPORT_TIMEOUT = 10 @@ -50,7 +50,6 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): class Transport: def __init__(self, api_origin, *, context=None): self.origin = canonical_agent_origin(api_origin) - self.control_origin = CONTROL_ORIGIN if self.origin == DEFAULT_ORIGIN else self.origin self.report_lock = threading.Lock() self.opener = urllib.request.build_opener( urllib.request.ProxyHandler({}), @@ -62,6 +61,7 @@ def request(self, method, path, *, credential=None, payload=None, session_id=Non if path not in ( "/v1/agent/enroll", "/v1/agent/bindings", + "/v1/agent/challenge", "/v1/agent/reports", "/v1/agent/policy", "/v1/agent/jobs", @@ -91,10 +91,9 @@ def request(self, method, path, *, credential=None, payload=None, session_id=Non headers["Authorization"] = "Bearer " + credential["key_id"] + "." + credential["secret"] if session_id is not None: headers["X-Agent-Session"] = session_id - # Official onboarding stays on the website; reports and command execution - # use the machine API. Custom providers retain their exact configured origin. - destination = self.control_origin if path in ("/v1/agent/enroll", "/v1/agent/bindings") else self.origin - target = destination + path if query is None else destination + path + "?" + query + # 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": diff --git a/src/core/welcome.py b/src/core/welcome.py index 9fc82950..499c1459 100644 --- a/src/core/welcome.py +++ b/src/core/welcome.py @@ -91,12 +91,13 @@ def _create(name, execute): def _connect(name, execute): - print('\nOptional Sector Alpha connection: enrollment and binding contact') - print('https://sector-alpha.net; get your enrollment code there too.') - print('Later stats and remote execution use https://alphagsm-api.sector-alpha.net.') - print('The existing wizard previews servers/endpoints and asks for binding permissions.') + 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 binding, reporting needs a separate explicit agent enable-reporting command.') + 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 diff --git a/tests/smoke_tests/run_sector_alpha_agent.sh b/tests/smoke_tests/run_sector_alpha_agent.sh index 1ca7fd63..8ef51de3 100755 --- a/tests/smoke_tests/run_sector_alpha_agent.sh +++ b/tests/smoke_tests/run_sector_alpha_agent.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Offline non-player-port rejection, approved player/query ports, website onboarding/API execution routing, hostname preflight and consent gates. +# 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 \ diff --git a/tests/unit_tests/agent/test_onboarding.py b/tests/unit_tests/agent/test_onboarding.py index 1b29e622..f4af71e9 100644 --- a/tests/unit_tests/agent/test_onboarding.py +++ b/tests/unit_tests/agent/test_onboarding.py @@ -40,6 +40,8 @@ def request(self, method, path, **kwargs): 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) @@ -70,10 +72,57 @@ def test_root_origin_slash_is_normalized_in_storage_and_transport(tmp_path): 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'}})] - assert not Config(tmp_path / 'agent').load()['management_enabled'] + 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): diff --git a/tests/unit_tests/agent/test_origins.py b/tests/unit_tests/agent/test_origins.py index 109f5567..3509f7b0 100644 --- a/tests/unit_tests/agent/test_origins.py +++ b/tests/unit_tests/agent/test_origins.py @@ -53,7 +53,11 @@ def test_legacy_official_origin_is_normalized_locally_without_changing_relations @pytest.mark.parametrize('old', ALIASES) -def test_enroll_normalizes_provider_before_transport_submission(tmp_path, provider, old): +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 @@ -120,15 +124,14 @@ def test_direct_transport_canonicalizes_old_official_origin_before_credentials() assert Transport('https://SECTOR-ALPHA.NET:443/').origin == API -@pytest.mark.parametrize('provider,control,execution', [ - *[(alias, 'https://sector-alpha.net', API) for alias in [API, API + ':443/', *ALIASES]], - ('https://control.example', 'https://control.example', 'https://control.example'), - ('https://sector-alpha.net:8443', 'https://sector-alpha.net:8443', 'https://sector-alpha.net:8443'), +@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', 'https://alphagsm-api.sector-alpha.net.attacker.example'), ]) -def test_transport_routes_official_onboarding_to_website_and_execution_to_api(monkeypatch, provider, control, execution): +def test_transport_uses_the_configured_origin_for_every_operation(monkeypatch, provider, destination): transport = Transport(provider) sent = [] def capture(request, _timeout): @@ -136,15 +139,16 @@ def capture(request, _timeout): return {'accepted': True} monkeypatch.setattr(transport, '_read_response', capture) credential = {'key_id': 'test-key', 'secret': 's' * 43} - for method, path, destination in ( - ('POST', '/v1/agent/enroll', control), - ('POST', '/v1/agent/bindings', control), - ('POST', '/v1/agent/reports', execution), - ('POST', '/v1/agent/policy', execution), - ('GET', '/v1/agent/jobs', execution), - ('POST', '/v1/agent/jobs/job-1/authorize', execution), - ('POST', '/v1/agent/results', execution), + 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 == execution + assert transport.origin == destination From d1c49fb0de49f7ba4070f93d0ee7bd2ac5d77a6e Mon Sep 17 00:00:00 2001 From: cosmosquark Date: Wed, 4 Nov 2026 23:16:37 +0000 Subject: [PATCH 19/22] Add an optional Linux user service for the Sector Alpha agent. alphagsm agent install-service writes a systemd user unit that runs the same program with agent run, then starts it. The unit does not change reporting or management, and uninstall leaves the local enrollment in place. Outbound agent HTTPS and the WebSocket now require TLS 1.2. --- DEVELOPERS.md | 6 + README.md | 12 +- changelog.txt | 8 + docs/commands.md | 4 +- docs/first-run-setup.md | 7 +- docs/sector-alpha-agent.md | 14 +- src/agent/cli.py | 20 +- src/agent/http_deadline.py | 5 +- src/agent/onboarding.py | 3 + src/agent/socket_channel.py | 4 +- src/agent/tls.py | 12 ++ src/agent/transport.py | 4 +- src/agent/user_service.py | 214 ++++++++++++++++++++ src/core/main.py | 5 +- tests/unit_tests/agent/test_user_service.py | 208 +++++++++++++++++++ 15 files changed, 505 insertions(+), 21 deletions(-) create mode 100644 src/agent/tls.py create mode 100644 src/agent/user_service.py create mode 100644 tests/unit_tests/agent/test_user_service.py diff --git a/DEVELOPERS.md b/DEVELOPERS.md index baf77b2a..d512df63 100644 --- a/DEVELOPERS.md +++ b/DEVELOPERS.md @@ -1234,6 +1234,12 @@ 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 install-service` writes a Linux systemd user unit for +`agent run` using the invoked program. It does not change reporting, management, +publication or credentials. `alphagsm agent uninstall-service` removes that 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. diff --git a/README.md b/README.md index daf15a7b..60f68f10 100644 --- a/README.md +++ b/README.md @@ -573,11 +573,13 @@ alphagsm agent enroll alphagsm agent bind '*' ``` -Enrollment, binding registration, accounts and the directory use -`https://sector-alpha.net`. Stats and outbound remote command delivery/results use -`https://alphagsm-api.sector-alpha.net`. Updated clients select the correct -host locally without redirects. Existing IDs, keys and permissions are preserved; -restart the agent after updating. Custom providers keep their configured origin. +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. On Linux, `alphagsm agent install-service` +keeps `alphagsm agent run` running after you close the terminal. It does not +change reporting or management. `alphagsm agent uninstall-service` removes that +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 diff --git a/changelog.txt b/changelog.txt index ac53ebf3..1a55fc2f 100644 --- a/changelog.txt +++ b/changelog.txt @@ -7,6 +7,14 @@ 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-04 +- Sector Alpha: `alphagsm agent install-service` installs a Linux systemd user + service that runs `alphagsm agent run`. It does not change reporting or + management. `alphagsm agent uninstall-service` removes it 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 diff --git a/docs/commands.md b/docs/commands.md index 06281ec6..1eef36d5 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -15,7 +15,9 @@ 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. See [First-run setup](first-run-setup.md). +scripts run directly. On Linux, `alphagsm agent install-service` keeps the +Sector Alpha agent running and does not change reporting or management. +See [First-run setup](first-run-setup.md). ## Lifecycle diff --git a/docs/first-run-setup.md b/docs/first-run-setup.md index 0318a7a2..6fdd9410 100644 --- a/docs/first-run-setup.md +++ b/docs/first-run-setup.md @@ -53,8 +53,11 @@ the command shown. ## Reporting and permissions -The tour never enables remote reporting or management. New installations keep -both off. Public listings require separate per-server approval, and reporting +The tour never enables remote reporting or management, and it does not install +a background service. On Linux, `alphagsm agent install-service` is a separate +command that keeps `alphagsm agent run` running; it leaves reporting and +management unchanged. 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. diff --git a/docs/sector-alpha-agent.md b/docs/sector-alpha-agent.md index cc5e51a4..fa7d924a 100644 --- a/docs/sector-alpha-agent.md +++ b/docs/sector-alpha-agent.md @@ -181,8 +181,10 @@ the enrollment instructions above and run `alphagsm agent enroll` first. alphagsm agent run ``` - `run` stays in the foreground; use your user service manager for continuous - operation. Complete each endpoint's ownership challenge in the website to + `run` stays in the foreground. On Linux, `alphagsm agent install-service` + can keep that same command running after you close the terminal. It does not + change reporting or management. Remove it 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: @@ -251,9 +253,11 @@ alphagsm agent bind BINDING_ID my-minecraft --epoch 1 \ 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. Run remains an explicit foreground -service; install it under a user service manager if continuous operation is wanted. -No background service is installed automatically. Reporting reconciles approved +can be managed without a directory listing. Run remains a foreground +command. `alphagsm agent install-service` is the optional Linux user service +for that command, and `alphagsm agent uninstall-service` removes it. 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 diff --git a/src/agent/cli.py b/src/agent/cli.py index 24cd4809..99833ec1 100644 --- a/src/agent/cli.py +++ b/src/agent/cli.py @@ -7,6 +7,7 @@ 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 @@ -17,7 +18,7 @@ from .binding_ports import selected_ports -def run(args, *, root=None): +def run(args, *, root=None, executable=None): parser = argparse.ArgumentParser(prog="alphagsm agent") commands = parser.add_subparsers(dest="command", required=True) for command in ( @@ -30,6 +31,8 @@ def run(args, *, root=None): "run", "report-once", "forget", + "install-service", + "uninstall-service", ): command_parser = commands.add_parser(command) if command == "enroll": @@ -73,6 +76,8 @@ def run(args, *, root=None): options = parser.parse_args(args) except SystemExit as exc: return exc.code + if options.command in ("install-service", "uninstall-service"): + return _service_command(options.command, executable) try: cfg = Config(root) if options.command == "enroll": @@ -190,6 +195,19 @@ def run(args, *, root=None): return 130 +def _service_command(command, executable): + """Install or remove the optional user service without changing consent.""" + try: + if command == "install-service": + user_service.install(executable) + else: + user_service.uninstall() + 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) diff --git a/src/agent/http_deadline.py b/src/agent/http_deadline.py index bb0016e5..2c4f7a44 100644 --- a/src/agent/http_deadline.py +++ b/src/agent/http_deadline.py @@ -3,11 +3,12 @@ import http.client import queue import socket -import ssl import threading import time import urllib.request +from .tls import client_context + class DeadlineBudget: """One deadline owns every socket, including connect, handshake and headers.""" @@ -84,7 +85,7 @@ def connect(self): class DeadlineHTTPSHandler(urllib.request.HTTPSHandler): def __init__(self, budget=None, *, context=None): - super().__init__(context=context or ssl.create_default_context()) + super().__init__(context=context or client_context()) self.budget = budget def https_open(self, req): diff --git a/src/agent/onboarding.py b/src/agent/onboarding.py index 4896e7c7..8547c3d8 100644 --- a/src/agent/onboarding.py +++ b/src/agent/onboarding.py @@ -17,6 +17,7 @@ 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): @@ -36,6 +37,7 @@ def enroll(cfg, api_origin, transport, *, bind_local=True): return bind_enrolled(cfg, transport) challenge_enrolled(cfg, transport) + print(user_service.install_hint()) def bind_enrolled(cfg, transport): @@ -313,6 +315,7 @@ def suggest(local): 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 diff --git a/src/agent/socket_channel.py b/src/agent/socket_channel.py index 320c5011..4270f7e4 100644 --- a/src/agent/socket_channel.py +++ b/src/agent/socket_channel.py @@ -6,11 +6,11 @@ import json import os import socket -import ssl 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" @@ -118,7 +118,7 @@ def _open(self): if self.connector is not None: return self.connector() raw = socket.create_connection((host, port), timeout=10) - context = self.context or ssl.create_default_context() + context = self.context or client_context() try: return context.wrap_socket(raw, server_hostname=host) except Exception: 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 index d7f64598..d68ca0f1 100644 --- a/src/agent/transport.py +++ b/src/agent/transport.py @@ -3,13 +3,13 @@ import http.client import json -import ssl 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 @@ -54,7 +54,7 @@ def __init__(self, api_origin, *, context=None): self.opener = urllib.request.build_opener( urllib.request.ProxyHandler({}), NoRedirect(), - DeadlineHTTPSHandler(context=context or ssl.create_default_context()), + DeadlineHTTPSHandler(context=context or client_context()), ) def request(self, method, path, *, credential=None, payload=None, session_id=None, timeout=10, query=None): diff --git a/src/agent/user_service.py b/src/agent/user_service.py new file mode 100644 index 00000000..a9ed0c6e --- /dev/null +++ b/src/agent/user_service.py @@ -0,0 +1,214 @@ +"""Optional Linux user service for the foreground Sector Alpha agent. + +Enrollment, reporting and management stay independent of this unit. Installing +it only keeps `alphagsm agent run` alive for the current operator. +""" + +import os +import shutil +import subprocess +from pathlib import Path + +from utils.platform_info import PLATFORM + + +UNIT_NAME = "alphagsm-agent.service" +_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 user service could not be installed or removed.""" + + +def install_hint(*, platform=None, which=None, home=None, env=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 unit_file(home, env).is_file(): + return "Restart the installed agent service with: alphagsm agent install-service" + return "To keep the agent running after you close the terminal: alphagsm agent install-service" + + +def install(executable=None, *, runner=None, home=None, env=None, platform=None, user=None, which=None): + """Write and start a user service that runs this AlphaGSM program.""" + _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 + text = render_unit(program, selected_env.get("ALPHAGSM_CONFIG_LOCATION")) + path = unit_file(home, selected_env) + _write_unit(path, text) + invoke = _default_runner if runner is None else runner + _systemctl(invoke, "daemon-reload") + _systemctl(invoke, "enable", UNIT_NAME) + _systemctl(invoke, "restart", UNIT_NAME) + print("Installed " + 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): + """Remove the user 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 = 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") + if not path.is_file(): + print("No AlphaGSM agent user service is installed.") + return + invoke = _default_runner if runner is None else runner + result = invoke(["systemctl", "--user", "disable", "--now", UNIT_NAME]) + if result.returncode != 0 and not _unit_absent(result): + detail = (result.stderr or result.stdout or "").strip() + raise ServiceInstallError( + "systemctl --user disable --now " + UNIT_NAME + " failed" + (": " + detail if detail else "") + ) + path.unlink() + _systemctl(invoke, "daemon-reload") + print("Removed the AlphaGSM agent user service. Local enrollment and permissions are unchanged.") + + +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): + """systemd user unit. It never enables reporting or management.""" + lines = [ + "[Unit]", + "Description=AlphaGSM Sector Alpha agent", + "After=network-online.target", + "Wants=network-online.target", + "", + "[Service]", + "Type=simple", + "ExecStart=" + systemd_quote(executable) + " agent run", + ] + 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=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 _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(runner, *args): + result = runner(["systemctl", "--user", *args]) + if result.returncode != 0: + detail = (result.stderr or result.stdout or "").strip() + rendered = " ".join(args) + raise ServiceInstallError("systemctl --user " + rendered + " 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 _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 user 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 98aa46b8..18850733 100644 --- a/src/core/main.py +++ b/src/core/main.py @@ -68,7 +68,7 @@ def main(name, args): if args and args[0].lower() == "agent": from agent.cli import run as run_agent - return run_agent(args[1:]) + return run_agent(args[1:], executable=name) if len(args) == 1 and args[0].lower() in ("-v", "--version", "version"): print("AlphaGSM %s" % (get_version(),)) @@ -718,6 +718,9 @@ 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 the agent running and does not change + reporting or management. self-update [OPTION]... : Check for a newer AlphaGSM release and apply it when supported. """).rstrip(), 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..fd526a1a --- /dev/null +++ b/tests/unit_tests/agent/test_user_service.py @@ -0,0 +1,208 @@ +"""The optional agent service installs only the foreground run command.""" + +import ssl +import subprocess + +import pytest + +from agent.cli import run +from agent.config import Config +from agent.tls import client_context +from agent.user_service import ( + ServiceInstallError, + UNIT_NAME, + install, + install_hint, + 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 " + UNIT_NAME + " and started it." in output + 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 agent user service is installed." in capsys.readouterr().out + + +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): + 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: 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): + assert "foreground" in install_hint(platform="windows") + assert "install-service" in install_hint(platform="linux", which=_which, home=tmp_path, env={}) + 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={}).startswith("Restart") + + +def test_percent_and_quotes_are_escaped(): + assert systemd_quote('/opt/a"b\\c%') == '"/opt/a\\"b\\\\c%%"' From e00ce510e12311c465dd8d33a705f171e1e83c96 Mon Sep 17 00:00:00 2001 From: cosmosquark Date: Wed, 4 Nov 2026 23:45:15 +0000 Subject: [PATCH 20/22] Install the AlphaGSM agent service for the user or the system. The service runs alphagsm agent run. There is no separate alphagsm-agent program. install-service --system writes a system unit for one operator account and does not run it as root. --- DEVELOPERS.md | 9 +- README.md | 9 +- changelog.txt | 12 +- docs/commands.md | 4 +- docs/first-run-setup.md | 7 +- docs/sector-alpha-agent.md | 15 +- src/agent/cli.py | 18 +- src/agent/user_service.py | 177 ++++++++++++++++---- src/core/main.py | 6 +- tests/unit_tests/agent/test_user_service.py | 135 ++++++++++++++- 10 files changed, 324 insertions(+), 68 deletions(-) diff --git a/DEVELOPERS.md b/DEVELOPERS.md index d512df63..8979ccd9 100644 --- a/DEVELOPERS.md +++ b/DEVELOPERS.md @@ -1236,9 +1236,12 @@ 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 install-service` writes a Linux systemd user unit for -`agent run` using the invoked program. It does not change reporting, management, -publication or credentials. `alphagsm agent uninstall-service` removes that unit +`alphagsm agent install-service` writes `alphagsm.service` for the current +user. `install-service --system` writes the same unit under +`/etc/systemd/system` and runs it as one operator account, never as root. +Both execute the invoked program with `agent run`. There is no separate +alphagsm-agent program. Neither command changes reporting, management, +publication or credentials. `uninstall-service` 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 diff --git a/README.md b/README.md index 60f68f10..beeb1e8a 100644 --- a/README.md +++ b/README.md @@ -576,10 +576,11 @@ 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. On Linux, `alphagsm agent install-service` -keeps `alphagsm agent run` running after you close the terminal. It does not -change reporting or management. `alphagsm agent uninstall-service` removes that -service and leaves the local enrollment in place. Custom providers keep their -configured origin. +keeps `alphagsm agent run` running after you close the terminal. +`install-service --system` installs that same command as a system service for +your account. It does not change reporting or management. +`alphagsm agent uninstall-service` 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 diff --git a/changelog.txt b/changelog.txt index 1a55fc2f..0161f82e 100644 --- a/changelog.txt +++ b/changelog.txt @@ -8,11 +8,13 @@ Historical detail lives in git history. Keep this file focused on release-facing high-level milestones. 2026-11-04 -- Sector Alpha: `alphagsm agent install-service` installs a Linux systemd user - service that runs `alphagsm agent run`. It does not change reporting or - management. `alphagsm agent uninstall-service` removes it and leaves the - local enrollment in place. Enrollment, binding, the first-run tour and game - setup do not install it. +- 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 diff --git a/docs/commands.md b/docs/commands.md index 1eef36d5..81c8f661 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -16,7 +16,9 @@ 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. On Linux, `alphagsm agent install-service` keeps the -Sector Alpha agent running and does not change reporting or management. +Sector Alpha agent running for this user. `install-service --system` installs +that same `alphagsm agent run` command as a system service. Neither changes +reporting or management. See [First-run setup](first-run-setup.md). ## Lifecycle diff --git a/docs/first-run-setup.md b/docs/first-run-setup.md index 6fdd9410..32139632 100644 --- a/docs/first-run-setup.md +++ b/docs/first-run-setup.md @@ -54,9 +54,10 @@ the command shown. ## Reporting and permissions The tour never enables remote reporting or management, and it does not install -a background service. On Linux, `alphagsm agent install-service` is a separate -command that keeps `alphagsm agent run` running; it leaves reporting and -management unchanged. New installations keep both off. Public listings require +a background service. On Linux, `alphagsm agent install-service` keeps +`alphagsm agent run` running for this user, and `install-service --system` +installs that same command as a system service. 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, diff --git a/docs/sector-alpha-agent.md b/docs/sector-alpha-agent.md index fa7d924a..1a1c7964 100644 --- a/docs/sector-alpha-agent.md +++ b/docs/sector-alpha-agent.md @@ -182,8 +182,9 @@ the enrollment instructions above and run `alphagsm agent enroll` first. ``` `run` stays in the foreground. On Linux, `alphagsm agent install-service` - can keep that same command running after you close the terminal. It does not - change reporting or management. Remove it with `alphagsm agent uninstall-service`. + keeps that command running for this user. `alphagsm agent install-service --system` + installs it as a system service for your account. Neither changes reporting + or management. Remove the selected one 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. @@ -254,10 +255,12 @@ alphagsm agent bind BINDING_ID my-minecraft --epoch 1 \ 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. Run remains a foreground -command. `alphagsm agent install-service` is the optional Linux user service -for that command, and `alphagsm agent uninstall-service` removes it. 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 +command. `alphagsm agent install-service` installs a user service for it, and +`install-service --system` installs a system service that runs the same +`alphagsm agent run` command as one operator. There is no separate +alphagsm-agent program. `uninstall-service` 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 diff --git a/src/agent/cli.py b/src/agent/cli.py index 99833ec1..7aec8785 100644 --- a/src/agent/cli.py +++ b/src/agent/cli.py @@ -37,6 +37,10 @@ def run(args, *, root=None, executable=None): 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"): @@ -77,7 +81,7 @@ def run(args, *, root=None, executable=None): except SystemExit as exc: return exc.code if options.command in ("install-service", "uninstall-service"): - return _service_command(options.command, executable) + return _service_command(options, executable) try: cfg = Config(root) if options.command == "enroll": @@ -195,13 +199,15 @@ def run(args, *, root=None, executable=None): return 130 -def _service_command(command, executable): - """Install or remove the optional user service without changing consent.""" +def _service_command(options, executable): + """Install or remove the optional service without changing consent.""" try: - if command == "install-service": - user_service.install(executable) + 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() + user_service.uninstall(system=options.system) except user_service.ServiceInstallError as exc: print("Agent operation rejected: " + str(exc)) return 1 diff --git a/src/agent/user_service.py b/src/agent/user_service.py index a9ed0c6e..dd04f2a3 100644 --- a/src/agent/user_service.py +++ b/src/agent/user_service.py @@ -1,18 +1,25 @@ -"""Optional Linux user service for the foreground Sector Alpha agent. +"""Optional Linux service for the Sector Alpha agent. -Enrollment, reporting and management stay independent of this unit. Installing -it only keeps `alphagsm agent run` alive for the current operator. +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 grp import os +import pwd import shutil import subprocess +from collections import namedtuple from pathlib import Path from utils.platform_info import PLATFORM -UNIT_NAME = "alphagsm-agent.service" +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." @@ -20,10 +27,10 @@ class ServiceInstallError(ValueError): - """The optional user service could not be installed or removed.""" + """The optional agent service could not be installed or removed.""" -def install_hint(*, platform=None, which=None, home=None, env=None): +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." @@ -32,13 +39,30 @@ def install_hint(*, platform=None, which=None, home=None, env=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 agent service with: alphagsm agent install-service" - return "To keep the agent running after you close the terminal: alphagsm agent install-service" - - -def install(executable=None, *, runner=None, home=None, env=None, platform=None, user=None, which=None): - """Write and start a user service that runs this AlphaGSM program.""" + 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: @@ -48,14 +72,29 @@ def install(executable=None, *, runner=None, home=None, env=None, platform=None, ) 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) - invoke = _default_runner if runner is None else runner _systemctl(invoke, "daemon-reload") _systemctl(invoke, "enable", UNIT_NAME) _systemctl(invoke, "restart", UNIT_NAME) - print("Installed " + UNIT_NAME + " and started it.") + 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)) @@ -63,28 +102,51 @@ def install(executable=None, *, runner=None, home=None, env=None, platform=None, print(linger) -def uninstall(*, runner=None, home=None, env=None, platform=None, which=None): - """Remove the user service. Local enrollment and permissions stay in place.""" +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 = unit_file(home, os.environ if env is None else env) + 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 agent user service is installed.") + 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", "--user", "disable", "--now", UNIT_NAME]) + 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.""" + name = user or os.environ.get("SUDO_USER") or os.environ.get("USER") or "" + if name == "root": raise ServiceInstallError( - "systemctl --user disable --now " + UNIT_NAME + " failed" + (": " + detail if detail else "") + "The system service does not run as root. Choose the operator with --service-user." ) - path.unlink() - _systemctl(invoke, "daemon-reload") - print("Removed the AlphaGSM agent user service. Local enrollment and permissions are unchanged.") + _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): @@ -111,8 +173,8 @@ def resolve_executable(invoked): return candidate -def render_unit(executable, config_location=None): - """systemd user unit. It never enables reporting or management.""" +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", @@ -121,8 +183,18 @@ def render_unit(executable, config_location=None): "", "[Service]", "Type=simple", - "ExecStart=" + systemd_quote(executable) + " agent run", ] + 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") @@ -132,7 +204,7 @@ def render_unit(executable, config_location=None): "RestartSec=5", "", "[Install]", - "WantedBy=default.target", + "WantedBy=" + ("multi-user.target" if account is not None else "default.target"), "", ]) return "\n".join(lines) @@ -153,6 +225,16 @@ def unit_file(home=None, env=None): 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) @@ -168,12 +250,18 @@ def _default_runner(args): return subprocess.run(args, check=False, capture_output=True, text=True) -def _systemctl(runner, *args): - result = runner(["systemctl", "--user", *args]) +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() - rendered = " ".join(args) - raise ServiceInstallError("systemctl --user " + rendered + " failed" + (": " + detail if detail else "")) + raise ServiceInstallError(" ".join(command) + " failed" + (": " + detail if detail else "")) return result @@ -182,13 +270,38 @@ def _unit_absent(result): 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 user directory: " + str(exc)) from 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") diff --git a/src/core/main.py b/src/core/main.py index 18850733..3c22a797 100644 --- a/src/core/main.py +++ b/src/core/main.py @@ -719,8 +719,10 @@ def help(name, server, cmd=None, *, file=stderr, full_help=False): way. steam-auth login|status|logout : Manage local SteamCMD sessions. agent COMMAND : Optional Sector Alpha connection. install-service - keeps the agent running and does not change - reporting or management. + 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/tests/unit_tests/agent/test_user_service.py b/tests/unit_tests/agent/test_user_service.py index fd526a1a..ed7af933 100644 --- a/tests/unit_tests/agent/test_user_service.py +++ b/tests/unit_tests/agent/test_user_service.py @@ -9,10 +9,12 @@ 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, @@ -88,7 +90,8 @@ def test_install_writes_a_user_unit_and_restarts_it(tmp_path, capsys): 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 " + UNIT_NAME + " and started it." in output + 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() @@ -156,7 +159,7 @@ def test_uninstall_removes_only_the_unit(tmp_path, capsys): 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 agent user service is installed." in capsys.readouterr().out + assert "No AlphaGSM user service is installed." in capsys.readouterr().out def test_unsafe_program_paths_are_rejected(tmp_path): @@ -171,7 +174,7 @@ def test_unsafe_program_paths_are_rejected(tmp_path): def test_cli_install_does_not_create_agent_state(tmp_path, monkeypatch): seen = {} - def fake(executable): + def fake(executable, **_kwargs): seen["executable"] = executable monkeypatch.setattr("agent.user_service.install", fake) @@ -189,20 +192,140 @@ def test_cli_rejects_a_non_linux_service_install(tmp_path, monkeypatch, capsys): def test_main_dispatches_the_invoked_program(monkeypatch): seen = {} - monkeypatch.setattr("agent.user_service.install", lambda executable: seen.update(executable=executable)) + 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" in install_hint(platform="linux", which=_which, home=tmp_path, env={}) + 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={}).startswith("Restart") + 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() From e52e44b6ba64afffacdfa6e892fbeb5e994e9ecf Mon Sep 17 00:00:00 2001 From: cosmosquark Date: Thu, 5 Nov 2026 21:23:38 +0000 Subject: [PATCH 21/22] Document the agent service and keep Windows commands free of Linux modules. alphagsm agent run stays in the foreground. install-service enables and starts the user or system unit. Ordinary imports load grp and pwd only for a system account, and the TeamSpeak directory message lives in config so the import cycle is gone. --- DEVELOPERS.md | 18 +++++++------ README.md | 14 ++++++----- changelog.txt | 7 ++++++ docs/commands.md | 9 ++++--- docs/first-run-setup.md | 9 ++++--- docs/sector-alpha-agent.md | 28 +++++++++++++-------- src/agent/choices.py | 7 +----- src/agent/cli.py | 4 +-- src/agent/config.py | 8 +++--- src/agent/local_query.py | 15 ++++++++--- src/agent/user_service.py | 5 ++-- src/server/public_protocols.py | 18 ++++++++----- tests/unit_tests/agent/test_user_service.py | 23 +++++++++++++++++ 13 files changed, 108 insertions(+), 57 deletions(-) diff --git a/DEVELOPERS.md b/DEVELOPERS.md index 8979ccd9..ee940cc1 100644 --- a/DEVELOPERS.md +++ b/DEVELOPERS.md @@ -1236,13 +1236,17 @@ 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 install-service` writes `alphagsm.service` for the current -user. `install-service --system` writes the same unit under -`/etc/systemd/system` and runs it as one operator account, never as root. -Both execute the invoked program with `agent run`. There is no separate -alphagsm-agent program. Neither command changes reporting, management, -publication or credentials. `uninstall-service` removes the selected unit -and leaves the enrollment in place. The tour and game setup do not install it. +`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. diff --git a/README.md b/README.md index beeb1e8a..f8168ec7 100644 --- a/README.md +++ b/README.md @@ -575,12 +575,14 @@ 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. On Linux, `alphagsm agent install-service` -keeps `alphagsm agent run` running after you close the terminal. -`install-service --system` installs that same command as a system service for -your account. It does not change reporting or management. -`alphagsm agent uninstall-service` removes the selected service and leaves the -local enrollment in place. Custom providers keep their configured origin. +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 diff --git a/changelog.txt b/changelog.txt index 0161f82e..58df5b69 100644 --- a/changelog.txt +++ b/changelog.txt @@ -7,6 +7,13 @@ 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 +- 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` diff --git a/docs/commands.md b/docs/commands.md index 81c8f661..959a9093 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -15,10 +15,11 @@ 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. On Linux, `alphagsm agent install-service` keeps the -Sector Alpha agent running for this user. `install-service --system` installs -that same `alphagsm agent run` command as a system service. Neither changes -reporting or management. +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 diff --git a/docs/first-run-setup.md b/docs/first-run-setup.md index 32139632..01d6a666 100644 --- a/docs/first-run-setup.md +++ b/docs/first-run-setup.md @@ -54,10 +54,11 @@ the command shown. ## Reporting and permissions The tour never enables remote reporting or management, and it does not install -a background service. On Linux, `alphagsm agent install-service` keeps -`alphagsm agent run` running for this user, and `install-service --system` -installs that same command as a system service. Neither changes reporting or -management. New installations keep both off. Public listings require +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, diff --git a/docs/sector-alpha-agent.md b/docs/sector-alpha-agent.md index 1a1c7964..3c180266 100644 --- a/docs/sector-alpha-agent.md +++ b/docs/sector-alpha-agent.md @@ -181,10 +181,15 @@ the enrollment instructions above and run `alphagsm agent enroll` first. alphagsm agent run ``` - `run` stays in the foreground. On Linux, `alphagsm agent install-service` - keeps that command running for this user. `alphagsm agent install-service --system` - installs it as a system service for your account. Neither changes reporting - or management. Remove the selected one with `alphagsm agent uninstall-service`. + `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. @@ -254,13 +259,14 @@ alphagsm agent bind BINDING_ID my-minecraft --epoch 1 \ 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. Run remains a foreground -command. `alphagsm agent install-service` installs a user service for it, and -`install-service --system` installs a system service that runs the same -`alphagsm agent run` command as one operator. There is no separate -alphagsm-agent program. `uninstall-service` 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 +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 diff --git a/src/agent/choices.py b/src/agent/choices.py index f9b433c2..41be2716 100644 --- a/src/agent/choices.py +++ b/src/agent/choices.py @@ -1,14 +1,9 @@ """Capability-aware permission prompts for exact local binding previews.""" from server.public_query import PROFILES -from .config import ConfigError +from .config import TEAMSPEAK_DIRECTORY, ConfigError from .contracts import ACTIONS -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." -) - def publication_choice(game, approved, explicit): if game == "ts3server": diff --git a/src/agent/cli.py b/src/agent/cli.py index 7aec8785..0a421600 100644 --- a/src/agent/cli.py +++ b/src/agent/cli.py @@ -3,7 +3,7 @@ import argparse import getpass import json -from .config import Config, ConfigError, local_tag +from .config import TEAMSPEAK_DIRECTORY, Config, ConfigError, local_tag from .contracts import ACTIONS from .service import Service from .transport import Transport, TransportError @@ -100,8 +100,6 @@ def run(args, *, root=None, executable=None): ): raise ConfigError("Legacy bind requires exact game, host, port and name") if options.publish is True and options.game == "ts3server": - from .choices import TEAMSPEAK_DIRECTORY - 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") diff --git a/src/agent/config.py b/src/agent/config.py index 268ebcc8..2ae2fa08 100644 --- a/src/agent/config.py +++ b/src/agent/config.py @@ -10,6 +10,10 @@ 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): @@ -162,8 +166,6 @@ def bind(self, binding_id, tag, epoch, *, actions, publication, metadata, expect from .publication_config import local_publication if publication is True and metadata.get("game_id") == "ts3server": - from .choices import TEAMSPEAK_DIRECTORY - 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") @@ -242,8 +244,6 @@ def publication(self, binding_id, enabled): raise ConfigError("Unknown local binding") game_id = value["bindings"][binding_id]["metadata"]["game_id"] if enabled and game_id == "ts3server": - from .choices import TEAMSPEAK_DIRECTORY - raise ConfigError(TEAMSPEAK_DIRECTORY) if enabled and game_id not in PROFILES: raise ConfigError("This game has no supported public query profile") diff --git a/src/agent/local_query.py b/src/agent/local_query.py index a789e985..849810d9 100644 --- a/src/agent/local_query.py +++ b/src/agent/local_query.py @@ -15,14 +15,21 @@ def observe_local(server, game_id, *, timeout=5.0): if protocol == "ts3": info = ts3_serverinfo(host, port, timeout=timeout) players, capacity = info["clients_online"], info["max_clients"] - if type(players) is not int or type(capacity) is not int or players < 0 or capacity < 0: + if not _player_count(players) or not _player_count(capacity): raise QueryError("TeamSpeak returned invalid player counts") - if capacity < players: - capacity = players - return {"name": info["name"], "players": players, "capacity": capacity, "version": info["version"]} + 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) diff --git a/src/agent/user_service.py b/src/agent/user_service.py index dd04f2a3..fa8b3b7a 100644 --- a/src/agent/user_service.py +++ b/src/agent/user_service.py @@ -6,9 +6,7 @@ for one operator account. Enrollment, reporting and management stay unchanged. """ -import grp import os -import pwd import shutil import subprocess from collections import namedtuple @@ -130,6 +128,9 @@ def uninstall(*, runner=None, home=None, env=None, platform=None, which=None, sy 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( diff --git a/src/server/public_protocols.py b/src/server/public_protocols.py index ca70e883..41361376 100644 --- a/src/server/public_protocols.py +++ b/src/server/public_protocols.py @@ -8,7 +8,7 @@ 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 = None +_CACHE = {} def protocol_for(game_id): @@ -44,10 +44,11 @@ def game_supported(game_id): def _catalogue(): - global _CACHE - if _CACHE is None: - _CACHE = _build() - return _CACHE + cached = _CACHE.get("value") + if cached is None: + cached = _build() + _CACHE["value"] = cached + return cached def _build(): @@ -101,7 +102,12 @@ def _select(classified): def _literal_port(node): - if isinstance(node, ast.Constant) and type(node.value) is int and 1 <= node.value <= 65535: + 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) diff --git a/tests/unit_tests/agent/test_user_service.py b/tests/unit_tests/agent/test_user_service.py index ed7af933..9ddb8032 100644 --- a/tests/unit_tests/agent/test_user_service.py +++ b/tests/unit_tests/agent/test_user_service.py @@ -1,7 +1,10 @@ """The optional agent service installs only the foreground run command.""" +import os import ssl import subprocess +import sys +from pathlib import Path import pytest @@ -162,6 +165,26 @@ def test_uninstall_removes_only_the_unit(tmp_path, capsys): 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")) From 7dc2918612463ca634247151c43d1dded803512d Mon Sep 17 00:00:00 2001 From: cosmosquark Date: Thu, 5 Nov 2026 21:45:31 +0000 Subject: [PATCH 22/22] Resolve the ET: Legacy Linux archive from the current download page. The site now names the x86_64 tar.gz in the link title and puts the release number in a version label. Setup was stopping before the smoke test could start the server. --- changelog.txt | 2 + src/gamemodules/etlegacyserver/main.py | 57 +++++++++++++------ .../gamemodules/test_etlegacyserver_cov.py | 49 ++++++++++++++++ 3 files changed, 91 insertions(+), 17 deletions(-) diff --git a/changelog.txt b/changelog.txt index 58df5b69..736bba55 100644 --- a/changelog.txt +++ b/changelog.txt @@ -8,6 +8,8 @@ Historical detail lives in git history. Keep this file focused on release-facing 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 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/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')):