From 51c7d3adcad10ab93ae03854dfe2a5ee43751dbe Mon Sep 17 00:00:00 2001 From: Playit Coding Agent Date: Thu, 20 Aug 2026 18:16:33 +0000 Subject: [PATCH] Refactor agent architecture into layered runtime workflow - Extract model, platform, and runtime crates - Add dependency-boundary and compatibility checks - Route API access through the runtime gateway --- .github/api-client.sha256 | 5 + .github/scripts/check-api-client-frozen.sh | 14 + .../scripts/check-dependency-boundaries.sh | 118 ++ .github/workflows/compatibility.yml | 35 + Cargo.lock | 102 +- Cargo.toml | 3 + docs/architecture.md | 38 + packages/agent_core/Cargo.toml | 6 +- .../src/agent_control/connected_control.rs | 4 +- .../agent_core/src/agent_control/errors.rs | 28 +- .../src/agent_control/maintained_control.rs | 35 +- packages/agent_core/src/agent_control/mod.rs | 87 +- .../agent_core/src/agent_control/platform.rs | 2 +- .../agent_core/src/agent_control/version.rs | 12 +- packages/agent_core/src/gateway.rs | 232 ++ packages/agent_core/src/lib.rs | 1 + .../agent_core/src/network/origin_lookup.rs | 180 +- .../agent_core/src/network/tcp/tcp_client.rs | 47 +- .../agent_core/src/network/tcp/tcp_clients.rs | 255 ++- .../agent_core/src/network/tcp/tcp_pipe.rs | 193 +- .../src/network/tcp/tcp_settings.rs | 2 + .../agent_core/src/network/udp/packets.rs | 70 +- .../agent_core/src/network/udp/udp_channel.rs | 226 +- .../agent_core/src/network/udp/udp_clients.rs | 80 +- .../src/network/udp/udp_receiver.rs | 149 +- .../src/network/udp/udp_settings.rs | 2 + packages/agent_core/src/playit_agent.rs | 525 ++++- packages/agent_core/src/utils/id_slab.rs | 4 + .../agent_core/src/utils/non_overlapping.rs | 6 + .../tests/udp_tunnel_integration.rs | 127 +- packages/agent_proto/fixtures/udp_flow.hex | 5 + packages/agent_proto/src/control_feed.rs | 4 +- packages/agent_proto/src/control_messages.rs | 11 +- packages/agent_proto/src/hmac.rs | 1 + packages/agent_proto/src/lib.rs | 4 +- packages/agent_proto/tests/wire_fixtures.rs | 77 + packages/playit-cli/Cargo.toml | 12 +- packages/playit-cli/fixtures/help.txt | 21 + .../playit-cli/fixtures/help_non_linux.txt | 19 + .../playit-cli/fixtures/output_contract.txt | 15 + packages/playit-cli/src/client.rs | 431 ++-- packages/playit-cli/src/linux.rs | 210 +- packages/playit-cli/src/main.rs | 128 +- packages/playit-cli/src/problem.rs | 142 ++ packages/playit-cli/src/service.rs | 116 +- packages/playit-cli/src/signal_handle.rs | 43 +- packages/playit-cli/src/ui/mod.rs | 17 +- packages/playit-cli/src/ui/tui_app.rs | 96 +- packages/playit-ipc/Cargo.toml | 2 +- .../fixtures/ipc_v2_client_transcript.jsonl | 8 + .../fixtures/ipc_v2_lifecycle.jsonl | 7 + .../fixtures/ipc_v2_server_transcript.jsonl | 10 + packages/playit-ipc/src/endpoint.rs | 12 +- packages/playit-ipc/src/ipc.rs | 131 +- packages/playit-ipc/src/lib.rs | 1 - packages/playit-ipc/src/model.rs | 107 + packages/playit-ipc/src/paths.rs | 60 - packages/playit-ipc/tests/ipc_v2_contract.rs | 236 +++ packages/playit-ipc/tests/path_contract.rs | 50 + packages/playit-model/Cargo.toml | 9 + packages/playit-model/src/app.rs | 678 ++++++ packages/playit-model/src/config.rs | 286 +++ packages/playit-model/src/lib.rs | 9 + packages/playit-model/src/problem.rs | 252 +++ packages/playit-model/src/tunnel.rs | 317 +++ packages/playit-platform/Cargo.toml | 35 + packages/playit-platform/src/lib.rs | 15 + packages/playit-platform/src/linux.rs | 204 ++ packages/playit-platform/src/migration.rs | 143 ++ packages/playit-platform/src/paths.rs | 135 ++ packages/playit-platform/src/secret.rs | 121 ++ packages/playit-platform/src/service.rs | 538 +++++ .../src/unix.rs} | 5 +- .../playit-platform/src/windows/installer.rs | 276 +++ packages/playit-platform/src/windows/mod.rs | 13 + .../src/windows/shortcut.rs} | 134 +- .../src/windows/sid.rs} | 165 +- packages/playit-platform/tests/contracts.rs | 81 + packages/playit-runtime/Cargo.toml | 47 + .../examples/mtu_control.rs | 31 +- packages/playit-runtime/src/claim.rs | 147 ++ packages/playit-runtime/src/engine.rs | 113 + .../playit-runtime/src/generated_gateway.rs | 579 +++++ .../playit-runtime/src/installed_service.rs | 175 ++ packages/playit-runtime/src/lib.rs | 19 + packages/playit-runtime/src/supervisor.rs | 1858 +++++++++++++++++ packages/playitd-tray/Cargo.toml | 8 +- .../src/windows/backend_actions.rs | 248 ++- packages/playitd-tray/src/windows/mod.rs | 1 - .../src/windows/startup_shortcut.rs | 194 -- packages/playitd-windows-setup/Cargo.toml | 11 +- packages/playitd-windows-setup/src/main.rs | 25 +- .../playitd-windows-setup/src/permissions.rs | 218 -- .../src/secret_migration.rs | 382 ---- .../playitd-windows-setup/src/setup_log.rs | 18 +- packages/playitd-windows-setup/src/sid.rs | 58 - packages/playitd/Cargo.toml | 20 +- packages/playitd/src/bin/playitd-service.rs | 4 +- packages/playitd/src/bin/playitd.rs | 3 +- packages/playitd/src/daemon.rs | 1516 ++------------ packages/playitd/src/ipc_server.rs | 887 +++++--- packages/playitd/src/lib.rs | 18 - packages/playitd/src/linux.rs | 296 --- packages/playitd/src/manager.rs | 381 ---- packages/playitd/src/paths.rs | 67 - packages/playitd/tests/install_contract.rs | 67 + 106 files changed, 10405 insertions(+), 4966 deletions(-) create mode 100644 .github/api-client.sha256 create mode 100755 .github/scripts/check-api-client-frozen.sh create mode 100644 .github/scripts/check-dependency-boundaries.sh create mode 100644 .github/workflows/compatibility.yml create mode 100644 docs/architecture.md create mode 100644 packages/agent_core/src/gateway.rs create mode 100644 packages/agent_proto/fixtures/udp_flow.hex create mode 100644 packages/agent_proto/tests/wire_fixtures.rs create mode 100644 packages/playit-cli/fixtures/help.txt create mode 100644 packages/playit-cli/fixtures/help_non_linux.txt create mode 100644 packages/playit-cli/fixtures/output_contract.txt create mode 100644 packages/playit-cli/src/problem.rs create mode 100644 packages/playit-ipc/fixtures/ipc_v2_client_transcript.jsonl create mode 100644 packages/playit-ipc/fixtures/ipc_v2_lifecycle.jsonl create mode 100644 packages/playit-ipc/fixtures/ipc_v2_server_transcript.jsonl delete mode 100644 packages/playit-ipc/src/paths.rs create mode 100644 packages/playit-ipc/tests/ipc_v2_contract.rs create mode 100644 packages/playit-ipc/tests/path_contract.rs create mode 100644 packages/playit-model/Cargo.toml create mode 100644 packages/playit-model/src/app.rs create mode 100644 packages/playit-model/src/config.rs create mode 100644 packages/playit-model/src/lib.rs create mode 100644 packages/playit-model/src/problem.rs create mode 100644 packages/playit-model/src/tunnel.rs create mode 100644 packages/playit-platform/Cargo.toml create mode 100644 packages/playit-platform/src/lib.rs create mode 100644 packages/playit-platform/src/linux.rs create mode 100644 packages/playit-platform/src/migration.rs create mode 100644 packages/playit-platform/src/paths.rs create mode 100644 packages/playit-platform/src/secret.rs create mode 100644 packages/playit-platform/src/service.rs rename packages/{playitd/src/unix_account.rs => playit-platform/src/unix.rs} (94%) create mode 100644 packages/playit-platform/src/windows/installer.rs create mode 100644 packages/playit-platform/src/windows/mod.rs rename packages/{playitd-windows-setup/src/startup_shortcut.rs => playit-platform/src/windows/shortcut.rs} (55%) rename packages/{playitd/src/windows.rs => playit-platform/src/windows/sid.rs} (52%) create mode 100644 packages/playit-platform/tests/contracts.rs create mode 100644 packages/playit-runtime/Cargo.toml rename packages/{agent_core => playit-runtime}/examples/mtu_control.rs (88%) create mode 100644 packages/playit-runtime/src/claim.rs create mode 100644 packages/playit-runtime/src/engine.rs create mode 100644 packages/playit-runtime/src/generated_gateway.rs create mode 100644 packages/playit-runtime/src/installed_service.rs create mode 100644 packages/playit-runtime/src/lib.rs create mode 100644 packages/playit-runtime/src/supervisor.rs delete mode 100644 packages/playitd-tray/src/windows/startup_shortcut.rs delete mode 100644 packages/playitd-windows-setup/src/permissions.rs delete mode 100644 packages/playitd-windows-setup/src/secret_migration.rs delete mode 100644 packages/playitd-windows-setup/src/sid.rs delete mode 100644 packages/playitd/src/linux.rs delete mode 100644 packages/playitd/src/manager.rs delete mode 100644 packages/playitd/src/paths.rs create mode 100644 packages/playitd/tests/install_contract.rs diff --git a/.github/api-client.sha256 b/.github/api-client.sha256 new file mode 100644 index 00000000..8dc21430 --- /dev/null +++ b/.github/api-client.sha256 @@ -0,0 +1,5 @@ +24647a217c868be35a63ee1903256fee5464a2dce8227b2d1e93e53a0da6e1bf packages/api_client/Cargo.toml +e89d6b353b59e2f939429c030feaaed4a756f38028e0d5c86adb154be6723ee9 packages/api_client/src/api.rs +b564b8cf3ddcd4688f2ec9813201f8100d428e9aac36efaccef5583c985ea47b packages/api_client/src/http_client.rs +53df6aded4c3f32b88a266a0b37965702a7f56fb3ea0168746945320cb6e627b packages/api_client/src/ip_resource.rs +d08117991790a4a09793a0c23782a7b4e0abb307556bf95f7533042b619c093d packages/api_client/src/lib.rs diff --git a/.github/scripts/check-api-client-frozen.sh b/.github/scripts/check-api-client-frozen.sh new file mode 100755 index 00000000..e4ce7052 --- /dev/null +++ b/.github/scripts/check-api-client-frozen.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +repository_root="$(git rev-parse --show-toplevel)" +cd "$repository_root" + +actual_manifest="$(mktemp)" +trap 'rm -f "$actual_manifest"' EXIT + +find packages/api_client -type f -print0 \ + | LC_ALL=C sort -z \ + | xargs -0 sha256sum > "$actual_manifest" + +diff --unified .github/api-client.sha256 "$actual_manifest" diff --git a/.github/scripts/check-dependency-boundaries.sh b/.github/scripts/check-dependency-boundaries.sh new file mode 100644 index 00000000..2181339a --- /dev/null +++ b/.github/scripts/check-dependency-boundaries.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +set -euo pipefail + +repository_root="$(git rev-parse --show-toplevel)" +cd "$repository_root" + +forbidden_api='playit_api_client|playit-api-client|PlayitApi' +if grep -R -n -E "$forbidden_api" packages/agent_core packages/playitd/src packages/playitd/Cargo.toml; then + echo "agent_core and playitd must access the generated API client through playit-runtime" >&2 + exit 1 +fi + +if grep -R -n -E 'playit_runtime|playit-runtime' \ + packages/agent_core/src packages/agent_core/Cargo.toml; then + echo "agent_core must not depend on the application runtime" >&2 + exit 1 +fi + +forbidden_ipc='playit_api_client|playit-api-client|PlayitApi|reqwest|login_guest|guest_login_cache' +if grep -R -n -E "$forbidden_ipc" packages/playit-ipc packages/playitd/src/ipc_server.rs; then + echo "IPC must not own HTTP clients or login caches" >&2 + exit 1 +fi + +if grep -R -n -E 'Client[A-Z][A-Za-z]*View|playit_ipc|playit-ipc|interprocess|reqwest|tokio|windows[_-](sys|service)' \ + packages/playit-model/src packages/playit-model/Cargo.toml; then + echo "playit-model must not contain presentation, IPC, HTTP, runtime, or OS types" >&2 + exit 1 +fi + +if grep -R -n -E 'playit[_-](model|runtime)|api_client|playit-api-client' \ + packages/playit-platform/src packages/playit-platform/Cargo.toml; then + echo "playit-platform must contain OS mechanisms, not application lifecycle or API policy" >&2 + exit 1 +fi + +if grep -R -n -E '^pub use playit_platform' packages/playitd/src packages/playit-ipc/src; then + echo "entry-point and IPC crates must not re-export platform compatibility paths" >&2 + exit 1 +fi + +if [ -e packages/playit-ipc/src/paths.rs ] || grep -q '^pub mod paths;' packages/playit-ipc/src/lib.rs; then + echo "playit-ipc path forwarding must not return" >&2 + exit 1 +fi + +if [ -e packages/playit-runtime/src/gateway.rs ] || \ + grep -R -n -E '^pub use (playit_agent_core|crate::gateway)' \ + packages/playit-runtime/src packages/agent_core/src; then + echo "runtime and agent_core migration re-exports must not return" >&2 + exit 1 +fi + +api_adapter_files="$(grep -R -l -E 'playit_api_client|PlayitApi' \ + packages --include='*.rs' --exclude-dir=api_client || true)" +if [ "$api_adapter_files" != "packages/playit-runtime/src/generated_gateway.rs" ]; then + echo "expected one generated API adapter source, found:" >&2 + printf '%s\n' "$api_adapter_files" >&2 + exit 1 +fi + +require_one_definition() { + local pattern="$1" + local label="$2" + local count + count="$(grep -R -h -E "$pattern" packages --include='*.rs' | wc -l)" + if [ "$count" -ne 1 ]; then + echo "expected exactly one $label definition, found $count" >&2 + exit 1 + fi +} + +require_one_definition '^pub enum Phase[[:space:]]*\{' 'application lifecycle enum' +require_one_definition '^pub struct AppSnapshot[[:space:]]*\{' 'application snapshot' +require_one_definition '^pub struct SnapshotStore[[:space:]]*\{' 'snapshot authority' +require_one_definition '^pub struct AppSupervisor[[:space:]]*\{' 'application supervisor' +require_one_definition '^pub struct EngineSupervisor[[:space:]]*\{' 'engine supervisor' +require_one_definition '^pub struct GeneratedClientGateway[[:space:]]*\{' 'generated API adapter' +require_one_definition '^pub struct SupervisedEnginePort[[:space:]]*\{' 'engine application adapter' +require_one_definition '^pub async fn stop_installed_service_with_fallback' 'installed-service stop policy' +require_one_definition '^pub enum EngineExit[[:space:]]*\{' 'engine exit type' +require_one_definition '^pub enum EngineService[[:space:]]*\{' 'engine service type' +require_one_definition '^pub enum ServiceExit[[:space:]]*\{' 'service exit type' + +if grep -R -n 'PhaseKind' packages --include='*.rs'; then + echo "application lifecycle projections must match directly on Phase" >&2 + exit 1 +fi + +owned_task_sources=( + packages/playit-cli/src/main.rs + packages/playit-cli/src/signal_handle.rs + packages/playitd/src/daemon.rs + packages/playitd/src/ipc_server.rs + packages/playit-runtime/src/engine.rs + packages/agent_core/src/playit_agent.rs + packages/agent_core/src/network +) +if grep -R -n -E '^[[:space:]]*tokio::spawn[[:space:]]*\(' "${owned_task_sources[@]}"; then + echo "production tasks must retain a join owner" >&2 + exit 1 +fi + +if grep -n -E '^playitd[[:space:]]*=' \ + packages/playit-cli/Cargo.toml \ + packages/playitd-tray/Cargo.toml \ + packages/playitd-windows-setup/Cargo.toml; then + echo "clients and installer helpers must use playit-platform instead of playitd platform internals" >&2 + exit 1 +fi + +shortcut_owners="$(grep -R -l 'CoCreateInstance.*ShellLink\|CoCreateInstance(&ShellLink' \ + packages --include='*.rs' || true)" +if [ "$(printf '%s\n' "$shortcut_owners" | sed '/^$/d' | wc -l)" -ne 1 ]; then + echo "expected exactly one Windows COM shortcut implementation, found:" >&2 + printf '%s\n' "$shortcut_owners" >&2 + exit 1 +fi diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml new file mode 100644 index 00000000..76168c24 --- /dev/null +++ b/.github/workflows/compatibility.yml @@ -0,0 +1,35 @@ +name: Compatibility + +on: + pull_request: + push: + branches: [master] + +jobs: + frozen-api-client: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Reject api_client changes in pull requests + if: github.event_name == 'pull_request' + run: git diff --exit-code ${{ github.event.pull_request.base.sha }} ${{ github.sha }} -- packages/api_client + - name: Reject api_client drift + run: bash .github/scripts/check-api-client-frozen.sh + - name: Reject forbidden runtime dependencies + run: bash .github/scripts/check-dependency-boundaries.sh + + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + - name: Test workspace + run: cargo test --workspace --all-targets diff --git a/Cargo.lock b/Cargo.lock index e631470b..8e096473 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -100,6 +100,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "atk" version = "0.18.2" @@ -1898,23 +1909,21 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" name = "playit-agent-core" version = "1.0.10" dependencies = [ + "async-trait", "byteorder", - "chrono", "crossbeam", "futures-util", "governor", "hex", "message-encoding", "playit-agent-proto", - "playit-api-client", + "playit-model", "rand", "serde", - "serde_json", "slab", "slotmap", "tokio", "tokio-util", - "toml 0.9.12+spec-1.1.0", "tracing", "tracing-subscriber", "uuid", @@ -1956,26 +1965,22 @@ dependencies = [ "chrono", "clap", "crossterm", - "dirs 6.0.0", "dotenv", "hex", "playit-agent-core", - "playit-agent-proto", - "playit-api-client", "playit-ipc", - "playitd", - "rand", + "playit-model", + "playit-platform", + "playit-runtime", "ratatui", "serde", "serde_json", "serde_yml", "tokio", - "tokio-util", "toml 0.9.12+spec-1.1.0", "tracing", "tracing-appender", "tracing-subscriber", - "urlencoding", "uuid", "whoami", "winres", @@ -1985,9 +1990,9 @@ dependencies = [ name = "playit-ipc" version = "1.0.10" dependencies = [ - "dirs 6.0.0", "futures-util", "interprocess", + "playit-platform", "serde", "serde_json", "tokio", @@ -1996,24 +2001,61 @@ dependencies = [ "whoami", ] +[[package]] +name = "playit-model" +version = "1.0.10" + +[[package]] +name = "playit-platform" +version = "1.0.10" +dependencies = [ + "dirs 6.0.0", + "interprocess", + "libc", + "service-manager", + "tokio", + "uzers", + "widestring", + "windows", + "windows-sys 0.59.0", +] + +[[package]] +name = "playit-runtime" +version = "1.0.10" +dependencies = [ + "async-trait", + "hex", + "playit-agent-core", + "playit-api-client", + "playit-model", + "playit-platform", + "rand", + "serde", + "serde_json", + "tokio", + "tokio-util", + "toml 0.9.12+spec-1.1.0", + "tracing", + "uuid", +] + [[package]] name = "playitd" version = "1.0.10" dependencies = [ "clap", - "dirs 6.0.0", "dotenv", "governor", - "hex", "interprocess", - "libc", "playit-agent-core", - "playit-api-client", "playit-ipc", + "playit-model", + "playit-platform", + "playit-runtime", "serde", "serde_json", "serde_yml", - "service-manager", "tokio", "tokio-util", "toml 0.9.12+spec-1.1.0", @@ -2021,8 +2063,6 @@ dependencies = [ "tracing-appender", "tracing-rolling-file", "tracing-subscriber", - "uzers", - "widestring", "windows-service", "windows-sys 0.59.0", ] @@ -2034,10 +2074,10 @@ dependencies = [ "image", "kanal", "playit-ipc", - "playitd", + "playit-platform", + "playit-runtime", "tokio", "tray-icon", - "windows", "windows-sys 0.59.0", ] @@ -2045,8 +2085,7 @@ dependencies = [ name = "playitd-windows-setup" version = "1.0.10" dependencies = [ - "playitd", - "windows", + "playit-platform", ] [[package]] @@ -2812,6 +2851,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -3319,12 +3369,6 @@ dependencies = [ "serde", ] -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index 0182d768..6e6b318a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,9 @@ members = [ "packages/playit-cli", "packages/playit-ipc", + "packages/playit-model", + "packages/playit-platform", + "packages/playit-runtime", "packages/playitd", "packages/playitd-tray", "packages/playitd-windows-setup", diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..cc5b02b1 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,38 @@ +# Agent architecture + +Dependencies point from entry points toward application and engine contracts. +Generated API types stop in `playit-runtime/src/generated_gateway.rs`. +IPC version 2 types stop in `playit-ipc` and `playitd/src/ipc_server.rs`. +Operating-system mechanisms stop in `playit-platform`. + +## Owners + +| Concern | Owner | +| --- | --- | +| Application lifecycle | `playit-model::Phase` | +| Client-visible state | `playit-model::AppSnapshot` | +| Snapshot publication | `playit-runtime::SnapshotStore` | +| Application tasks and commands | `playit-runtime::AppSupervisor` | +| Interactive claim session, polling, and exchange | `playit-runtime::AppSupervisor` and `ClaimService` | +| Validated runtime limits and locations | `playit-model::AppConfig` | +| Tunnel-engine tasks | `playit-agent-core::EngineSupervisor` | +| Generated HTTP conversion | `playit-runtime::GeneratedClientGateway` | +| Installed-service stop policy | `playit-runtime::stop_installed_service_with_fallback` | +| Files, services, permissions, SIDs, and shortcuts | `playit-platform` | +| IPC version 2 conversion | `playitd::ipc_server` | + +`playit-platform` owns service-manager calls but does not decide application lifecycle policy. +CLI and tray code supply IPC and service-manager effects to the runtime-owned stop workflow. +Standalone CLI claim commands may use `ClaimService` without creating a daemon session. +Interactive setup uses the daemon session and sends only typed claim commands over IPC. + +Ordinary TCP connection and UDP flow I/O failures stay local to that child. +Panics, invariant failures, and shutdown-deadline failures can stop their owning service. + +## Compatibility + +IPC version 2 and installed file paths remain stable. +`playit-ipc::get_default_socket_path` remains as the transport's public default-endpoint convenience function. +Platform path modules are not re-exported through `playitd` or `playit-ipc`. + +The dependency-boundary script checks these ownership rules in CI. diff --git a/packages/agent_core/Cargo.toml b/packages/agent_core/Cargo.toml index 380eebb9..370c3930 100644 --- a/packages/agent_core/Cargo.toml +++ b/packages/agent_core/Cargo.toml @@ -9,22 +9,20 @@ repository = "https://github.com/playit-cloud/playit-agent" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +async-trait = "0.1" tokio = { workspace = true } tokio-util = { workspace = true } tracing = { workspace = true } -serde_json = { workspace = true } rand = { workspace = true } serde = { workspace = true } hex = { workspace = true } -toml = { workspace = true } uuid = { workspace = true } byteorder = { workspace = true } -chrono = { workspace = true } futures-util = { workspace = true } message-encoding = { workspace = true } playit-agent-proto = { path = "../agent_proto", version = "1.3.0" } -playit-api-client = { path = "../api_client", version = "0.2.0" } +playit-model = { path = "../playit-model", version = "1.0.10" } governor = "0.10.0" crossbeam = "0.8.4" diff --git a/packages/agent_core/src/agent_control/connected_control.rs b/packages/agent_core/src/agent_control/connected_control.rs index 80e934bc..dc0e00b8 100644 --- a/packages/agent_core/src/agent_control/connected_control.rs +++ b/packages/agent_core/src/agent_control/connected_control.rs @@ -193,8 +193,8 @@ impl ConnectedControl { } let mut reader = &self.buffer[..bytes]; - let feed = ControlFeed::read_from(&mut reader) - .map_err(|e| ControlError::FailedToReadControlFeed(e))?; + let feed = + ControlFeed::read_from(&mut reader).map_err(ControlError::FailedToReadControlFeed)?; if let ControlFeed::Response(ControlRpcMessage { content: ControlResponse::Pong(pong), diff --git a/packages/agent_core/src/agent_control/errors.rs b/packages/agent_core/src/agent_control/errors.rs index 3d72b790..99eddeb2 100644 --- a/packages/agent_core/src/agent_control/errors.rs +++ b/packages/agent_core/src/agent_control/errors.rs @@ -7,19 +7,14 @@ use std::{ time::Duration, }; +use crate::gateway::GatewayError; use futures_util::TryFutureExt; -use playit_api_client::{ - api::{ApiError, ApiErrorNoFail, ApiResponseError}, - http_client::HttpClientError, -}; #[derive(Debug)] pub enum SetupError { IoError(std::io::Error), FailedToConnect, - ApiFail(String), - ApiError(ApiResponseError), - RequestError(HttpClientError), + Gateway(GatewayError), AttemptingToAuthWithOldFlow, FailedToDecodeSignedAgentRegisterHex, NoResponseFromAuthenticate, @@ -94,22 +89,9 @@ impl, F: Future>> TryTimeoutHelp } } -impl From> for SetupError { - fn from(value: ApiError) -> Self { - match value { - ApiError::ApiError(api) => SetupError::ApiError(api), - ApiError::ClientError(error) => SetupError::RequestError(error), - ApiError::Fail(fail) => SetupError::ApiFail(serde_json::to_string(&fail).unwrap()), - } - } -} - -impl From> for SetupError { - fn from(value: ApiErrorNoFail) -> Self { - match value { - ApiErrorNoFail::ApiError(api) => SetupError::ApiError(api), - ApiErrorNoFail::ClientError(error) => SetupError::RequestError(error), - } +impl From for SetupError { + fn from(value: GatewayError) -> Self { + Self::Gateway(value) } } diff --git a/packages/agent_core/src/agent_control/maintained_control.rs b/packages/agent_core/src/agent_control/maintained_control.rs index 5bce4f05..99f49424 100644 --- a/packages/agent_core/src/agent_control/maintained_control.rs +++ b/packages/agent_core/src/agent_control/maintained_control.rs @@ -21,19 +21,33 @@ pub struct MaintainedControl { last_pong: u64, last_udp_auth: u64, last_control_targets: Vec, + connect_timeout: Duration, + retry_delay: Duration, } impl MaintainedControl { pub async fn setup(io: I, auth: A) -> Result { - let addresses = auth.get_control_addresses().await?; + Self::setup_with_policy(io, auth, Duration::from_secs(10), Duration::from_secs(2)).await + } + + pub async fn setup_with_policy( + io: I, + auth: A, + connect_timeout: Duration, + retry_delay: Duration, + ) -> Result { + let addresses = auth + .get_control_addresses() + .try_timeout(connect_timeout) + .await?; let setup = AddressSelector::new(addresses.clone(), io) .connect_to_first() - .try_timeout(Duration::from_secs(10)) + .try_timeout(connect_timeout) .await?; let control_channel = setup .auth_into_established(auth) - .try_timeout(Duration::from_secs(10)) + .try_timeout(connect_timeout) .await?; Ok(MaintainedControl { @@ -43,6 +57,8 @@ impl MaintainedControl { last_pong: 0, last_udp_auth: 0, last_control_targets: addresses, + connect_timeout, + retry_delay, }) } @@ -50,11 +66,12 @@ impl MaintainedControl { &mut self, create_io: C, ) -> Result { + let connect_timeout = self.connect_timeout; let addresses = self .control .auth .get_control_addresses() - .try_timeout(Duration::from_secs(5)) + .try_timeout(connect_timeout) .await?; if self.last_control_targets == addresses { @@ -62,17 +79,17 @@ impl MaintainedControl { } let new_io = async { create_io.await.map_err(|e| e.into()) } - .try_timeout(Duration::from_secs(5)) + .try_timeout(connect_timeout) .await?; let connected = AddressSelector::new(addresses.clone(), new_io) .connect_to_first() - .try_timeout(Duration::from_secs(10)) + .try_timeout(connect_timeout) .await?; let updated = self .replace_connection(connected, false) - .try_timeout(Duration::from_secs(5)) + .try_timeout(connect_timeout) .await?; self.last_control_targets = addresses; @@ -94,7 +111,7 @@ impl MaintainedControl { let registered = connected .authenticate(&self.control.auth) - .try_timeout(Duration::from_secs(10)) + .try_timeout(self.connect_timeout) .await?; tracing::info!(old = %self.control.conn.pong_latest.tunnel_addr, new = %connected.pong_latest.tunnel_addr, "update control address"); @@ -132,7 +149,7 @@ impl MaintainedControl { .await { tracing::error!(?error, "failed to authenticate"); - tokio::time::sleep(Duration::from_secs(2)).await; + tokio::time::sleep(self.retry_delay).await; return None; } } diff --git a/packages/agent_core/src/agent_control/mod.rs b/packages/agent_core/src/agent_control/mod.rs index 462ab9b6..e3f2d996 100644 --- a/packages/agent_core/src/agent_control/mod.rs +++ b/packages/agent_core/src/agent_control/mod.rs @@ -10,11 +10,7 @@ use playit_agent_proto::control_messages::Pong; use tokio::{io::ReadBuf, net::UdpSocket}; use version::get_version; -pub use playit_api_client::api::SignedAgentKey; -use playit_api_client::{ - PlayitApi, - api::{ReqAgentsRoutingGet, ReqProtoRegister}, -}; +use crate::gateway::{PlayitGateway, RegisterRequest, SignedAgentKey}; use crate::{agent_control::platform::current_platform, utils::error_helper::ErrorHelper}; @@ -130,10 +126,10 @@ impl DualStackUdpSocket { impl PacketIO for DualStackUdpSocket { async fn send_to(&self, buf: &[u8], target: SocketAddr) -> std::io::Result { - if target.is_ipv6() { - if let Some(ip6) = &self.ip6 { - return ip6.send_to(buf, target).await; - } + if target.is_ipv6() + && let Some(ip6) = &self.ip6 + { + return ip6.send_to(buf, target).await; } self.ip4.send_to(buf, target).await } @@ -141,7 +137,7 @@ impl PacketIO for DualStackUdpSocket { async fn recv_from(&self, buf: &mut [u8]) -> std::io::Result<(usize, SocketAddr)> { let sel = self.next.fetch_add(1, std::sync::atomic::Ordering::AcqRel); - if sel % 2 == 0 { + if sel.is_multiple_of(2) { PoolBoth { buffer: buf, a: self.ip6.as_ref(), @@ -176,22 +172,22 @@ impl Future for PoolBoth<'_> { let mut buf = ReadBuf::new(buffer); - if let Some(a) = a { - if let Poll::Ready(ready) = a.poll_recv_from(cx, &mut buf) { - return match ready { - Ok(addr) => Poll::Ready(Ok((buf.filled().len(), addr))), - Err(error) => Poll::Ready(Err(error)), - }; - } + if let Some(a) = a + && let Poll::Ready(ready) = a.poll_recv_from(cx, &mut buf) + { + return match ready { + Ok(addr) => Poll::Ready(Ok((buf.filled().len(), addr))), + Err(error) => Poll::Ready(Err(error)), + }; } - if let Some(b) = b { - if let Poll::Ready(ready) = b.poll_recv_from(cx, &mut buf) { - return match ready { - Ok(addr) => Poll::Ready(Ok((buf.filled().len(), addr))), - Err(error) => Poll::Ready(Err(error)), - }; - } + if let Some(b) = b + && let Poll::Ready(ready) = b.poll_recv_from(cx, &mut buf) + { + return match ready { + Ok(addr) => Poll::Ready(Ok((buf.filled().len(), addr))), + Err(error) => Poll::Ready(Err(error)), + }; } Poll::Pending @@ -216,34 +212,28 @@ impl PacketIO for UdpSocket { } pub trait AuthResource: Clone { - fn authenticate( - &self, - pong: &Pong, - ) -> impl Future> + Sync; + fn authenticate(&self, pong: &Pong) + -> impl Future>; - fn get_control_addresses( - &self, - ) -> impl Future, SetupError>> + Sync; + fn get_control_addresses(&self) -> impl Future, SetupError>>; } #[derive(Clone)] -pub struct AuthApi { - client: PlayitApi, +pub struct GatewayAuth { + gateway: Arc, } -impl AuthApi { - pub fn new(api_url: String, secret_key: String) -> Self { - let client = PlayitApi::create(api_url, Some(secret_key)); - AuthApi { client } +impl GatewayAuth { + pub fn new(gateway: Arc) -> Self { + Self { gateway } } } -impl AuthResource for AuthApi { +impl AuthResource for GatewayAuth { async fn authenticate(&self, pong: &Pong) -> Result { let res = self - .client - .proto_register(ReqProtoRegister { - agent_version: None, + .gateway + .register(RegisterRequest { client_addr: pong.client_addr, tunnel_addr: pong.tunnel_addr, proto_version: 2, @@ -257,19 +247,6 @@ impl AuthResource for AuthApi { } async fn get_control_addresses(&self) -> Result, SetupError> { - let routing = self - .client - .agents_routing_get(ReqAgentsRoutingGet { agent_id: None }) - .await?; - - let mut addresses = vec![]; - for ip6 in routing.targets6 { - addresses.push(SocketAddr::new(ip6.into(), 5525)); - } - for ip4 in routing.targets4 { - addresses.push(SocketAddr::new(ip4.into(), 5525)); - } - - Ok(addresses) + self.gateway.control_addresses().await.map_err(Into::into) } } diff --git a/packages/agent_core/src/agent_control/platform.rs b/packages/agent_core/src/agent_control/platform.rs index 4b5f3d20..8c360c94 100644 --- a/packages/agent_core/src/agent_control/platform.rs +++ b/packages/agent_core/src/agent_control/platform.rs @@ -1,4 +1,4 @@ -use playit_api_client::api::Platform; +use crate::gateway::Platform; pub fn current_platform() -> Platform { #[cfg(target_os = "windows")] diff --git a/packages/agent_core/src/agent_control/version.rs b/packages/agent_core/src/agent_control/version.rs index 472ed36f..f918ad0d 100644 --- a/packages/agent_core/src/agent_control/version.rs +++ b/packages/agent_core/src/agent_control/version.rs @@ -1,6 +1,6 @@ use std::sync::OnceLock; -use playit_api_client::api::{AgentVersion, Platform}; +use crate::gateway::{AgentVersion, Platform}; use std::str::FromStr; use uuid::Uuid; @@ -10,9 +10,7 @@ pub static AGENT_VERSION: OnceLock = OnceLock::new(); pub static PLATFORM: OnceLock = OnceLock::new(); pub fn get_platform() -> Platform { - PLATFORM - .get_or_init(|| agent_control::current_platform()) - .clone() + *PLATFORM.get_or_init(agent_control::current_platform) } pub fn register_platform(platform: Platform) { @@ -37,9 +35,9 @@ pub fn help_register_version(v_str: &str, variant_id: &str) -> AgentVersion { AgentVersion { variant_id: Uuid::from_str(variant_id).expect("variant id must be UUID"), - version_major: major, - version_minor: minor, - version_patch: patch, + major, + minor, + patch, } }) .clone() diff --git a/packages/agent_core/src/gateway.rs b/packages/agent_core/src/gateway.rs new file mode 100644 index 00000000..3e199b7d --- /dev/null +++ b/packages/agent_core/src/gateway.rs @@ -0,0 +1,232 @@ +use std::fmt; +use std::net::{IpAddr, SocketAddr}; + +use async_trait::async_trait; +use playit_model::{ + AccountStatus, Notice, PendingTunnel, Problem, ProblemCode, ProxyProtocol, TunnelCatalog, + TunnelProtocol, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Platform { + Linux, + Freebsd, + Windows, + Macos, + Android, + Ios, + Docker, + MinecraftPlugin, + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentVersion { + pub variant_id: uuid::Uuid, + pub major: u32, + pub minor: u32, + pub patch: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RegisterRequest { + pub proto_version: u64, + pub version: AgentVersion, + pub platform: Platform, + pub client_addr: SocketAddr, + pub tunnel_addr: SocketAddr, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SignedAgentKey { + pub key: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RunDataContext { + pub catalog_revision: u64, + pub accepted_at_millis: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GatewayRunData { + pub agent_id: String, + pub origins: Vec, + pub catalog: TunnelCatalog, + pub account_status: AccountStatus, + pub pending: Vec, + pub notices: Vec, + pub login_url: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GatewayOrigin { + pub tunnel_id: u64, + pub protocol: TunnelProtocol, + pub target: GatewayOriginTarget, + pub port_count: u16, + pub proxy_protocol: ProxyProtocol, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GatewayOriginHost { + Ip(IpAddr), + Hostname(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GatewayOriginTarget { + Https { + host: GatewayOriginHost, + http_port: u16, + https_port: u16, + }, + Port { + host: GatewayOriginHost, + port: u16, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GatewayErrorCode { + InvalidSecret, + AgentDisabledOverLimit, + Authentication, + Transport, + Rejected, + InvalidRunData, + NotReady, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GatewayError { + pub code: GatewayErrorCode, + pub operation: &'static str, + pub detail: String, +} + +impl GatewayError { + pub fn new(code: GatewayErrorCode, operation: &'static str, detail: impl Into) -> Self { + Self { + code, + operation, + detail: detail.into(), + } + } + + pub const fn problem_code(&self) -> ProblemCode { + match self.code { + GatewayErrorCode::InvalidSecret => ProblemCode::InvalidSecret, + GatewayErrorCode::AgentDisabledOverLimit => ProblemCode::AgentDisabledOverLimit, + GatewayErrorCode::InvalidRunData => ProblemCode::CatalogInvalid, + GatewayErrorCode::NotReady => ProblemCode::EngineUnavailable, + GatewayErrorCode::Authentication + | GatewayErrorCode::Transport + | GatewayErrorCode::Rejected => ProblemCode::CatalogUnavailable, + } + } + + pub const fn problem(&self) -> Problem { + Problem::new(self.problem_code()) + } +} + +impl fmt::Display for GatewayError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "playit gateway {} failed: {}", + self.operation, self.detail + ) + } +} + +impl std::error::Error for GatewayError {} + +#[async_trait] +pub trait PlayitGateway: Send + Sync { + async fn register(&self, request: RegisterRequest) -> Result; + async fn control_addresses(&self) -> Result, GatewayError>; + async fn run_data(&self, context: RunDataContext) -> Result; + async fn account_login_url(&self) -> Result; +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use super::*; + + #[derive(Default)] + struct FakeGateway { + calls: Mutex>, + } + + #[async_trait] + impl PlayitGateway for FakeGateway { + async fn register( + &self, + _request: RegisterRequest, + ) -> Result { + self.calls.lock().unwrap().push("register"); + Ok(SignedAgentKey { + key: "signed".to_owned(), + }) + } + + async fn control_addresses(&self) -> Result, GatewayError> { + self.calls.lock().unwrap().push("control_addresses"); + Ok(vec!["127.0.0.1:5525".parse().unwrap()]) + } + + async fn run_data(&self, _context: RunDataContext) -> Result { + Err(GatewayError::new( + GatewayErrorCode::InvalidRunData, + "run_data", + "fixture", + )) + } + + async fn account_login_url(&self) -> Result { + self.calls.lock().unwrap().push("account_login_url"); + Ok("https://playit.gg/login/fixture".to_owned()) + } + } + + #[tokio::test] + async fn fake_gateway_satisfies_the_engine_contract() { + let gateway = Arc::new(FakeGateway::default()); + assert_eq!( + gateway.account_login_url().await.unwrap(), + "https://playit.gg/login/fixture" + ); + assert_eq!( + gateway.control_addresses().await.unwrap(), + vec!["127.0.0.1:5525".parse().unwrap()] + ); + assert_eq!( + gateway.calls.lock().unwrap().as_slice(), + ["account_login_url", "control_addresses"] + ); + } + + #[test] + fn gateway_errors_have_stable_problem_codes() { + let cases = [ + (GatewayErrorCode::InvalidSecret, ProblemCode::InvalidSecret), + ( + GatewayErrorCode::AgentDisabledOverLimit, + ProblemCode::AgentDisabledOverLimit, + ), + ( + GatewayErrorCode::InvalidRunData, + ProblemCode::CatalogInvalid, + ), + (GatewayErrorCode::NotReady, ProblemCode::EngineUnavailable), + ]; + for (gateway_code, problem_code) in cases { + let error = GatewayError::new(gateway_code, "fixture", "fixture"); + assert_eq!(error.problem_code(), problem_code); + } + } +} diff --git a/packages/agent_core/src/lib.rs b/packages/agent_core/src/lib.rs index ca4bbb97..e455947e 100644 --- a/packages/agent_core/src/lib.rs +++ b/packages/agent_core/src/lib.rs @@ -1,6 +1,7 @@ extern crate core; pub mod agent_control; +pub mod gateway; pub mod network; pub mod playit_agent; pub mod stats; diff --git a/packages/agent_core/src/network/origin_lookup.rs b/packages/agent_core/src/network/origin_lookup.rs index 5f8802d2..dc8948d7 100644 --- a/packages/agent_core/src/network/origin_lookup.rs +++ b/packages/agent_core/src/network/origin_lookup.rs @@ -2,11 +2,11 @@ use std::{ collections::HashMap, fmt::Display, net::{IpAddr, SocketAddr}, - str::FromStr, }; +use crate::gateway::{GatewayOrigin, GatewayOriginHost, GatewayOriginTarget}; use playit_agent_proto::PortProto; -use playit_api_client::api::{AgentRunDataV1, AgentTunnelV1, PortType, ProxyProtocol, TunnelType}; +use playit_model::{ProxyProtocol, TunnelProtocol}; use tokio::net::lookup_host; use tokio::sync::RwLock; @@ -16,14 +16,9 @@ pub struct OriginLookup { } impl OriginLookup { - pub async fn update_from_run_data(&self, run_data: &AgentRunDataV1) { - self.update( - run_data - .tunnels - .iter() - .filter_map(OriginResource::from_agent_tunnel), - ) - .await; + pub async fn update_from_gateway(&self, origins: &[GatewayOrigin]) { + self.update(origins.iter().cloned().map(OriginResource::from_gateway)) + .await; } pub async fn update>(&self, resources: I) { @@ -149,89 +144,35 @@ pub enum OriginTarget { } impl OriginResource { - fn parse_origin_ip(tunn: &AgentTunnelV1) -> OriginIp { - tunn.agent_config - .fields - .iter() - .find(|f| f.name.eq("local_ip")) - .map(|v| v.value.trim()) - .filter(|v| !v.is_empty()) - .map(|value| { - IpAddr::from_str(value) - .map(OriginIp::IpAddress) - .unwrap_or_else(|_| OriginIp::Hostname(value.to_owned())) - }) - .unwrap_or_else(|| OriginIp::IpAddress("127.0.0.1".parse().unwrap())) - } - - pub fn from_agent_tunnel(tunn: &AgentTunnelV1) -> Option { - let tunnel_type = tunn - .tunnel_type - .clone() - .and_then(|v| serde_json::from_value::(serde_json::Value::String(v)).ok()); - - let proxy_protocol = tunn - .agent_config - .fields - .iter() - .find(|f| f.name.eq("proxy_protocol")) - .and_then(|v| { - serde_json::from_value::(serde_json::Value::String(v.value.clone())) - .ok() - }); - - let target = match tunnel_type { - Some(TunnelType::Https) => OriginTarget::Https { - ip: Self::parse_origin_ip(tunn), - http_port: tunn - .agent_config - .fields - .iter() - .find(|f| f.name.eq("http_port")) - .and_then(|v| u16::from_str(&v.value).ok()) - .unwrap_or(80), - https_port: tunn - .agent_config - .fields - .iter() - .find(|f| f.name.eq("https_port")) - .and_then(|v| u16::from_str(&v.value).ok()) - .unwrap_or(443), + pub fn from_gateway(origin: GatewayOrigin) -> Self { + Self { + tunnel_id: origin.tunnel_id, + proto: match origin.protocol { + TunnelProtocol::Tcp => PortProto::Tcp, + TunnelProtocol::Udp => PortProto::Udp, + TunnelProtocol::Both => PortProto::Both, }, - _ => { - // Get local_port from config, or fall back to public port from display_address - let local_port = tunn - .agent_config - .fields - .iter() - .find(|f| f.name.eq("local_port")) - .and_then(|v| u16::from_str(&v.value).ok()) - .or_else(|| { - // Extract port from display_address (format: "hostname:port" or "ip:port") - tunn.display_address - .rsplit(':') - .next() - .and_then(|p| u16::from_str(p).ok()) - })?; - - OriginTarget::Port { - ip: Self::parse_origin_ip(tunn), - port: local_port, - } - } - }; - - Some(OriginResource { - tunnel_id: tunn.internal_id, - proto: match tunn.port_type { - PortType::Tcp => PortProto::Tcp, - PortType::Udp => PortProto::Udp, - PortType::Both => PortProto::Both, + target: match origin.target { + GatewayOriginTarget::Https { + host, + http_port, + https_port, + } => OriginTarget::Https { + ip: origin_ip(host), + http_port, + https_port, + }, + GatewayOriginTarget::Port { host, port } => OriginTarget::Port { + ip: origin_ip(host), + port, + }, }, - target, - port_count: tunn.port_count, - proxy_protocol, - }) + port_count: origin.port_count, + proxy_protocol: match origin.proxy_protocol { + ProxyProtocol::None => None, + configured => Some(configured), + }, + } } pub async fn resolve_local(&self, port_offset: u16) -> Option { @@ -265,50 +206,29 @@ impl OriginResource { } } +fn origin_ip(host: GatewayOriginHost) -> OriginIp { + match host { + GatewayOriginHost::Ip(address) => OriginIp::IpAddress(address), + GatewayOriginHost::Hostname(hostname) => OriginIp::Hostname(hostname), + } +} + #[cfg(test)] mod tests { use super::*; - use playit_api_client::api::{AgentTunnelAttr, AgentTunnelConfig}; - use uuid::Uuid; - - fn build_tunnel( - tunnel_type: Option<&str>, - local_ip: &str, - local_port: Option<&str>, - port_type: PortType, - port_count: u16, - ) -> AgentTunnelV1 { - let mut fields = vec![AgentTunnelAttr { - name: "local_ip".to_owned(), - value: local_ip.to_owned(), - }]; - - if let Some(local_port) = local_port { - fields.push(AgentTunnelAttr { - name: "local_port".to_owned(), - value: local_port.to_owned(), - }); - } - - AgentTunnelV1 { - id: Uuid::nil(), - internal_id: 7, - name: "test".to_owned(), - display_address: "public.example:25565".to_owned(), - port_type, - port_count, - tunnel_type: tunnel_type.map(str::to_owned), - tunnel_type_display: "test".to_owned(), - agent_config: AgentTunnelConfig { fields }, - disabled_reason: None, - } - } #[test] - fn from_agent_tunnel_preserves_hostname_target() { - let tunnel = build_tunnel(None, "origin.internal", Some("25565"), PortType::Tcp, 0); - - let resource = OriginResource::from_agent_tunnel(&tunnel).expect("resource"); + fn gateway_origin_preserves_hostname_target() { + let resource = OriginResource::from_gateway(GatewayOrigin { + tunnel_id: 7, + protocol: TunnelProtocol::Tcp, + target: GatewayOriginTarget::Port { + host: GatewayOriginHost::Hostname("origin.internal".to_owned()), + port: 25565, + }, + port_count: 0, + proxy_protocol: ProxyProtocol::None, + }); match resource.target { OriginTarget::Port { diff --git a/packages/agent_core/src/network/tcp/tcp_client.rs b/packages/agent_core/src/network/tcp/tcp_client.rs index 58caa407..eb5e0386 100644 --- a/packages/agent_core/src/network/tcp/tcp_client.rs +++ b/packages/agent_core/src/network/tcp/tcp_client.rs @@ -1,4 +1,5 @@ use serde::Serialize; +use std::time::Duration; use tokio::net::TcpStream; use tokio_util::sync::CancellationToken; @@ -12,11 +13,11 @@ pub struct TcpClient { } impl TcpClient { - pub async fn create(tunn: TcpStream, origin: TcpStream) -> Self { - Self::create_with_stats(tunn, origin, None).await + pub async fn spawn(tunn: TcpStream, origin: TcpStream) -> Self { + Self::spawn_with_stats(tunn, origin, None).await } - pub async fn create_with_stats( + pub async fn spawn_with_stats( tunn: TcpStream, origin: TcpStream, stats: Option, @@ -27,14 +28,14 @@ impl TcpClient { let cancel = CancellationToken::new(); TcpClient { - tunn_to_origin: TcpPipe::new_with_stats( + tunn_to_origin: TcpPipe::spawn_with_stats( cancel.clone(), tunn_read, origin_write, stats.clone(), PipeDirection::TunnelToOrigin, ), - origin_to_tunn: TcpPipe::new_with_stats( + origin_to_tunn: TcpPipe::spawn_with_stats( cancel, origin_read, tunn_write, @@ -57,6 +58,42 @@ impl TcpClient { origin_to_tunn: self.origin_to_tunn.bytes_written(), } } + + pub fn failure(&self) -> Option { + [self.tunn_to_origin.exit(), self.origin_to_tunn.exit()] + .into_iter() + .flatten() + .find(|exit| { + !matches!( + exit, + crate::playit_agent::ServiceExit::Cancelled + | crate::playit_agent::ServiceExit::CleanEof + ) + }) + } + + pub async fn shutdown(self, deadline: Duration) -> crate::playit_agent::ServiceExit { + let (left, right) = tokio::join!( + self.tunn_to_origin.join(deadline), + self.origin_to_tunn.join(deadline) + ); + if matches!(left, crate::playit_agent::ServiceExit::DeadlineExceeded) + || matches!(right, crate::playit_agent::ServiceExit::DeadlineExceeded) + { + crate::playit_agent::ServiceExit::DeadlineExceeded + } else if let Some(exit) = [left, right].into_iter().find(|exit| { + !matches!( + exit, + crate::playit_agent::ServiceExit::Cancelled + | crate::playit_agent::ServiceExit::Completed + | crate::playit_agent::ServiceExit::CleanEof + ) + }) { + exit + } else { + crate::playit_agent::ServiceExit::Cancelled + } + } } #[derive(Debug, Serialize)] diff --git a/packages/agent_core/src/network/tcp/tcp_clients.rs b/packages/agent_core/src/network/tcp/tcp_clients.rs index 22b6203e..64d5a62a 100644 --- a/packages/agent_core/src/network/tcp/tcp_clients.rs +++ b/packages/agent_core/src/network/tcp/tcp_clients.rs @@ -2,12 +2,13 @@ use std::{net::SocketAddr, num::NonZeroU32, sync::Arc, time::Duration}; use governor::{DefaultDirectRateLimiter, Quota, RateLimiter}; use playit_agent_proto::control_feed::NewClient; -use playit_api_client::api::ProxyProtocol; +use playit_model::ProxyProtocol; use serde::Serialize; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, net::TcpStream, sync::mpsc::{Receiver, Sender, channel}, + task::JoinSet, time::Instant, }; use tokio_util::sync::CancellationToken; @@ -45,6 +46,16 @@ pub struct TcpClients { cancel: CancellationToken, } +pub struct TcpService { + worker: Worker, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TcpQueueError { + Closed, + ResponseClosed, +} + struct Worker { lookup: Arc, events: Receiver, @@ -54,6 +65,7 @@ struct Worker { stats: AgentStats, clients: Vec, + claims: JoinSet<()>, next_client_id: u64, } @@ -110,50 +122,63 @@ impl TcpClients { lookup: Arc, stats: AgentStats, cancel: CancellationToken, - ) -> Self { + ) -> (Self, TcpService) { let quota = build_quota(&settings); - let (events_tx, events_rx) = channel(1024); - - tokio::spawn( - Worker { - next_client_id: 1, - lookup, - events: events_rx, - events_tx: events_tx.clone(), - cancel: cancel.child_token(), - settings, - stats, - clients: Vec::with_capacity(32), - } - .start(), - ); - - TcpClients { - new_client_limiter: RateLimiter::direct(quota), - events_tx, - cancel, - } + let queue_capacity = settings.queue_capacity.max(1); + let (events_tx, events_rx) = channel(queue_capacity); + + let worker = Worker { + next_client_id: 1, + lookup, + events: events_rx, + events_tx: events_tx.clone(), + cancel: cancel.child_token(), + settings, + stats, + clients: Vec::with_capacity(32), + claims: JoinSet::new(), + }; + + ( + TcpClients { + new_client_limiter: RateLimiter::direct(quota), + events_tx, + cancel, + }, + TcpService { worker }, + ) } - pub async fn get_details(&self) -> Vec { + pub async fn get_details(&self) -> Result, TcpQueueError> { let (tx, rx) = tokio::sync::oneshot::channel(); self.events_tx .send(Event::GetDetails(tx)) .await - .expect("TcpClients worker closed"); - rx.await.expect("TcpClients worker closed") + .map_err(|_| TcpQueueError::Closed)?; + rx.await.map_err(|_| TcpQueueError::ResponseClosed) } - pub async fn handle_new_client(&self, new_client: NewClient) { + pub async fn handle_new_client(&self, new_client: NewClient) -> Result<(), TcpQueueError> { if self.new_client_limiter.check().is_err() { tcp_errors().new_client_rate_limited.inc(); - return; + return Ok(()); } self.events_tx .send(Event::NewClient(new_client)) .await - .expect("TcpClients worker closed"); + .map_err(|_| TcpQueueError::Closed) + } +} + +impl TcpService { + pub async fn run( + self, + ) -> ( + crate::playit_agent::EngineService, + crate::playit_agent::ServiceExit, + ) { + self.worker.start().await } } @@ -164,7 +189,12 @@ impl Drop for TcpClients { } impl Worker { - pub async fn start(mut self) { + pub async fn start( + mut self, + ) -> ( + crate::playit_agent::EngineService, + crate::playit_agent::ServiceExit, + ) { let mut next_clear = Instant::now() + Duration::from_secs(15); loop { @@ -172,7 +202,11 @@ impl Worker { recv_opt = self.events.recv() => { let Some(event) = recv_opt else { tracing::debug!("TcpClients worker closed because event channel closed"); - break; + let _ = self.shutdown_children(Duration::from_secs(4)).await; + return ( + crate::playit_agent::EngineService::Tcp, + crate::playit_agent::ServiceExit::QueueClosed("tcp events".to_owned()), + ); }; event }, @@ -182,12 +216,29 @@ impl Worker { }, _ = self.cancel.cancelled() => { tracing::debug!("TcpClients worker closed via cancel"); - break + return ( + crate::playit_agent::EngineService::Tcp, + self.shutdown_children(Duration::from_secs(4)).await, + ); }, + claim = wait_for_claim(&mut self.claims), if !self.claims.is_empty() => { + if let Err(error) = claim { + let _ = self.shutdown_children(Duration::from_secs(4)).await; + return ( + crate::playit_agent::EngineService::TcpClaim, + crate::playit_agent::ServiceExit::Panicked(error.to_string()), + ); + } + continue; + } }; match event { Event::NewClient(details) => { + if self.claims.len() >= self.settings.queue_capacity.max(1) { + tcp_errors().new_client_rate_limited.inc(); + continue; + } let client_id = self.next_client_id; self.next_client_id = client_id + 1; @@ -233,7 +284,7 @@ impl Worker { let event_tx = self.events_tx.clone(); let stats = self.stats.clone(); let cancel = self.cancel.child_token(); - tokio::spawn(async move { + self.claims.spawn(async move { let Some(origin_addr) = found.resolve_local(details.port_offset).await else { tracing::error!( @@ -369,7 +420,7 @@ impl Worker { } let proxy_write_res = match found.proxy_protocol { - Some(ProxyProtocol::ProxyProtocolV1) => { + Some(ProxyProtocol::V1) => { tokio::select! { _ = cancel.cancelled() => return, res = tokio::time::timeout( @@ -378,7 +429,7 @@ impl Worker { ) => res, } } - Some(ProxyProtocol::ProxyProtocolV2) => { + Some(ProxyProtocol::V2) => { tokio::select! { _ = cancel.cancelled() => return, res = tokio::time::timeout( @@ -387,7 +438,7 @@ impl Worker { ) => res, } } - None => Ok(Ok(())), + None | Some(ProxyProtocol::None) => Ok(Ok(())), }; match proxy_write_res { @@ -405,7 +456,7 @@ impl Worker { } let tcp_client = - TcpClient::create_with_stats(tunn_stream, origin_stream, Some(stats)) + TcpClient::spawn_with_stats(tunn_stream, origin_stream, Some(stats)) .await; let event = Event::ConnectedClient(Client { id: client_id, @@ -432,29 +483,27 @@ impl Worker { } Event::ClearOld => { let now = now_milli(); - self.clients.retain(|client| { - let last_use = client.tcp.last_use(); - - let since_tunn = now.max(last_use.tunn_to_origin) - last_use.tunn_to_origin; - let since_orig = now.max(last_use.origin_to_tunn) - last_use.origin_to_tunn; - - if 90_000 < since_tunn && 30_000 < since_orig { - tracing::debug!(id = client.id, "clear old: 90s since tunnel data"); - return false; - } - - if 90_000 < since_orig && 30_000 < since_tunn { - tracing::debug!(id = client.id, "clear old: 90s since origin data"); - return false; - } - - if 60_000 < since_tunn && 60_000 < since_orig { - tracing::debug!(id = client.id, "clear old: 60s since any data"); - return false; + let mut retained = Vec::with_capacity(self.clients.len()); + for client in std::mem::take(&mut self.clients) { + let child_failure = client.tcp.failure(); + if child_failure.is_some() || client_expired(&client, now) { + if let Some(exit) = &child_failure { + tracing::warn!( + client_id = client.id, + ?exit, + "TCP connection task ended" + ); + } + let exit = client.tcp.shutdown(Duration::from_secs(2)).await; + if exit.is_fatal_child_failure() { + let _ = self.shutdown_children(Duration::from_secs(4)).await; + return (crate::playit_agent::EngineService::TcpPipe, exit); + } + } else { + retained.push(client); } - - true - }); + } + self.clients = retained; // Update active TCP connection count self.stats.set_tcp(self.clients.len() as u32); @@ -462,4 +511,92 @@ impl Worker { } } } + + async fn shutdown_children(&mut self, deadline: Duration) -> crate::playit_agent::ServiceExit { + let stop_at = Instant::now() + deadline; + self.claims.abort_all(); + while self.claims.join_next().await.is_some() {} + + for client in self.clients.drain(..) { + let remaining = stop_at.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return crate::playit_agent::ServiceExit::DeadlineExceeded; + } + if matches!( + client.tcp.shutdown(remaining).await, + crate::playit_agent::ServiceExit::DeadlineExceeded + ) { + return crate::playit_agent::ServiceExit::DeadlineExceeded; + } + } + self.stats.set_tcp(0); + crate::playit_agent::ServiceExit::Cancelled + } +} + +fn client_expired(client: &Client, now: u64) -> bool { + let last_use = client.tcp.last_use(); + let since_tunn = now.max(last_use.tunn_to_origin) - last_use.tunn_to_origin; + let since_orig = now.max(last_use.origin_to_tunn) - last_use.origin_to_tunn; + + if 90_000 < since_tunn && 30_000 < since_orig { + tracing::debug!(id = client.id, "clear old: 90s since tunnel data"); + return true; + } + if 90_000 < since_orig && 30_000 < since_tunn { + tracing::debug!(id = client.id, "clear old: 90s since origin data"); + return true; + } + if 60_000 < since_tunn && 60_000 < since_orig { + tracing::debug!(id = client.id, "clear old: 60s since any data"); + return true; + } + false +} + +async fn wait_for_claim(claims: &mut JoinSet<()>) -> Result<(), tokio::task::JoinError> { + match claims.join_next().await { + Some(result) => result, + None => std::future::pending().await, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn closed_tcp_service_queue_returns_an_error() { + let cancel = CancellationToken::new(); + let (clients, service) = TcpClients::new( + TcpSettings::default(), + Arc::new(OriginLookup::default()), + AgentStats::new(), + cancel, + ); + drop(service); + assert!(matches!( + clients.get_details().await, + Err(TcpQueueError::Closed) + )); + } + + #[tokio::test] + async fn cancellation_joins_the_tcp_service() { + let cancel = CancellationToken::new(); + let (_clients, service) = TcpClients::new( + TcpSettings::default(), + Arc::new(OriginLookup::default()), + AgentStats::new(), + cancel.clone(), + ); + let task = tokio::spawn(service.run()); + cancel.cancel(); + let (service, exit) = tokio::time::timeout(Duration::from_secs(1), task) + .await + .expect("TCP service stopped before its deadline") + .expect("TCP service did not panic"); + assert_eq!(service, crate::playit_agent::EngineService::Tcp); + assert_eq!(exit, crate::playit_agent::ServiceExit::Cancelled); + } } diff --git a/packages/agent_core/src/network/tcp/tcp_pipe.rs b/packages/agent_core/src/network/tcp/tcp_pipe.rs index ac6b7310..c2bde150 100644 --- a/packages/agent_core/src/network/tcp/tcp_pipe.rs +++ b/packages/agent_core/src/network/tcp/tcp_pipe.rs @@ -1,5 +1,5 @@ use std::sync::{ - Arc, + Arc, Mutex, atomic::{AtomicU64, Ordering}, }; @@ -23,22 +23,17 @@ pub enum PipeDirection { pub struct TcpPipe { cancel: CancellationToken, shared: Arc, + task: Option>, } struct Shared { last_activity: AtomicU64, bytes_written: AtomicU64, + exit: Mutex>, } impl TcpPipe { - pub fn new( - from: R, - to: W, - ) -> Self { - Self::new_with_cancel(Default::default(), from, to) - } - - pub fn new_with_cancel< + pub fn spawn_with_cancel< R: AsyncRead + Unpin + Send + 'static, W: AsyncWrite + Unpin + Send + 'static, >( @@ -46,10 +41,10 @@ impl TcpPipe { from: R, to: W, ) -> Self { - Self::new_with_stats(cancel, from, to, None, PipeDirection::TunnelToOrigin) + Self::spawn_with_stats(cancel, from, to, None, PipeDirection::TunnelToOrigin) } - pub fn new_with_stats< + pub fn spawn_with_stats< R: AsyncRead + Unpin + Send + 'static, W: AsyncWrite + Unpin + Send + 'static, >( @@ -62,23 +57,28 @@ impl TcpPipe { let shared = Arc::new(Shared { last_activity: AtomicU64::new(now_milli()), bytes_written: AtomicU64::new(0), + exit: Mutex::new(None), }); - let this = TcpPipe { cancel, shared }; - - tokio::spawn( + let task_cancel = cancel.clone(); + let task_shared = shared.clone(); + let task = tokio::spawn( Worker { - cancel: this.cancel.clone(), - shared: this.shared.clone(), + cancel: task_cancel, + shared: task_shared.clone(), from, to, stats, direction, } - .start(), + .start_and_record(), ); - this + TcpPipe { + cancel, + shared, + task: Some(task), + } } pub fn bytes_written(&self) -> u64 { @@ -95,14 +95,48 @@ impl TcpPipe { self.shared.last_activity.load(Ordering::Acquire) == u64::MAX } + pub fn exit(&self) -> Option { + let recorded = self.shared.exit.lock().ok().and_then(|exit| exit.clone()); + if recorded.is_some() { + return recorded; + } + self.task + .as_ref() + .is_some_and(tokio::task::JoinHandle::is_finished) + .then(|| { + crate::playit_agent::ServiceExit::Panicked( + "TCP pipe ended without recording an exit".to_owned(), + ) + }) + } + pub fn shutdown(&self) { self.cancel.cancel(); } + + pub async fn join(mut self, deadline: std::time::Duration) -> crate::playit_agent::ServiceExit { + self.cancel.cancel(); + let Some(mut task) = self.task.take() else { + return crate::playit_agent::ServiceExit::Completed; + }; + match tokio::time::timeout(deadline, &mut task).await { + Ok(Ok(exit)) => exit, + Ok(Err(error)) => crate::playit_agent::ServiceExit::Panicked(error.to_string()), + Err(_) => { + task.abort(); + let _ = task.await; + crate::playit_agent::ServiceExit::DeadlineExceeded + } + } + } } impl Drop for TcpPipe { fn drop(&mut self) { self.cancel.cancel(); + if let Some(task) = &self.task { + task.abort(); + } } } @@ -116,7 +150,16 @@ struct Worker { } impl Worker { - pub async fn start(mut self) { + async fn start_and_record(self) -> crate::playit_agent::ServiceExit { + let shared = self.shared.clone(); + let exit = self.start().await; + if let Ok(mut slot) = shared.exit.lock() { + *slot = Some(exit.clone()); + } + exit + } + + pub async fn start(mut self) -> crate::playit_agent::ServiceExit { let mut buffer = vec![0u8; TCP_PIPE_BUFFER_SIZE]; loop { @@ -124,7 +167,8 @@ impl Worker { tokio::select! { _ = self.cancel.cancelled() => { tracing::debug!("TcpPipe cancelled"); - break; + self.shared.last_activity.store(u64::MAX, Ordering::Release); + return crate::playit_agent::ServiceExit::Cancelled; } _ = tokio::task::yield_now() => {} } @@ -135,25 +179,42 @@ impl Worker { .await else { tracing::debug!("TcpPipe cancelled"); - break; + self.shared.last_activity.store(u64::MAX, Ordering::Release); + return crate::playit_agent::ServiceExit::Cancelled; }; let byte_count = match read_res { Ok(count) => count, Err(error) => { tracing::error!(?error, "failed to read data"); - break; + self.shared.last_activity.store(u64::MAX, Ordering::Release); + return crate::playit_agent::ServiceExit::Io { + operation: "read TCP stream".to_owned(), + message: error.to_string(), + }; } }; if byte_count == 0 { tracing::debug!("pipe ended due to EOF"); - break; + self.shared.last_activity.store(u64::MAX, Ordering::Release); + return crate::playit_agent::ServiceExit::CleanEof; } - if let Err(error) = self.to.write_all(&buffer[..byte_count]).await { + let write = tokio::select! { + _ = self.cancel.cancelled() => { + self.shared.last_activity.store(u64::MAX, Ordering::Release); + return crate::playit_agent::ServiceExit::Cancelled; + } + write = self.to.write_all(&buffer[..byte_count]) => write, + }; + if let Err(error) = write { tracing::error!(?error, "failed to write data"); - break; + self.shared.last_activity.store(u64::MAX, Ordering::Release); + return crate::playit_agent::ServiceExit::Io { + operation: "write TCP stream".to_owned(), + message: error.to_string(), + }; } self.shared @@ -172,7 +233,87 @@ impl Worker { } } } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::playit_agent::ServiceExit; + use std::io; + use std::pin::Pin; + use std::task::{Context, Poll}; + use std::time::Duration; + use tokio::io::{AsyncWrite, ReadBuf, sink}; + + struct ErrorReader; + + impl AsyncRead for ErrorReader { + fn poll_read( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + _buf: &mut ReadBuf<'_>, + ) -> Poll> { + Poll::Ready(Err(io::Error::new( + io::ErrorKind::ConnectionReset, + "fixture", + ))) + } + } + + async fn wait_for_exit(pipe: &TcpPipe) -> ServiceExit { + tokio::time::timeout(Duration::from_secs(1), async { + loop { + if let Some(exit) = pipe.exit() { + return exit; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("TCP pipe reported an exit") + } + + #[tokio::test] + async fn clean_eof_has_a_typed_exit() { + let pipe = TcpPipe::spawn_with_cancel(CancellationToken::new(), tokio::io::empty(), sink()); + assert_eq!(wait_for_exit(&pipe).await, ServiceExit::CleanEof); + } + + #[tokio::test] + async fn read_failure_has_a_typed_exit() { + let pipe = TcpPipe::spawn_with_cancel(CancellationToken::new(), ErrorReader, sink()); + assert!(matches!( + wait_for_exit(&pipe).await, + ServiceExit::Io { operation, .. } if operation == "read TCP stream" + )); + } + + #[tokio::test] + async fn cancellation_stops_a_pending_pipe() { + let (reader, _peer) = tokio::io::duplex(16); + let pipe = TcpPipe::spawn_with_cancel(CancellationToken::new(), reader, PendingWriter); + pipe.shutdown(); + assert_eq!(wait_for_exit(&pipe).await, ServiceExit::Cancelled); + } + + struct PendingWriter; - self.shared.last_activity.store(u64::MAX, Ordering::Release); + impl AsyncWrite for PendingWriter { + fn poll_write( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + _buf: &[u8], + ) -> Poll> { + Poll::Pending + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } } } diff --git a/packages/agent_core/src/network/tcp/tcp_settings.rs b/packages/agent_core/src/network/tcp/tcp_settings.rs index a0ac5f5b..7b4fcc5e 100644 --- a/packages/agent_core/src/network/tcp/tcp_settings.rs +++ b/packages/agent_core/src/network/tcp/tcp_settings.rs @@ -2,6 +2,7 @@ pub struct TcpSettings { pub new_client_ratelimit: u32, pub new_client_ratelimit_burst: u32, + pub queue_capacity: usize, pub tcp_no_delay: bool, } @@ -10,6 +11,7 @@ impl Default for TcpSettings { Self { new_client_ratelimit: 100, new_client_ratelimit_burst: 300, + queue_capacity: 1024, tcp_no_delay: true, } } diff --git a/packages/agent_core/src/network/udp/packets.rs b/packages/agent_core/src/network/udp/packets.rs index 2830cb22..42648ff3 100644 --- a/packages/agent_core/src/network/udp/packets.rs +++ b/packages/agent_core/src/network/udp/packets.rs @@ -1,4 +1,5 @@ use std::{ + num::NonZeroUsize, sync::{Arc, atomic::Ordering}, task::{Poll, Waker}, }; @@ -7,6 +8,11 @@ use crossbeam::queue::ArrayQueue; pub const PACKET_LEN: usize = 2048; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PacketPoolError { + CapacityOverflow, +} + #[derive(Clone)] pub struct Packets { inner: Arc, @@ -32,9 +38,20 @@ unsafe impl Send for Packet {} unsafe impl Sync for Packet {} impl Packets { - pub fn new(mut packet_count: usize) -> Self { - packet_count = packet_count.next_power_of_two(); - let bytes = packet_count * PACKET_LEN; + pub fn new(packet_count: usize) -> Self { + Self::try_new(NonZeroUsize::new(packet_count).unwrap_or(NonZeroUsize::MIN)).unwrap_or_else( + |_| Self::try_new(NonZeroUsize::MIN).expect("one packet fits in memory"), + ) + } + + pub fn try_new(packet_count: NonZeroUsize) -> Result { + let packet_count = packet_count + .get() + .checked_next_power_of_two() + .ok_or(PacketPoolError::CapacityOverflow)?; + let bytes = packet_count + .checked_mul(PACKET_LEN) + .ok_or(PacketPoolError::CapacityOverflow)?; let mut buffer = vec![0u8; bytes]; @@ -42,19 +59,22 @@ impl Packets { let ptr = buffer.as_mut_ptr(); for i in 0..packet_count { - free_packets + if free_packets .push(unsafe { ptr.add(i * PACKET_LEN) }) - .expect("free packet queue too small"); + .is_err() + { + return Err(PacketPoolError::CapacityOverflow); + } } - Packets { + Ok(Packets { inner: Arc::new(PacketsInner { _buffer: buffer, packet_count, free_packets, waiting: ArrayQueue::new(1024), }), - } + }) } pub fn packet_count(&self) -> usize { @@ -95,10 +115,10 @@ impl Packets { impl Drop for Packet { fn drop(&mut self) { - self.inner - .free_packets - .push(self.ptr) - .expect("free packet queue full"); + if self.inner.free_packets.push(self.ptr).is_err() { + tracing::error!("packet pool rejected a returned buffer"); + return; + } if let Some(wake) = self.inner.waiting.pop() { std::sync::atomic::fence(Ordering::Release); @@ -145,3 +165,31 @@ impl Packet { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pool_rejects_overload_and_reuses_returned_packets() { + let packets = Packets::new(3); + assert_eq!(packets.packet_count(), 4); + let mut allocated = Vec::new(); + for _ in 0..4 { + allocated.push(packets.allocate().expect("packet available")); + } + assert!(packets.allocate().is_none()); + drop(allocated.pop()); + assert!(packets.allocate().is_some()); + } + + #[test] + fn sustained_reuse_never_grows_the_pool() { + let packets = Packets::new(8); + for _ in 0..10_000 { + let packet = packets.allocate().expect("packet available"); + drop(packet); + } + assert_eq!(packets.packet_count(), 8); + } +} diff --git a/packages/agent_core/src/network/udp/udp_channel.rs b/packages/agent_core/src/network/udp/udp_channel.rs index c92e731c..32958d76 100644 --- a/packages/agent_core/src/network/udp/udp_channel.rs +++ b/packages/agent_core/src/network/udp/udp_channel.rs @@ -6,7 +6,9 @@ use std::{ }, time::Duration, }; +use tokio::task::JoinSet; use tokio::time::Instant; +use tokio_util::sync::CancellationToken; use playit_agent_proto::{ control_messages::UdpChannelDetails, @@ -31,6 +33,18 @@ pub struct UdpChannel { shared: Arc, } +pub struct UdpChannelTasks { + send: SendTask, + recv: RecvTask, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UdpChannelError { + SessionQueueClosed, + SendQueueClosed, + ReceiveQueueClosed, +} + #[derive(Default)] struct Shared { establish_rx_epoch: AtomicU64, @@ -54,7 +68,7 @@ struct RecvTask { } impl UdpChannel { - pub async fn new(packets: Packets) -> Result { + pub async fn new(packets: Packets) -> Result<(Self, UdpChannelTasks), std::io::Error> { let socket = Arc::new(DualStackUdpSocket::new().await?); let (session_tx, session_rx) = channel(32); @@ -64,32 +78,32 @@ impl UdpChannel { let shared = Arc::new(Shared::default()); - tokio::spawn( - SendTask { - socket: socket.clone(), - session: None, - session_rx, - send_rx, - shared: shared.clone(), - } - .start(), - ); - tokio::spawn( - RecvTask { - socket, - packets, - recv_tx, - shared: shared.clone(), - } - .start(), - ); + let send_task = SendTask { + socket: socket.clone(), + session: None, + session_rx, + send_rx, + shared: shared.clone(), + }; + let recv_task = RecvTask { + socket, + packets, + recv_tx, + shared: shared.clone(), + }; - Ok(UdpChannel { - session_tx, - send: send_tx, - recv: recv_rx, - shared, - }) + Ok(( + UdpChannel { + session_tx, + send: send_tx, + recv: recv_rx, + shared, + }, + UdpChannelTasks { + send: send_task, + recv: recv_task, + }, + )) } pub fn time_since_established(&self) -> Option { @@ -110,23 +124,83 @@ impl UdpChannel { Some(Duration::from_millis(now.max(ts) - ts)) } - pub async fn update_session(&self, details: UdpChannelDetails) { - self.session_tx.send(details).await.expect("task closed"); + pub async fn update_session(&self, details: UdpChannelDetails) -> Result<(), UdpChannelError> { + self.session_tx + .send(details) + .await + .map_err(|_| UdpChannelError::SessionQueueClosed) } - pub async fn send(&self, flow: UdpFlow, packet: Packet) { - if self.send.send((flow, packet)).await.is_err() { - panic!("UdpChannel task closed"); - } + pub async fn send(&self, flow: UdpFlow, packet: Packet) -> Result<(), UdpChannelError> { + self.send + .send((flow, packet)) + .await + .map_err(|_| UdpChannelError::SendQueueClosed) } - pub async fn recv(&mut self) -> (UdpFlow, Packet) { - self.recv.recv().await.expect("UdpChannel task closed") + pub async fn recv(&mut self) -> Result<(UdpFlow, Packet), UdpChannelError> { + self.recv + .recv() + .await + .ok_or(UdpChannelError::ReceiveQueueClosed) + } +} + +impl UdpChannelTasks { + pub async fn run( + self, + cancel: CancellationToken, + ) -> ( + crate::playit_agent::EngineService, + crate::playit_agent::ServiceExit, + ) { + let mut tasks = JoinSet::new(); + let send_cancel = cancel.child_token(); + tasks.spawn(async move { + ( + crate::playit_agent::EngineService::UdpChannelSend, + self.send.start(send_cancel).await, + ) + }); + let recv_cancel = cancel.child_token(); + tasks.spawn(async move { + ( + crate::playit_agent::EngineService::UdpChannelRecv, + self.recv.start(recv_cancel).await, + ) + }); + + tokio::select! { + _ = cancel.cancelled() => { + tasks.abort_all(); + while tasks.join_next().await.is_some() {} + ( + crate::playit_agent::EngineService::UdpChannelRecv, + crate::playit_agent::ServiceExit::Cancelled, + ) + } + result = tasks.join_next() => { + cancel.cancel(); + tasks.abort_all(); + while tasks.join_next().await.is_some() {} + match result { + Some(Ok(exit)) => exit, + Some(Err(error)) => ( + crate::playit_agent::EngineService::UdpChannelRecv, + crate::playit_agent::ServiceExit::Panicked(error.to_string()), + ), + None => ( + crate::playit_agent::EngineService::UdpChannelRecv, + crate::playit_agent::ServiceExit::Completed, + ), + } + } + } } } impl SendTask { - async fn start(mut self) { + async fn start(mut self, cancel: CancellationToken) -> crate::playit_agent::ServiceExit { let mut last_establish_send = Instant::now(); loop { @@ -151,18 +225,23 @@ impl SendTask { }; tokio::select! { + _ = cancel.cancelled() => return crate::playit_agent::ServiceExit::Cancelled, _ = tokio::time::sleep_until(next_send) => { last_establish_send = Instant::now(); self.send_establish().await; continue; } session_res = self.session_rx.recv() => { - let Some(details) = session_res else { break }; + let Some(details) = session_res else { + return crate::playit_agent::ServiceExit::QueueClosed("udp session".to_owned()); + }; self.handle_session(details).await; continue; } to_send_res = self.send_rx.recv() => { - let Some((flow, to_send)) = to_send_res else { break }; + let Some((flow, to_send)) = to_send_res else { + return crate::playit_agent::ServiceExit::QueueClosed("udp send".to_owned()); + }; self.send(flow, to_send).await; continue; } @@ -240,14 +319,26 @@ impl SendTask { } impl RecvTask { - async fn start(self) { - let mut packet = self.packets.allocate_wait().await; + async fn start(self, cancel: CancellationToken) -> crate::playit_agent::ServiceExit { + let mut packet = tokio::select! { + _ = cancel.cancelled() => return crate::playit_agent::ServiceExit::Cancelled, + packet = self.packets.allocate_wait() => packet, + }; loop { - let Ok((bytes, source)) = self.socket.recv_from(packet.full_slice_mut()).await else { - udp_errors().recv_io_error.inc(); - tokio::time::sleep(Duration::from_millis(20)).await; - continue; + let recv = tokio::select! { + _ = cancel.cancelled() => return crate::playit_agent::ServiceExit::Cancelled, + recv = self.socket.recv_from(packet.full_slice_mut()) => recv, + }; + let (bytes, source) = match recv { + Ok(received) => received, + Err(error) => { + udp_errors().recv_io_error.inc(); + return crate::playit_agent::ServiceExit::Io { + operation: "receive tunnel UDP packet".to_owned(), + message: error.to_string(), + }; + } }; let Some(session_addr) = *self.shared.session_tunnel_addr.read().unwrap() else { @@ -284,9 +375,54 @@ impl RecvTask { .expect("failed to remove udp footer"); if self.recv_tx.send((flow, packet)).await.is_err() { - break; + return crate::playit_agent::ServiceExit::QueueClosed("udp receive".to_owned()); } - packet = self.packets.allocate_wait().await; + packet = tokio::select! { + _ = cancel.cancelled() => return crate::playit_agent::ServiceExit::Cancelled, + packet = self.packets.allocate_wait() => packet, + }; } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn closed_service_queues_return_errors() { + let (mut channel, tasks) = UdpChannel::new(Packets::new(1)) + .await + .expect("bind UDP channel"); + drop(tasks); + + let details = UdpChannelDetails { + tunnel_addr: "127.0.0.1:1".parse().expect("fixture address"), + token: Arc::new(vec![1]), + }; + assert_eq!( + channel.update_session(details).await, + Err(UdpChannelError::SessionQueueClosed) + ); + assert!(matches!( + channel.recv().await, + Err(UdpChannelError::ReceiveQueueClosed) + )); + } + + #[tokio::test] + async fn cancellation_joins_both_channel_tasks() { + let (_channel, tasks) = UdpChannel::new(Packets::new(1)) + .await + .expect("bind UDP channel"); + let cancel = CancellationToken::new(); + let task_cancel = cancel.clone(); + let task = tokio::spawn(tasks.run(task_cancel)); + cancel.cancel(); + let (_, exit) = tokio::time::timeout(Duration::from_secs(1), task) + .await + .expect("UDP channel stopped before its deadline") + .expect("UDP channel task did not panic"); + assert_eq!(exit, crate::playit_agent::ServiceExit::Cancelled); + } +} diff --git a/packages/agent_core/src/network/udp/udp_clients.rs b/packages/agent_core/src/network/udp/udp_clients.rs index 39fcd9c5..ba68264c 100644 --- a/packages/agent_core/src/network/udp/udp_clients.rs +++ b/packages/agent_core/src/network/udp/udp_clients.rs @@ -6,10 +6,11 @@ use std::{ Arc, atomic::{AtomicU32, Ordering}, }, + time::Duration, }; use governor::{DefaultDirectRateLimiter, Quota, RateLimiter}; -use playit_api_client::api::ProxyProtocol; +use playit_model::ProxyProtocol; use slab::Slab; use tokio::{ net::UdpSocket, @@ -101,12 +102,13 @@ impl UdpClients { packets: Packets, stats: AgentStats, ) -> Self { - let (origin_tx, origin_rx) = channel(2048); + let queue_capacity = settings.queue_capacity.max(1); + let (origin_tx, origin_rx) = channel(queue_capacity); UdpClients { lookup, virtual_client_lookup: HashMap::new(), - virtual_clients: Slab::with_capacity(2048), + virtual_clients: Slab::with_capacity(queue_capacity), next_client_generation: AtomicU32::new(1), setup: UdpReceiverSetup { output: origin_tx, @@ -118,7 +120,7 @@ impl UdpClients { } } - pub async fn clear_old(&mut self, now_ms: u64) { + pub async fn clear_old(&mut self, now_ms: u64) -> Result<(), crate::playit_agent::ServiceExit> { let mut to_remove = Vec::new(); for (slot, client) in self.virtual_clients.iter_mut() { @@ -146,20 +148,37 @@ impl UdpClients { for slot in to_remove { let client = self.virtual_clients.remove(slot); - let removed = self.virtual_client_lookup.remove(&client.key).unwrap(); - assert_eq!(removed, slot); - client.receiver.shutdown().await; + let removed = self.virtual_client_lookup.remove(&client.key); + if removed != Some(slot) { + return Err(crate::playit_agent::ServiceExit::Failed( + "UDP flow index disagrees with the flow table".to_owned(), + )); + } + let exit = client.receiver.shutdown(Duration::from_secs(2)).await; + if exit.is_fatal_child_failure() { + return Err(exit); + } + if !matches!( + exit, + crate::playit_agent::ServiceExit::Cancelled + | crate::playit_agent::ServiceExit::Completed + | crate::playit_agent::ServiceExit::CleanEof + ) { + tracing::warn!(flow_id = client.id, ?exit, "UDP flow task ended"); + } } // Update active UDP count self.stats.set_udp(self.virtual_clients.len() as u32); + Ok(()) } - pub async fn recv_origin_packet(&mut self) -> UdpReceivedPacket { - self.rx - .recv() - .await - .expect("should never close with local reference") + pub async fn recv_origin_packet( + &mut self, + ) -> Result { + self.rx.recv().await.ok_or_else(|| { + crate::playit_agent::ServiceExit::QueueClosed("udp origin receive".to_owned()) + }) } pub async fn dispatch_origin_packet( @@ -269,6 +288,11 @@ impl UdpClients { return; } + if self.virtual_clients.len() >= 2048 { + udp_errors().new_client_ratelimit.inc(); + return; + } + let Some(target_addr) = origin.resolve_local(extension.port_offset).await else { return; }; @@ -292,11 +316,14 @@ impl UdpClients { let entry = self.virtual_clients.vacant_entry(); let slot = entry.key(); - let slot = u32::try_from(slot).expect("udp client slot overflow"); + let Ok(slot) = u32::try_from(slot) else { + udp_errors().new_client_ratelimit.inc(); + return; + }; let generation = self.next_client_generation.fetch_add(1, Ordering::Relaxed); let id = pack_client_id(slot, generation); - let receiver = self.setup.create(id, socket.clone()); + let receiver = self.setup.spawn(id, socket.clone()); let mut client_flow = flow.flip(); match &mut client_flow { @@ -338,7 +365,7 @@ impl UdpClients { }; if let Some(proto) = origin.proxy_protocol { - if proto != ProxyProtocol::ProxyProtocolV2 { + if proto != ProxyProtocol::V2 { udp_errors().origin_v1_proxy_protocol.inc(); } else { let header = ProxyProtocolHeader::from_udp_flow(&flow); @@ -363,13 +390,28 @@ impl UdpClients { udp_errors().origin_send_io_error.inc(); } - self.virtual_client_lookup.insert( - key, - usize::try_from(slot).expect("udp client slot overflow"), - ); + let slot = slot as usize; + self.virtual_client_lookup.insert(key, slot); entry.insert(client); // Update active UDP count for new client self.stats.set_udp(self.virtual_clients.len() as u32); } + + pub async fn shutdown(mut self, deadline: Duration) -> crate::playit_agent::ServiceExit { + let stop_at = tokio::time::Instant::now() + deadline; + for client in self.virtual_clients.drain() { + self.virtual_client_lookup.remove(&client.key); + let remaining = stop_at.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return crate::playit_agent::ServiceExit::DeadlineExceeded; + } + let exit = client.receiver.shutdown(remaining).await; + if matches!(exit, crate::playit_agent::ServiceExit::DeadlineExceeded) { + return exit; + } + } + self.stats.set_udp(0); + crate::playit_agent::ServiceExit::Cancelled + } } diff --git a/packages/agent_core/src/network/udp/udp_receiver.rs b/packages/agent_core/src/network/udp/udp_receiver.rs index dc6b6195..d2648cd4 100644 --- a/packages/agent_core/src/network/udp/udp_receiver.rs +++ b/packages/agent_core/src/network/udp/udp_receiver.rs @@ -15,23 +15,20 @@ pub struct UdpReceiverSetup { pub struct UdpReceiver { id: u64, cancel: CancellationToken, - end: Option>, - closed: bool, + task: Option>, } impl UdpReceiverSetup { - pub fn create(&self, id: u64, rx: I) -> UdpReceiver { + pub fn spawn(&self, id: u64, rx: I) -> UdpReceiver { let cancel = CancellationToken::new(); - let (end_tx, end_rx) = tokio::sync::oneshot::channel(); - tokio::spawn( + let task = tokio::spawn( Task { id, rx, packets: self.packets.clone(), output: self.output.clone(), cancel: cancel.clone(), - end: end_tx, } .start(), ); @@ -39,8 +36,7 @@ impl UdpReceiverSetup { UdpReceiver { id, cancel, - end: Some(end_rx), - closed: false, + task: Some(task), } } } @@ -50,35 +46,35 @@ impl UdpReceiver { self.id } - pub fn is_closed(&mut self) -> bool { - if !self.closed { - self.closed = match self.end.as_mut().unwrap().try_recv() { - Ok(_) => true, - Err(tokio::sync::oneshot::error::TryRecvError::Closed) => true, - Err(tokio::sync::oneshot::error::TryRecvError::Empty) => false, - }; - } - self.closed + pub fn is_closed(&self) -> bool { + self.task + .as_ref() + .is_none_or(tokio::task::JoinHandle::is_finished) } - pub async fn shutdown(mut self) { + pub async fn shutdown(mut self, deadline: Duration) -> crate::playit_agent::ServiceExit { self.cancel.cancel(); - - let timedout = tokio::time::timeout(Duration::from_secs(15), self.end.take().unwrap()) - .await - .is_err(); - - if timedout { - panic!("Timeout waiting for UdpReceiver to shutdown 15s+"); + let Some(mut task) = self.task.take() else { + return crate::playit_agent::ServiceExit::Completed; + }; + match tokio::time::timeout(deadline, &mut task).await { + Ok(Ok(exit)) => exit, + Ok(Err(error)) => crate::playit_agent::ServiceExit::Panicked(error.to_string()), + Err(_) => { + task.abort(); + let _ = task.await; + crate::playit_agent::ServiceExit::DeadlineExceeded + } } - - self.closed = true; } } impl Drop for UdpReceiver { fn drop(&mut self) { self.cancel.cancel(); + if let Some(task) = &self.task { + task.abort(); + } } } @@ -87,7 +83,6 @@ struct Task { rx: I, packets: Packets, cancel: CancellationToken, - end: tokio::sync::oneshot::Sender<()>, output: Sender, } @@ -98,23 +93,30 @@ pub struct UdpReceivedPacket { } impl Task { - async fn start(self) { + async fn start(self) -> crate::playit_agent::ServiceExit { let mut next_error_log_allowed = tokio::time::Instant::now(); + let mut consecutive_errors = 0u8; - 'rx_loop: loop { + loop { let mut packet = tokio::select! { - _ = self.cancel.cancelled() => break 'rx_loop, + _ = self.cancel.cancelled() => return crate::playit_agent::ServiceExit::Cancelled, p = self.packets.allocate_wait() => p, }; let res = tokio::select! { - _ = self.cancel.cancelled() => break, + _ = self.cancel.cancelled() => return crate::playit_agent::ServiceExit::Cancelled, res = self.rx.recv_from(packet.full_slice_mut()) => res, }; let packet = match res { Ok((bytes, source)) => { - packet.set_len(bytes).unwrap(); + consecutive_errors = 0; + if let Err(error) = packet.set_len(bytes) { + return crate::playit_agent::ServiceExit::Io { + operation: "resize origin UDP packet".to_owned(), + message: error.to_string(), + }; + } UdpReceivedPacket { rx_id: self.id, @@ -123,12 +125,20 @@ impl Task { } } Err(error) => { + consecutive_errors = consecutive_errors.saturating_add(1); let now = tokio::time::Instant::now(); if next_error_log_allowed <= now { tracing::warn!(?error, id = self.id, "failed to receive UDP packet"); next_error_log_allowed = now + Duration::from_secs(1); } + if consecutive_errors >= 8 { + return crate::playit_agent::ServiceExit::Io { + operation: "receive origin UDP packet".to_owned(), + message: error.to_string(), + }; + } + tokio::time::sleep(Duration::from_millis(20)).await; continue; } }; @@ -139,10 +149,79 @@ impl Task { .await; match result { Some(Ok(_)) => {} - None | Some(Err(_)) => break, + None => return crate::playit_agent::ServiceExit::Cancelled, + Some(Err(_)) => { + return crate::playit_agent::ServiceExit::QueueClosed("udp origin".to_owned()); + } } } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::{Ipv4Addr, SocketAddrV4}; + + struct FailedReceiver; - let _ = self.end.send(()); + impl PacketRx for FailedReceiver { + async fn recv_from(&self, _buf: &mut [u8]) -> std::io::Result<(usize, SocketAddr)> { + Err(std::io::Error::new( + std::io::ErrorKind::ConnectionReset, + "fixture socket failure", + )) + } + } + + struct OnePacketReceiver; + + impl PacketRx for OnePacketReceiver { + async fn recv_from(&self, buf: &mut [u8]) -> std::io::Result<(usize, SocketAddr)> { + buf[0] = 1; + Ok((1, SocketAddrV4::new(Ipv4Addr::LOCALHOST, 1234).into())) + } + } + + async fn wait_until_closed(receiver: &UdpReceiver) { + tokio::time::timeout(Duration::from_secs(1), async { + while !receiver.is_closed() { + tokio::task::yield_now().await; + } + }) + .await + .expect("UDP receiver stopped"); + } + + #[tokio::test] + async fn repeated_socket_failure_reaches_the_owner() { + let (output, _input) = tokio::sync::mpsc::channel(1); + let setup = UdpReceiverSetup { + packets: Packets::new(1), + output, + }; + let receiver = setup.spawn(1, FailedReceiver); + wait_until_closed(&receiver).await; + assert!(matches!( + receiver.shutdown(Duration::from_secs(1)).await, + crate::playit_agent::ServiceExit::Io { operation, .. } + if operation == "receive origin UDP packet" + )); + } + + #[tokio::test] + async fn closed_origin_queue_reaches_the_owner() { + let (output, input) = tokio::sync::mpsc::channel(1); + drop(input); + let setup = UdpReceiverSetup { + packets: Packets::new(1), + output, + }; + let receiver = setup.spawn(1, OnePacketReceiver); + wait_until_closed(&receiver).await; + assert_eq!( + receiver.shutdown(Duration::from_secs(1)).await, + crate::playit_agent::ServiceExit::QueueClosed("udp origin".to_owned()) + ); } } diff --git a/packages/agent_core/src/network/udp/udp_settings.rs b/packages/agent_core/src/network/udp/udp_settings.rs index 1eabd0d2..2f5a88a5 100644 --- a/packages/agent_core/src/network/udp/udp_settings.rs +++ b/packages/agent_core/src/network/udp/udp_settings.rs @@ -2,6 +2,7 @@ pub struct UdpSettings { pub new_client_ratelimit: u32, pub new_client_ratelimit_burst: u32, + pub queue_capacity: usize, } impl Default for UdpSettings { @@ -9,6 +10,7 @@ impl Default for UdpSettings { UdpSettings { new_client_ratelimit: 16, new_client_ratelimit_burst: 32, + queue_capacity: 2048, } } } diff --git a/packages/agent_core/src/playit_agent.rs b/packages/agent_core/src/playit_agent.rs index a24e139c..060c24ea 100644 --- a/packages/agent_core/src/playit_agent.rs +++ b/packages/agent_core/src/playit_agent.rs @@ -1,58 +1,223 @@ +use std::collections::HashSet; +use std::num::NonZeroUsize; use std::sync::{ Arc, atomic::{AtomicBool, Ordering}, }; use std::time::Duration; -use tokio::sync::mpsc::channel; +use tokio::sync::mpsc::{channel, error::TrySendError}; +use tokio::task::JoinSet; use tokio::time::Instant; use tokio_util::sync::CancellationToken; -use tracing::Instrument; use crate::agent_control::errors::SetupError; use crate::agent_control::maintained_control::{MaintainedControl, TunnelControlEvent}; -use crate::agent_control::{AuthApi, DualStackUdpSocket}; +use crate::agent_control::{DualStackUdpSocket, GatewayAuth}; +use crate::gateway::PlayitGateway; use crate::network::origin_lookup::OriginLookup; -use crate::network::tcp::tcp_clients::TcpClients; +use crate::network::tcp::tcp_clients::{TcpClients, TcpService}; use crate::network::tcp::tcp_settings::TcpSettings; use crate::network::udp::packets::Packets; -use crate::network::udp::udp_channel::UdpChannel; +use crate::network::udp::udp_channel::{UdpChannel, UdpChannelTasks}; use crate::network::udp::udp_clients::UdpClients; use crate::network::udp::udp_settings::UdpSettings; use crate::stats::AgentStats; use crate::utils::now_milli; +use playit_agent_proto::control_messages::UdpChannelDetails; -pub struct PlayitAgent { - control: MaintainedControl, +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ServiceExit { + Completed, + Cancelled, + CleanEof, + QueueClosed(String), + Io { operation: String, message: String }, + DeadlineExceeded, + Failed(String), + Panicked(String), +} - udp_clients: UdpClients, - udp_channel: UdpChannel, +impl ServiceExit { + pub const fn is_fatal_child_failure(&self) -> bool { + matches!(self, Self::DeadlineExceeded | Self::Panicked(_)) + } +} - tcp_clients: TcpClients, - cancel_token: CancellationToken, +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum EngineService { + Control, + Tcp, + TcpClaim, + TcpPipe, + Udp, + UdpChannelSend, + UdpChannelRecv, + UdpFlow, + Stats, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EngineExit { + Cancelled, + Completed, + Service { + service: EngineService, + exit: ServiceExit, + }, + ShutdownTimedOut { + unfinished: Vec, + }, + Panicked(String), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct EngineLimits { + pub packet_count: NonZeroUsize, + pub shutdown_deadline: Duration, + pub udp_flow_drain_deadline: Duration, +} + +impl Default for EngineLimits { + fn default() -> Self { + Self { + packet_count: NonZeroUsize::new(8 * 1024).expect("default packet count is non-zero"), + shutdown_deadline: Duration::from_secs(5), + udp_flow_drain_deadline: Duration::from_secs(4), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EngineLimitsError { + ZeroPacketCount, + ZeroShutdownDeadline, + ZeroUdpFlowDrainDeadline, +} + +impl EngineLimits { + pub fn try_new( + packet_count: usize, + shutdown_deadline: Duration, + udp_flow_drain_deadline: Duration, + ) -> Result { + let packet_count = + NonZeroUsize::new(packet_count).ok_or(EngineLimitsError::ZeroPacketCount)?; + if shutdown_deadline.is_zero() { + return Err(EngineLimitsError::ZeroShutdownDeadline); + } + if udp_flow_drain_deadline.is_zero() { + return Err(EngineLimitsError::ZeroUdpFlowDrainDeadline); + } + Ok(Self { + packet_count, + shutdown_deadline, + udp_flow_drain_deadline, + }) + } +} + +pub struct PlayitAgent { + supervisor: EngineSupervisor, + cancel: CancellationToken, stats: AgentStats, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Default)] pub struct PlayitAgentSettings { - pub api_url: String, - pub secret_key: String, + pub control_settings: ControlSettings, pub tcp_settings: TcpSettings, pub udp_settings: UdpSettings, + pub limits: EngineLimits, +} + +#[derive(Clone, Debug)] +pub struct ControlSettings { + pub connect_timeout: Duration, + pub retry_delay: Duration, + pub event_queue_capacity: usize, +} + +impl Default for ControlSettings { + fn default() -> Self { + Self { + connect_timeout: Duration::from_secs(10), + retry_delay: Duration::from_secs(2), + event_queue_capacity: 1024, + } + } +} + +pub struct EngineSupervisor { + control: MaintainedControl, + udp_clients: UdpClients, + udp_channel: UdpChannel, + udp_channel_tasks: UdpChannelTasks, + tcp_clients: TcpClients, + tcp_service: TcpService, + cancel: CancellationToken, + stats: AgentStats, + limits: EngineLimits, + control_event_queue_capacity: usize, } impl PlayitAgent { pub async fn new( settings: PlayitAgentSettings, lookup: Arc, + gateway: Arc, + ) -> Result { + let supervisor = EngineSupervisor::new(settings, lookup, gateway).await?; + let cancel = supervisor.cancellation_token(); + let stats = supervisor.stats(); + Ok(Self { + supervisor, + cancel, + stats, + }) + } + + pub fn cancellation_token(&self) -> CancellationToken { + self.cancel.clone() + } + + pub fn stats(&self) -> AgentStats { + self.stats.clone() + } + + pub fn into_supervisor(self) -> EngineSupervisor { + self.supervisor + } + + pub async fn run(self) -> EngineExit { + self.supervisor.run().await + } +} + +impl EngineSupervisor { + pub async fn new( + settings: PlayitAgentSettings, + lookup: Arc, + gateway: Arc, ) -> Result { let io = DualStackUdpSocket::new().await?; - let auth = AuthApi::new(settings.api_url, settings.secret_key); - let control = MaintainedControl::setup(io, auth).await?; + let auth = GatewayAuth::new(gateway); + let control_event_queue_capacity = settings.control_settings.event_queue_capacity.max(1); + let control = MaintainedControl::setup_with_policy( + io, + auth, + settings.control_settings.connect_timeout, + settings.control_settings.retry_delay, + ) + .await?; - let tunnel_packets = Packets::new(1024 * 8); - let origin_packets = Packets::new(1024 * 8); - let udp_channel = UdpChannel::new(tunnel_packets) + let tunnel_packets = Packets::try_new(settings.limits.packet_count).map_err(|_| { + SetupError::IoError(std::io::Error::other("invalid tunnel packet pool")) + })?; + let origin_packets = Packets::try_new(settings.limits.packet_count).map_err(|_| { + SetupError::IoError(std::io::Error::other("invalid origin packet pool")) + })?; + let (udp_channel, udp_channel_tasks) = UdpChannel::new(tunnel_packets) .await .map_err(SetupError::IoError)?; @@ -63,186 +228,326 @@ impl PlayitAgent { origin_packets, stats.clone(), ); - let cancel_token = CancellationToken::new(); - let tcp_clients = TcpClients::new( + let cancel = CancellationToken::new(); + let (tcp_clients, tcp_service) = TcpClients::new( settings.tcp_settings, - lookup.clone(), + lookup, stats.clone(), - cancel_token.child_token(), + cancel.child_token(), ); - Ok(PlayitAgent { + Ok(Self { control, udp_clients, udp_channel, + udp_channel_tasks, tcp_clients, - cancel_token, + tcp_service, + cancel, stats, + limits: settings.limits, + control_event_queue_capacity, }) } pub fn cancellation_token(&self) -> CancellationToken { - self.cancel_token.clone() + self.cancel.clone() } - /// Get a handle to the agent stats pub fn stats(&self) -> AgentStats { self.stats.clone() } - pub async fn run(self) { - let PlayitAgent { + pub async fn run(self) -> EngineExit { + let Self { mut control, - udp_clients, - udp_channel, + mut udp_clients, + mut udp_channel, + udp_channel_tasks, tcp_clients, - cancel_token, - .. + tcp_service, + cancel, + stats, + limits, + control_event_queue_capacity, } = self; - let (udp_session_tx, mut udp_session_rx) = channel(8); + let (udp_session_tx, mut udp_session_rx) = channel(control_event_queue_capacity); let udp_session_should_renew = Arc::new(AtomicBool::new(false)); + let mut tasks = JoinSet::new(); + let mut active = HashSet::new(); - let tunnel_cancel = cancel_token.child_token(); + active.insert(EngineService::Control); + let control_cancel = cancel.child_token(); let should_renew_udp = udp_session_should_renew.clone(); - let mut tunnel_task = tokio::spawn(async move { + tasks.spawn(async move { let mut last_control_addr_check = now_milli(); - loop { - // Keep the control loop cooperative when updates are continuously ready. tokio::select! { - _ = tunnel_cancel.cancelled() => break, + _ = control_cancel.cancelled() => { + return (EngineService::Control, ServiceExit::Cancelled); + } _ = tokio::task::yield_now() => {} } if should_renew_udp.load(Ordering::Acquire) { - let Some(sent) = tunnel_cancel + let Some(sent) = control_cancel .run_until_cancelled(control.send_udp_session_auth(now_milli(), 5_000)) .await else { - break; + return (EngineService::Control, ServiceExit::Cancelled); }; if sent { - tracing::debug!("udp channel requires auth, sent auth request"); + tracing::debug!("requested new UDP session credentials"); } } let now = now_milli(); if 30_000 < now.saturating_sub(last_control_addr_check) { last_control_addr_check = now; - - let reload = - control.reload_control_addr(async { DualStackUdpSocket::new().await }); - if let Some(Err(error)) = tunnel_cancel.run_until_cancelled(reload).await { - tracing::error!(?error, "failed to reload_control_addr"); + let reload = control.reload_control_addr(DualStackUdpSocket::new()); + if let Some(Err(error)) = control_cancel.run_until_cancelled(reload).await { + tracing::error!(?error, "failed to reload control address"); } } let update = tokio::select! { - _ = tunnel_cancel.cancelled() => break, + _ = control_cancel.cancelled() => { + return (EngineService::Control, ServiceExit::Cancelled); + } update = control.update() => update, }; match update { Some(TunnelControlEvent::NewClient(new_client)) => { - tokio::select! { - _ = tunnel_cancel.cancelled() => break, - _ = tcp_clients.handle_new_client(new_client) => {} + if tcp_clients.handle_new_client(new_client).await.is_err() { + return ( + EngineService::Control, + ServiceExit::QueueClosed("tcp admission".to_owned()), + ); } } - Some(TunnelControlEvent::UdpChannelDetails(udp_details)) => { - tracing::debug!("udp session details received"); - let _ = udp_session_tx.try_send(udp_details); + Some(TunnelControlEvent::UdpChannelDetails(details)) => { + if let Err(exit) = enqueue_udp_session(&udp_session_tx, details) { + return (EngineService::Control, exit); + } } None => {} } } }); - let udp_cancel = cancel_token.child_token(); - let mut udp_channel = udp_channel; - let mut udp_clients = udp_clients; + active.insert(EngineService::Tcp); + tasks.spawn(tcp_service.run()); - let mut udp_task = tokio::spawn(async move { - let mut next_clear = Instant::now() + Duration::from_secs(16); + active.insert(EngineService::UdpChannelSend); + active.insert(EngineService::UdpChannelRecv); + let channel_cancel = cancel.child_token(); + tasks.spawn(async move { udp_channel_tasks.run(channel_cancel).await }); - loop { - // Keep the UDP packet loop cooperative under sustained bidirectional traffic. + active.insert(EngineService::Udp); + let udp_cancel = cancel.child_token(); + tasks.spawn(async move { + let mut next_clear = Instant::now() + Duration::from_secs(16); + let (mut service, mut exit) = loop { tokio::select! { - _ = udp_cancel.cancelled() => break, + _ = udp_cancel.cancelled() => break (EngineService::Udp, ServiceExit::Cancelled), _ = tokio::task::yield_now() => {} } tokio::select! { - _ = udp_cancel.cancelled() => break, + _ = udp_cancel.cancelled() => break (EngineService::Udp, ServiceExit::Cancelled), recv = udp_clients.recv_origin_packet() => { - let Some((flow, packet)) = udp_clients.dispatch_origin_packet(now_milli(), recv).await else { continue }; - udp_channel.send(flow, packet).await; + let recv = match recv { + Ok(recv) => recv, + Err(exit) => break (EngineService::Udp, exit), + }; + let Some((flow, packet)) = udp_clients.dispatch_origin_packet(now_milli(), recv).await else { + continue; + }; + if udp_channel.send(flow, packet).await.is_err() { + break (EngineService::Udp, ServiceExit::QueueClosed("udp channel send".to_owned())); + } } - (flow, packet) = udp_channel.recv() => { + recv = udp_channel.recv() => { + let (flow, packet) = match recv { + Ok(packet) => packet, + Err(_) => break (EngineService::Udp, ServiceExit::QueueClosed("udp channel receive".to_owned())), + }; udp_clients.handle_tunneled_packet(now_milli(), flow, packet).await; } - session_opt = udp_session_rx.recv() => { - let Some(session) = session_opt else { - tracing::debug!("udp session channel closed"); - break; + session = udp_session_rx.recv() => { + let Some(session) = session else { + break (EngineService::Udp, ServiceExit::QueueClosed("udp session".to_owned())); }; - udp_channel.update_session(session).await; + if udp_channel.update_session(session).await.is_err() { + break (EngineService::Udp, ServiceExit::QueueClosed("udp channel session".to_owned())); + } } _ = tokio::time::sleep_until(next_clear) => { next_clear = Instant::now() + Duration::from_secs(16); - udp_clients.clear_old(now_milli()).await; + if let Err(exit) = udp_clients.clear_old(now_milli()).await { + break (EngineService::UdpFlow, exit); + } } _ = tokio::time::sleep(Duration::from_secs(3)) => {} } - { - let udp_needs_renew = match udp_channel.time_since_established() { - Some(since) => Duration::from_secs(6) <= since, - None => true, - }; - udp_session_should_renew.store(udp_needs_renew, Ordering::Release); - } + let renew = udp_channel + .time_since_established() + .is_none_or(|since| Duration::from_secs(6) <= since); + udp_session_should_renew.store(renew, Ordering::Release); + }; + + let drain_exit = udp_clients.shutdown(limits.udp_flow_drain_deadline).await; + if matches!(drain_exit, ServiceExit::DeadlineExceeded) { + service = EngineService::UdpFlow; + exit = drain_exit; } - }.instrument(tracing::info_span!("udp_session"))); - - let mut tunnel_done = false; - let mut udp_done = false; - tokio::select! { - result = &mut tunnel_task => { - tunnel_done = true; - if let Err(error) = result { - tracing::error!(?error, "tunnel task failed"); + (service, exit) + }); + + active.insert(EngineService::Stats); + let stats_cancel = cancel.child_token(); + tasks.spawn(async move { + let mut tick = tokio::time::interval(Duration::from_millis(100)); + loop { + tokio::select! { + _ = stats_cancel.cancelled() => { + return (EngineService::Stats, ServiceExit::Cancelled); + } + _ = tick.tick() => { + let _ = stats.snapshot(); + } } } - result = &mut udp_task => { - udp_done = true; - if let Err(error) = result { - tracing::error!(?error, "udp task failed"); + }); + + let root_exit = tokio::select! { + biased; + _ = cancel.cancelled() => EngineExit::Cancelled, + result = tasks.join_next() => match result { + Some(Ok((service, exit))) => { + active.remove(&service); + if matches!(service, EngineService::TcpClaim | EngineService::TcpPipe) { + active.remove(&EngineService::Tcp); + } + if matches!(service, EngineService::UdpFlow) { + active.remove(&EngineService::Udp); + } + if matches!(service, EngineService::UdpChannelSend | EngineService::UdpChannelRecv) { + active.remove(&EngineService::UdpChannelSend); + active.remove(&EngineService::UdpChannelRecv); + } + EngineExit::Service { service, exit } + } + Some(Err(error)) => EngineExit::Panicked(error.to_string()), + None => EngineExit::Completed, + }, + }; + + cancel.cancel(); + let deadline = Instant::now() + limits.shutdown_deadline; + while !tasks.is_empty() { + let next = tokio::time::timeout_at(deadline, tasks.join_next()).await; + match next { + Ok(Some(Ok((service, _)))) => { + active.remove(&service); + if matches!(service, EngineService::TcpClaim | EngineService::TcpPipe) { + active.remove(&EngineService::Tcp); + } + if matches!(service, EngineService::UdpFlow) { + active.remove(&EngineService::Udp); + } + if matches!( + service, + EngineService::UdpChannelSend | EngineService::UdpChannelRecv + ) { + active.remove(&EngineService::UdpChannelSend); + active.remove(&EngineService::UdpChannelRecv); + } + } + Ok(Some(Err(_))) => {} + Ok(None) => break, + Err(_) => { + tasks.abort_all(); + while tasks.join_next().await.is_some() {} + return EngineExit::ShutdownTimedOut { + unfinished: active.into_iter().collect(), + }; } } - _ = cancel_token.cancelled() => {} } + root_exit + } +} - cancel_token.cancel(); +fn enqueue_udp_session( + sender: &tokio::sync::mpsc::Sender, + details: UdpChannelDetails, +) -> Result<(), ServiceExit> { + match sender.try_send(details) { + Ok(()) | Err(TrySendError::Full(_)) => Ok(()), + Err(TrySendError::Closed(_)) => Err(ServiceExit::QueueClosed("udp session".to_owned())), + } +} - if !tunnel_done { - if tokio::time::timeout(Duration::from_secs(5), &mut tunnel_task) - .await - .is_err() - { - tunnel_task.abort(); - let _ = tunnel_task.await; - } +#[cfg(test)] +mod tests { + use super::*; + + fn udp_details() -> UdpChannelDetails { + UdpChannelDetails { + tunnel_addr: "127.0.0.1:1".parse().expect("fixture address"), + token: Arc::new(vec![1]), } - if !udp_done { - if tokio::time::timeout(Duration::from_secs(5), &mut udp_task) - .await - .is_err() - { - udp_task.abort(); - let _ = udp_task.await; + } + + #[test] + fn engine_limits_reject_zero_bounds() { + assert_eq!( + EngineLimits::try_new(0, Duration::from_secs(1), Duration::from_secs(1)), + Err(EngineLimitsError::ZeroPacketCount) + ); + assert_eq!( + EngineLimits::try_new(1, Duration::ZERO, Duration::from_secs(1)), + Err(EngineLimitsError::ZeroShutdownDeadline) + ); + assert_eq!( + EngineLimits::try_new(1, Duration::from_secs(1), Duration::ZERO), + Err(EngineLimitsError::ZeroUdpFlowDrainDeadline) + ); + } + + #[test] + fn control_queue_closure_has_a_typed_exit() { + let (sender, receiver) = tokio::sync::mpsc::channel(1); + drop(receiver); + assert_eq!( + enqueue_udp_session(&sender, udp_details()), + Err(ServiceExit::QueueClosed("udp session".to_owned())) + ); + } + + #[test] + fn control_queue_overload_stays_bounded() { + let (sender, mut receiver) = tokio::sync::mpsc::channel(1); + enqueue_udp_session(&sender, udp_details()).expect("first update queued"); + enqueue_udp_session(&sender, udp_details()).expect("overload is coalesced"); + assert!(receiver.try_recv().is_ok()); + assert!(receiver.try_recv().is_err()); + } + + #[test] + fn ordinary_connection_io_is_not_a_service_failure() { + assert!( + !ServiceExit::Io { + operation: "read TCP stream".to_owned(), + message: "connection reset".to_owned(), } - } + .is_fatal_child_failure() + ); + assert!(ServiceExit::Panicked("fixture".to_owned()).is_fatal_child_failure()); + assert!(ServiceExit::DeadlineExceeded.is_fatal_child_failure()); } } diff --git a/packages/agent_core/src/utils/id_slab.rs b/packages/agent_core/src/utils/id_slab.rs index c1815691..702bb5f2 100644 --- a/packages/agent_core/src/utils/id_slab.rs +++ b/packages/agent_core/src/utils/id_slab.rs @@ -31,6 +31,10 @@ impl IdSlab { self.entries.len() } + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + pub fn available(&self) -> usize { self.capacity.saturating_sub(self.entries.len()) } diff --git a/packages/agent_core/src/utils/non_overlapping.rs b/packages/agent_core/src/utils/non_overlapping.rs index 32b0c296..16e83c9b 100644 --- a/packages/agent_core/src/utils/non_overlapping.rs +++ b/packages/agent_core/src/utils/non_overlapping.rs @@ -2,6 +2,12 @@ pub struct NonOverlapping { elements: Vec, } +impl Default for NonOverlapping { + fn default() -> Self { + Self::new() + } +} + impl NonOverlapping { pub fn new() -> Self { NonOverlapping { elements: vec![] } diff --git a/packages/agent_core/tests/udp_tunnel_integration.rs b/packages/agent_core/tests/udp_tunnel_integration.rs index 812a1d92..a8a89a0e 100644 --- a/packages/agent_core/tests/udp_tunnel_integration.rs +++ b/packages/agent_core/tests/udp_tunnel_integration.rs @@ -1,3 +1,5 @@ +#![allow(clippy::too_many_arguments)] + use std::{ collections::HashMap, net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6}, @@ -23,6 +25,7 @@ use playit_agent_proto::{ udp_proto::{UDP_CHANNEL_ESTABLISH_ID, UdpFlow, UdpFlowExtension}, }; use tokio::{net::UdpSocket, time::timeout}; +use tokio_util::sync::CancellationToken; const TEST_TIMEOUT: Duration = Duration::from_secs(3); const STRESS_TIMEOUT: Duration = Duration::from_secs(30); @@ -63,16 +66,15 @@ async fn encapsulated_udp_tunnel_relays_in_both_directions_and_recovers_same_flo Packets::new(64), stats.clone(), ); - let mut udp_channel = UdpChannel::new(Packets::new(64)) - .await - .expect("create udp channel"); + let mut udp_channel = test_udp_channel(64).await; udp_channel .update_session(UdpChannelDetails { tunnel_addr, token: Arc::new(b"test-session-token".to_vec()), }) - .await; + .await + .expect("UDP session queue open"); let (token_len, channel_addr, token_bytes) = recv_from_socket(&tunnel_server).await; assert_eq!(&token_bytes[..token_len], b"test-session-token"); @@ -89,7 +91,8 @@ async fn encapsulated_udp_tunnel_relays_in_both_directions_and_recovers_same_flo let (recv_flow_1, recv_packet_1) = timeout(TEST_TIMEOUT, udp_channel.recv()) .await - .expect("recv tunneled packet"); + .expect("recv tunneled packet") + .expect("udp channel open"); assert_eq!(recv_flow_1, flow); udp_clients .handle_tunneled_packet(1_000, recv_flow_1, recv_packet_1) @@ -107,12 +110,16 @@ async fn encapsulated_udp_tunnel_relays_in_both_directions_and_recovers_same_flo let reply_1 = timeout(TEST_TIMEOUT, udp_clients.recv_origin_packet()) .await - .expect("recv origin reply"); + .expect("recv origin reply") + .expect("udp origin queue open"); let (reply_flow_1, reply_packet_1) = udp_clients .dispatch_origin_packet(2_000, reply_1) .await .expect("dispatch origin reply"); - udp_channel.send(reply_flow_1, reply_packet_1).await; + udp_channel + .send(reply_flow_1, reply_packet_1) + .await + .expect("UDP send queue open"); let (encap_flow_1, encap_payload_1, encap_source_1) = recv_tunneled_packet(&tunnel_server).await; @@ -132,7 +139,8 @@ async fn encapsulated_udp_tunnel_relays_in_both_directions_and_recovers_same_flo let (changed_flow, changed_packet) = timeout(TEST_TIMEOUT, udp_channel.recv()) .await - .expect("recv packet after tunnel server change"); + .expect("recv packet after tunnel server change") + .expect("udp channel open"); udp_clients .handle_tunneled_packet(3_000, changed_flow, changed_packet) .await; @@ -153,7 +161,8 @@ async fn encapsulated_udp_tunnel_relays_in_both_directions_and_recovers_same_flo let changed_reply = timeout(TEST_TIMEOUT, udp_clients.recv_origin_packet()) .await - .expect("recv origin reply after tunnel server change"); + .expect("recv origin reply after tunnel server change") + .expect("udp origin queue open"); let (changed_reply_flow, changed_reply_packet) = udp_clients .dispatch_origin_packet(4_000, changed_reply) .await @@ -161,7 +170,10 @@ async fn encapsulated_udp_tunnel_relays_in_both_directions_and_recovers_same_flo assert_eq!(changed_reply_flow, flow_after_server_change.flip()); assert_eq!(changed_reply_packet.as_ref(), reply_after_server_change); - udp_clients.clear_old(100_000).await; + udp_clients + .clear_old(100_000) + .await + .expect("clear UDP flows"); assert_eq!(stats.active_udp(), 0); let origin_payload_2 = b"packet after clear"; @@ -169,7 +181,8 @@ async fn encapsulated_udp_tunnel_relays_in_both_directions_and_recovers_same_flo let (recv_flow_2, recv_packet_2) = timeout(TEST_TIMEOUT, udp_channel.recv()) .await - .expect("recv tunneled packet after clear"); + .expect("recv tunneled packet after clear") + .expect("udp channel open"); assert_eq!(recv_flow_2, flow); udp_clients .handle_tunneled_packet(101_000, recv_flow_2, recv_packet_2) @@ -188,12 +201,16 @@ async fn encapsulated_udp_tunnel_relays_in_both_directions_and_recovers_same_flo let reply_2 = timeout(TEST_TIMEOUT, udp_clients.recv_origin_packet()) .await - .expect("recv origin reply after clear"); + .expect("recv origin reply after clear") + .expect("udp origin queue open"); let (reply_flow_2, reply_packet_2) = udp_clients .dispatch_origin_packet(102_000, reply_2) .await .expect("dispatch origin reply after clear"); - udp_channel.send(reply_flow_2, reply_packet_2).await; + udp_channel + .send(reply_flow_2, reply_packet_2) + .await + .expect("UDP send queue open"); let (encap_flow_2, encap_payload_2, encap_source_2) = recv_tunneled_packet(&tunnel_server).await; @@ -235,16 +252,15 @@ async fn encapsulated_udp_tunnel_supports_ipv6_origin_addresses() { Packets::new(64), stats.clone(), ); - let mut udp_channel = UdpChannel::new(Packets::new(64)) - .await - .expect("create udp channel"); + let mut udp_channel = test_udp_channel(64).await; udp_channel .update_session(UdpChannelDetails { tunnel_addr, token: Arc::new(b"test-session-token".to_vec()), }) - .await; + .await + .expect("UDP session queue open"); let (token_len, channel_addr, token_bytes) = recv_from_socket(&tunnel_server).await; assert_eq!(&token_bytes[..token_len], b"test-session-token"); @@ -260,7 +276,8 @@ async fn encapsulated_udp_tunnel_supports_ipv6_origin_addresses() { let (recv_flow, recv_packet) = timeout(TEST_TIMEOUT, udp_channel.recv()) .await - .expect("recv tunneled packet"); + .expect("recv tunneled packet") + .expect("udp channel open"); assert_eq!(recv_flow, flow); udp_clients .handle_tunneled_packet(1_000, recv_flow, recv_packet) @@ -279,12 +296,16 @@ async fn encapsulated_udp_tunnel_supports_ipv6_origin_addresses() { let reply = timeout(TEST_TIMEOUT, udp_clients.recv_origin_packet()) .await - .expect("recv origin reply"); + .expect("recv origin reply") + .expect("udp origin queue open"); let (reply_flow, reply_packet) = udp_clients .dispatch_origin_packet(2_000, reply) .await .expect("dispatch origin reply"); - udp_channel.send(reply_flow, reply_packet).await; + udp_channel + .send(reply_flow, reply_packet) + .await + .expect("UDP send queue open"); let (encap_flow, encap_payload, encap_source) = recv_tunneled_packet(&tunnel_server).await; assert_eq!(encap_source, channel_addr); @@ -325,16 +346,15 @@ async fn encapsulated_udp_tunnel_isolates_multiple_parallel_flows_and_recovers_t Packets::new(128), stats.clone(), ); - let mut udp_channel = UdpChannel::new(Packets::new(128)) - .await - .expect("create udp channel"); + let mut udp_channel = test_udp_channel(128).await; udp_channel .update_session(UdpChannelDetails { tunnel_addr, token: Arc::new(b"test-session-token".to_vec()), }) - .await; + .await + .expect("UDP session queue open"); let (token_len, channel_addr, token_bytes) = recv_from_socket(&tunnel_server).await; assert_eq!(&token_bytes[..token_len], b"test-session-token"); @@ -381,7 +401,10 @@ async fn encapsulated_udp_tunnel_isolates_multiple_parallel_flows_and_recovers_t assert_eq!(stats.active_udp(), cases.len() as u32); assert_unique_virtual_addrs(&first_virtual_addrs); - udp_clients.clear_old(100_000).await; + udp_clients + .clear_old(100_000) + .await + .expect("clear UDP flows"); assert_eq!(stats.active_udp(), 0); let second_virtual_addrs = drive_parallel_flows( @@ -435,16 +458,15 @@ async fn udp_tunnel_stress_reports_bitrate_by_packet_size() { Packets::new(4096), stats.clone(), ); - let mut udp_channel = UdpChannel::new(Packets::new(4096)) - .await - .expect("create udp channel"); + let mut udp_channel = test_udp_channel(4096).await; udp_channel .update_session(UdpChannelDetails { tunnel_addr, token: Arc::new(b"test-session-token".to_vec()), }) - .await; + .await + .expect("UDP session queue open"); let (token_len, channel_addr, token_bytes) = recv_from_socket(&tunnel_server).await; assert_eq!(&token_bytes[..token_len], b"test-session-token"); @@ -588,7 +610,8 @@ async fn drive_parallel_flows( for _ in 0..cases.len() { let (recv_flow, recv_packet) = timeout(TEST_TIMEOUT, udp_channel.recv()) .await - .expect("recv tunneled packet"); + .expect("recv tunneled packet") + .expect("udp channel open"); udp_clients .handle_tunneled_packet(tunnel_ts, recv_flow, recv_packet) .await; @@ -623,12 +646,16 @@ async fn drive_parallel_flows( for _ in 0..cases.len() { let reply = timeout(TEST_TIMEOUT, udp_clients.recv_origin_packet()) .await - .expect("recv origin reply"); + .expect("recv origin reply") + .expect("udp origin queue open"); let (reply_flow, reply_packet) = udp_clients .dispatch_origin_packet(origin_ts, reply) .await .expect("dispatch origin reply"); - udp_channel.send(reply_flow, reply_packet).await; + udp_channel + .send(reply_flow, reply_packet) + .await + .expect("UDP send queue open"); } let cases_by_outbound: HashMap, &FlowCase> = cases @@ -681,7 +708,8 @@ async fn establish_virtual_client( let (recv_flow, recv_packet) = timeout(TEST_TIMEOUT, udp_channel.recv()) .await - .expect("recv warmup tunneled packet"); + .expect("recv warmup tunneled packet") + .expect("udp channel open"); assert_eq!(recv_flow, flow); udp_clients .handle_tunneled_packet(1_000, recv_flow, recv_packet) @@ -718,7 +746,7 @@ async fn measure_tunnel_to_origin_bitrate( } for i in 0..batch { - let (recv_flow, recv_packet) = udp_channel.recv().await; + let (recv_flow, recv_packet) = udp_channel.recv().await.expect("udp channel open"); assert_eq!(recv_flow, flow); udp_clients .handle_tunneled_packet(10_000 + (processed + i) as u64, recv_flow, recv_packet) @@ -773,13 +801,19 @@ async fn measure_origin_to_tunnel_bitrate( } for i in 0..batch { - let recv = udp_clients.recv_origin_packet().await; + let recv = udp_clients + .recv_origin_packet() + .await + .expect("udp origin queue open"); let (reply_flow, reply_packet) = udp_clients .dispatch_origin_packet(20_000 + (processed + i) as u64, recv) .await .expect("dispatch origin stress packet"); assert_eq!(reply_flow, flow.flip()); - udp_channel.send(reply_flow, reply_packet).await; + udp_channel + .send(reply_flow, reply_packet) + .await + .expect("UDP send queue open"); } for _ in 0..batch { @@ -839,10 +873,15 @@ async fn send_tunneled_packet( } async fn recv_tunneled_packet(socket: &UdpSocket) -> (UdpFlow, Vec, std::net::SocketAddr) { - let (len, source, buf) = recv_from_socket(socket).await; - let flow = UdpFlow::from_tail(&buf[..len]).expect("parse flow footer"); - let payload = buf[..len - flow.footer_len()].to_vec(); - (flow, payload, source) + loop { + let (len, source, buf) = recv_from_socket(socket).await; + if &buf[..len] == b"test-session-token" { + continue; + } + let flow = UdpFlow::from_tail(&buf[..len]).expect("parse flow footer"); + let payload = buf[..len - flow.footer_len()].to_vec(); + return (flow, payload, source); + } } async fn recv_from_socket(socket: &UdpSocket) -> (usize, std::net::SocketAddr, [u8; 2048]) { @@ -853,3 +892,11 @@ async fn recv_from_socket(socket: &UdpSocket) -> (usize, std::net::SocketAddr, [ .expect("udp receive"); (len, source, buf) } + +async fn test_udp_channel(packet_count: usize) -> UdpChannel { + let (channel, tasks) = UdpChannel::new(Packets::new(packet_count)) + .await + .expect("create UDP channel"); + tokio::spawn(tasks.run(CancellationToken::new())); + channel +} diff --git a/packages/agent_proto/fixtures/udp_flow.hex b/packages/agent_proto/fixtures/udp_flow.hex new file mode 100644 index 00000000..ba461634 --- /dev/null +++ b/packages/agent_proto/fixtures/udp_flow.hex @@ -0,0 +1,5 @@ +v4_legacy=c000020a7f00000163dd4abc5cb867cf788173b2 +v4_extension=c000020a7f00000163dd4abc000000000000000c000000000000007b000900005cb867cf78817399 +v4_fragment=c000020a7f00000163dd4abc000000000000000c000000000000007b000901000702015cb867cf78817399 +v6_legacy=20010db80000000000000000000000100000000000000000000000000000000163dd4abc000000006668676f68616366 +v6_extension=20010db80000000000000000000000100000000000000000000000000000000163dd4abc000000000000000c000000000000007b00096cb667cf78817369 diff --git a/packages/agent_proto/src/control_feed.rs b/packages/agent_proto/src/control_feed.rs index c0a61d9d..8f292513 100644 --- a/packages/agent_proto/src/control_feed.rs +++ b/packages/agent_proto/src/control_feed.rs @@ -1,5 +1,5 @@ use std::fmt::{Debug, Formatter}; -use std::io::{Error, ErrorKind, Read, Write}; +use std::io::{Error, Read, Write}; use std::net::SocketAddr; use byteorder::{BigEndian, ReadBytesExt}; @@ -80,7 +80,7 @@ impl MessageEncoding for ControlFeed { NewClientOld::read_from(read)?.into(), )), 3 => Ok(ControlFeed::NewClient(NewClient::read_from(read)?)), - _ => Err(Error::new(ErrorKind::Other, "invalid ControlFeed id")), + _ => Err(Error::other("invalid ControlFeed id")), } } } diff --git a/packages/agent_proto/src/control_messages.rs b/packages/agent_proto/src/control_messages.rs index 68f346cc..9482be1e 100644 --- a/packages/agent_proto/src/control_messages.rs +++ b/packages/agent_proto/src/control_messages.rs @@ -646,7 +646,7 @@ fn read_and_verify_mtu_pattern(read: &mut T, len: usize) -> std::io::Re read.read_exact(&mut buffer[..size])?; let offset = &MTU_TEST_PATTERN[i % MTU_PATTERN_SIZE..]; - if !(&buffer[..size]).eq(&offset[..size]) { + if !buffer[..size].eq(&offset[..size]) { return Err(std::io::Error::new( std::io::ErrorKind::InvalidData, "invalid mtu test pattern", @@ -710,10 +710,7 @@ impl MessageEncoding for AgentPortMappingFound { 1 => Ok(AgentPortMappingFound::ToAgent(AgentSessionId::read_from( read, )?)), - _ => Err(std::io::Error::new( - std::io::ErrorKind::Other, - "unknown AgentPortMappingFound id", - )), + _ => Err(std::io::Error::other("unknown AgentPortMappingFound id")), } } } @@ -969,12 +966,12 @@ mod test { match rng.next_u32() % 7 { 0 => ControlRequest::Ping(Ping { now: rng.next_u64(), - current_ping: if rng.next_u32() % 2 == 0 { + current_ping: if rng.next_u32().is_multiple_of(2) { Some(rng.next_u32()) } else { None }, - session_id: if rng.next_u32() % 2 == 0 { + session_id: if rng.next_u32().is_multiple_of(2) { Some(AgentSessionId { session_id: rng.next_u64(), account_id: rng.next_u64() % (i64::MAX as u64), diff --git a/packages/agent_proto/src/hmac.rs b/packages/agent_proto/src/hmac.rs index 0dcaf2c8..2f5cff9a 100644 --- a/packages/agent_proto/src/hmac.rs +++ b/packages/agent_proto/src/hmac.rs @@ -23,6 +23,7 @@ impl HmacSha256 { HmacSha256(Hmac::::new_from_slice(secret).unwrap()) } + #[allow(clippy::result_unit_err)] pub fn verify(&self, data: &[u8], sig: &[u8]) -> Result<(), ()> { let mut mac = self.0.clone(); mac.update(data); diff --git a/packages/agent_proto/src/lib.rs b/packages/agent_proto/src/lib.rs index f8821eeb..39155a36 100644 --- a/packages/agent_proto/src/lib.rs +++ b/packages/agent_proto/src/lib.rs @@ -1,4 +1,4 @@ -use std::io::{Error, ErrorKind, Read, Write}; +use std::io::{Error, Read, Write}; use std::net::IpAddr; use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; @@ -106,7 +106,7 @@ impl MessageEncoding for PortProto { 1 => Ok(PortProto::Tcp), 2 => Ok(PortProto::Udp), 3 => Ok(PortProto::Both), - _ => Err(Error::new(ErrorKind::Other, "invalid port proto")), + _ => Err(Error::other("invalid port proto")), } } } diff --git a/packages/agent_proto/tests/wire_fixtures.rs b/packages/agent_proto/tests/wire_fixtures.rs new file mode 100644 index 00000000..b5931f12 --- /dev/null +++ b/packages/agent_proto/tests/wire_fixtures.rs @@ -0,0 +1,77 @@ +use std::net::{Ipv6Addr, SocketAddrV4}; +use std::num::{NonZeroU16, NonZeroU64}; + +use playit_agent_proto::udp_proto::{FragmentInfo, UdpFlow, UdpFlowExtension}; + +fn fixtures() -> Vec<(&'static str, UdpFlow)> { + let extension = UdpFlowExtension { + client_server_id: NonZeroU64::new(12).unwrap(), + tunnel_id: NonZeroU64::new(123).unwrap(), + port_offset: 9, + }; + + vec![ + ( + "v4_legacy", + UdpFlow::V4 { + src: SocketAddrV4::new([192, 0, 2, 10].into(), 25565), + dst: SocketAddrV4::new([127, 0, 0, 1].into(), 19132), + frag: None, + extension: None, + }, + ), + ( + "v4_extension", + UdpFlow::V4 { + src: SocketAddrV4::new([192, 0, 2, 10].into(), 25565), + dst: SocketAddrV4::new([127, 0, 0, 1].into(), 19132), + frag: None, + extension: Some(extension), + }, + ), + ( + "v4_fragment", + UdpFlow::V4 { + src: SocketAddrV4::new([192, 0, 2, 10].into(), 25565), + dst: SocketAddrV4::new([127, 0, 0, 1].into(), 19132), + frag: Some(FragmentInfo { + packet_id: NonZeroU16::new(513).unwrap(), + frag_offset: 7, + has_more: true, + }), + extension: Some(extension), + }, + ), + ( + "v6_legacy", + UdpFlow::V6 { + src: ("2001:db8::10".parse::().unwrap(), 25565), + dst: ("::1".parse::().unwrap(), 19132), + extension: None, + }, + ), + ( + "v6_extension", + UdpFlow::V6 { + src: ("2001:db8::10".parse::().unwrap(), 25565), + dst: ("::1".parse::().unwrap(), 19132), + extension: Some(extension), + }, + ), + ] +} + +#[test] +fn udp_flow_bytes_remain_wire_compatible() { + let expected: std::collections::BTreeMap<_, _> = include_str!("../fixtures/udp_flow.hex") + .lines() + .map(|line| line.split_once('=').unwrap()) + .collect(); + + for (name, flow) in fixtures() { + let mut bytes = vec![0; flow.footer_len()]; + assert!(flow.write_to(&mut bytes)); + assert_eq!(hex::encode(&bytes), expected[name], "fixture {name}"); + assert_eq!(UdpFlow::from_tail(&bytes).unwrap(), flow, "fixture {name}"); + } +} diff --git a/packages/playit-cli/Cargo.toml b/packages/playit-cli/Cargo.toml index fc9765ae..82bbd672 100644 --- a/packages/playit-cli/Cargo.toml +++ b/packages/playit-cli/Cargo.toml @@ -10,8 +10,6 @@ repository = "https://github.com/playit-cloud/playit-agent" [dependencies] tokio = { workspace = true } -tokio-util = { workspace = true } -rand = { workspace = true } hex = { workspace = true } toml = { workspace = true } serde = { workspace = true } @@ -21,11 +19,8 @@ tracing-subscriber = { workspace = true } tracing-appender = { workspace = true } uuid = { workspace = true } -dirs = { workspace = true } - chrono = "0.4" clap = { version = "4.5", features = ["derive"] } -urlencoding = "2.1" serde_yml = "0.0.12" crossterm = "0.28" ratatui = "0.29" @@ -33,10 +28,9 @@ dotenv = "0.15.0" playit-agent-core = { path = "../agent_core", version = "1.0.10" } playit-ipc = { path = "../playit-ipc", version = "1.0.10" } -playitd = { path = "../playitd", version = "1.0.10" } -playit-agent-proto = { path = "../agent_proto", version = "1.3.0" } -playit-api-client = { path = "../api_client", version = "0.2.0" } -# playit-ping-monitor = { path = "../ping_monitor" } +playit-model = { path = "../playit-model", version = "1.0.10" } +playit-platform = { path = "../playit-platform", version = "1.0.10" } +playit-runtime = { path = "../playit-runtime", version = "1.0.10" } [target.'cfg(windows)'.dependencies] whoami = "1.5" diff --git a/packages/playit-cli/fixtures/help.txt b/packages/playit-cli/fixtures/help.txt new file mode 100644 index 00000000..c5154fb2 --- /dev/null +++ b/packages/playit-cli/fixtures/help.txt @@ -0,0 +1,21 @@ +Usage: playit-cli [OPTIONS] [COMMAND] + +Commands: + version Print version information + attach Attach to a running playitd service + start Start the installed playitd service + stop Stop the installed playitd service + status Show the status of the installed playitd service + reset Removes the secret key on your system so the playit agent can be re-claimed + secret-path Shows the file path where the playit secret can be found + setup Setup playit by provisioning a new secret to playitd + account Account management commands + claim Setting up a new playit agent + help Print this message or the help of the given subcommand(s) + +Options: + -s, --stdout Prints logs to stdout + --socket-path Override the IPC socket or named pipe used to reach playitd + --systemd + --openrc + -h, --help Print help diff --git a/packages/playit-cli/fixtures/help_non_linux.txt b/packages/playit-cli/fixtures/help_non_linux.txt new file mode 100644 index 00000000..315d789a --- /dev/null +++ b/packages/playit-cli/fixtures/help_non_linux.txt @@ -0,0 +1,19 @@ +Usage: playit-cli [OPTIONS] [COMMAND] + +Commands: + version Print version information + attach Attach to a running playitd service + start Start the installed playitd service + stop Stop the installed playitd service + status Show the status of the installed playitd service + reset Removes the secret key on your system so the playit agent can be re-claimed + secret-path Shows the file path where the playit secret can be found + setup Setup playit by provisioning a new secret to playitd + account Account management commands + claim Setting up a new playit agent + help Print this message or the help of the given subcommand(s) + +Options: + -s, --stdout Prints logs to stdout + --socket-path Override the IPC socket or named pipe used to reach playitd + -h, --help Print help diff --git a/packages/playit-cli/fixtures/output_contract.txt b/packages/playit-cli/fixtures/output_contract.txt new file mode 100644 index 00000000..54fe0867 --- /dev/null +++ b/packages/playit-cli/fixtures/output_contract.txt @@ -0,0 +1,15 @@ +waiting for secret +invalid secret +disabled over limit +starting +running +stopping +error +The playit service cannot start because this account is over the agent limit. +Delete unused agents: https://playit.gg/account/agents +Increase your agent limit: https://playit.gg/account/upgrade +Connection to the playit service was lost: connection reset. Run "playit attach" to reconnect. +Connection to the playit daemon was lost: connection reset. Reattach with "playit attach --socket-path ./fixture.sock" once the daemon is reachable again. +Could not connect to the playit service. playit tried to start it first, but it is still not reachable. +Could not connect to the playit service. playit also tried to start it first, but startup failed: fixture startup failure +2023-11-14T22:13:20.123000Z WARN playitd::fixture: fixture log diff --git a/packages/playit-cli/src/client.rs b/packages/playit-cli/src/client.rs index 03bdb82d..6f751ee9 100644 --- a/packages/playit-cli/src/client.rs +++ b/packages/playit-cli/src/client.rs @@ -3,21 +3,25 @@ use std::time::Duration; use chrono::{DateTime, Utc}; use playit_ipc::ipc::{IpcClient, get_default_socket_path}; use playit_ipc::model::{ - AgentLifecycle, LogLevel as ServiceLogLevel, ServicePhase, ServiceUpdate, SubscribeResponse, + AgentLifecycle, ClaimExchangeResponse, ClaimProgressResponse, ClaimSessionResponse, + LogLevel as ServiceLogLevel, ServicePhase, ServiceUpdate, SubscribeResponse, +}; +use playit_platform::service::{ServiceState, installed_service_state, stop_installed_service}; +use playit_runtime::{ + GracefulStopAttempt, InstalledServiceStopOutcome, InstalledServiceStopPolicy, + stop_installed_service_with_fallback, }; #[cfg(target_os = "linux")] use crate::linux; +use crate::problem::{lifecycle_message, render_ipc_error, render_problem, service_phase_label}; use crate::service::{ - InstalledServiceStartState, InstalledServiceStopState, ServiceManagerMode, - ensure_installed_service_running_for_cli, stop_installed_service_for_cli, + InstalledServiceStartState, ServiceManagerMode, ensure_installed_service_running_for_cli, + installed_service_manager, }; use crate::ui::{ConnectionStats, ConsoleUi, TuiApp}; use crate::{CliError, run_setup_flow}; -const ACCOUNT_AGENTS_URL: &str = "https://playit.gg/account/agents"; -const ACCOUNT_UPGRADE_URL: &str = "https://playit.gg/account/upgrade"; - #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum AttachMode { Interactive, @@ -92,7 +96,7 @@ pub async fn run_auto_command( .yn_question( format!( "The playit service has an invalid secret: {}.\nReset it now and run setup again?", - error.message + render_problem(&error) ), Some(false), ) @@ -106,15 +110,10 @@ pub async fn run_auto_command( } reset_service_secret_for_setup(target).await?; - wait_for_service_waiting_for_secret(target).await?; run_setup_flow(console, target, service_manager).await?; } - AgentLifecycle::DisabledOverLimit(_) => { - return Err(CliError::ServiceError(format!( - "{}\n{}", - agent_over_limit_title(), - agent_over_limit_guidance() - ))); + AgentLifecycle::DisabledOverLimit(error) => { + return Err(CliError::ServiceError(render_problem(&error))); } AgentLifecycle::Starting => { return Err(CliError::ServiceError( @@ -128,10 +127,7 @@ pub async fn run_auto_command( )); } AgentLifecycle::Error(error) => { - return Err(CliError::ServiceError(format!( - "The playit service reported an error and cannot continue: {}", - error.message - ))); + return Err(CliError::ServiceError(render_problem(&error))); } } @@ -163,10 +159,12 @@ async fn wait_for_auto_lifecycle(client: &mut IpcClient) -> Result Result<(), CliError> { let mut client = connect_target(target).await?; - let response = client - .reset_secret() - .await - .map_err(|error| CliError::IpcError(format!("Failed to reset secret: {error}")))?; + let response = client.reset_secret().await.map_err(|error| { + CliError::IpcError(format!( + "Failed to reset secret: {}", + render_ipc_error(&error) + )) + })?; if !response.accepted { return Err(CliError::IpcError(response.message.unwrap_or_else(|| { @@ -174,49 +172,15 @@ async fn reset_service_secret_for_setup(target: &CliTarget) -> Result<(), CliErr }))); } - Ok(()) -} - -async fn wait_for_service_waiting_for_secret(target: &CliTarget) -> Result<(), CliError> { for _ in 0..50 { - let mut client = match connect_target(target).await { - Ok(client) => client, - Err(_) => { - tokio::time::sleep(Duration::from_millis(100)).await; - continue; - } - }; - - let lifecycle = client.lifecycle().await.map_err(|error| { - CliError::IpcError(format!("Failed to read playitd lifecycle: {error}")) - })?; - - match lifecycle { - AgentLifecycle::WaitingForSecret => return Ok(()), - AgentLifecycle::HasInvalidSecret(_) - | AgentLifecycle::DisabledOverLimit(_) - | AgentLifecycle::Running(_) - | AgentLifecycle::Starting => { - tokio::time::sleep(Duration::from_millis(100)).await; - } - AgentLifecycle::Stopping => { - return Err(CliError::ServiceError( - "The playit service is stopping and did not become ready for setup after the reset." - .to_string(), - )); - } - AgentLifecycle::Error(error) => { - return Err(CliError::ServiceError(format!( - "The playit service reported an error after the reset: {}", - error.message - ))); - } + if !IpcClient::is_running(target.socket_path()).await { + return Ok(()); } + tokio::time::sleep(Duration::from_millis(100)).await; } Err(CliError::ServiceError( - "Timed out while waiting for the playit service to become ready for setup after the reset." - .to_string(), + "The playit service accepted reset but did not stop before setup restarted.".to_owned(), )) } @@ -320,17 +284,27 @@ fn apply_tui_update(tui: &mut TuiApp, update: ServiceUpdate) { } fn apply_stdout_update(update: ServiceUpdate) { - if let ServiceUpdate::Log(entry) = update { - println!( - "{} {:>5} {}: {}", - format_timestamp_millis(entry.timestamp), - format_log_level(&entry.level), - entry.target, - entry.message - ); + match update { + ServiceUpdate::Log(entry) => println!("{}", format_stdout_log(&entry)), + ServiceUpdate::Lifecycle(lifecycle) => { + if let Some(message) = lifecycle_message(&lifecycle) { + println!("{message}"); + } + } + ServiceUpdate::Status(_) | ServiceUpdate::Stats(_) => {} } } +fn format_stdout_log(entry: &playit_ipc::model::LogEntry) -> String { + format!( + "{} {:>5} {}: {}", + format_timestamp_millis(entry.timestamp), + format_log_level(&entry.level), + entry.target, + entry.message + ) +} + fn print_detach_message() { println!("Detached from service. Service continues running in background."); println!("Use 'playit stop' to stop the service."); @@ -371,52 +345,78 @@ pub async fn run_stop_command( ) -> Result<(), CliError> { match target { CliTarget::InstalledService => { - let mut direct_stop_fallback = true; - - match connect_target(target).await { - Ok(mut client) => match client.stop().await { - Ok(response) if response.accepted => { - direct_stop_fallback = false; - println!("Asked the playit service to stop."); - tokio::time::sleep(Duration::from_secs(1)).await; - } - Ok(response) => { - tracing::warn!( - "playitd rejected stop request: {}", - response - .message - .unwrap_or_else(|| "service rejected stop request".to_string()) - ); - } - Err(error) => { - tracing::warn!("Failed to send stop via IPC: {error}"); - eprintln!( - "Could not reach the playit service over IPC. Trying the system service manager instead." - ); + let manager = installed_service_manager(service_manager)?; + let report = stop_installed_service_with_fallback( + InstalledServiceStopPolicy::default(), + || async move { + tokio::task::spawn_blocking(move || installed_service_state(manager)) + .await + .map_err(|error| format!("Failed to join service status task: {error}"))? + .map(|state| state == ServiceState::Running) + .map_err(|error| error.to_string()) + }, + || async { + match connect_target(target).await { + Ok(mut client) => match client.stop().await { + Ok(response) if response.accepted => GracefulStopAttempt::Accepted, + Ok(response) => GracefulStopAttempt::Rejected( + response + .message + .unwrap_or_else(|| "service rejected stop request".to_owned()), + ), + Err(error) => GracefulStopAttempt::Failed(error.to_string()), + }, + Err(error) => GracefulStopAttempt::Failed(error.to_string()), } }, - Err(error) => { - tracing::warn!("Failed to connect to installed service over IPC: {error}"); + || async move { + tokio::task::spawn_blocking(move || stop_installed_service(manager)) + .await + .map_err(|error| { + format!("Failed to join installed service stop task: {error}") + })? + .map_err(|error| error.to_string()) + }, + ) + .await; + + if let Some(error) = report.initial_status_error.as_deref() { + tracing::warn!("Failed to query installed service before stop: {error}"); + } + match report.graceful.as_ref() { + Some(GracefulStopAttempt::Accepted) => { + println!("Asked the playit service to stop.") + } + Some(GracefulStopAttempt::Rejected(error)) => { + tracing::warn!("playitd rejected stop request: {error}"); + } + Some(GracefulStopAttempt::Failed(error)) => { + tracing::warn!("Failed to send stop via IPC: {error}"); eprintln!( "Could not reach the playit service over IPC. Trying the system service manager instead." ); } + None => {} } - - if direct_stop_fallback { - if matches!( - stop_installed_service_for_cli(service_manager)?, - InstalledServiceStopState::AlreadyStopped - ) { - return Ok(()); - } + if let Some(Err(error)) = report.fallback.as_ref() { + tracing::warn!("Failed to stop installed service: {error}"); } - - tokio::time::sleep(Duration::from_millis(500)).await; - if !IpcClient::is_running(get_default_socket_path()).await { - println!("The playit service stopped."); - } else { - println!("The playit service may still be running. Run `playit status` to check."); + match report.outcome { + InstalledServiceStopOutcome::AlreadyStopped => { + println!("The playit service is already stopped."); + } + InstalledServiceStopOutcome::Stopped => println!("The playit service stopped."), + InstalledServiceStopOutcome::StillRunning => { + println!( + "The playit service may still be running. Run `playit status` to check." + ); + } + InstalledServiceStopOutcome::StatusUnknown(error) => { + tracing::warn!("Failed to query installed service after stop: {error}"); + println!( + "The playit service stop was requested. Run `playit status` to check." + ); + } } Ok(()) @@ -470,7 +470,7 @@ pub async fn run_status_command(target: &CliTarget) -> Result<(), CliError> { println!("playitd daemon status for socket {path}:") } } - println!(" Phase: {}", format_service_phase(&status.phase)); + println!(" Phase: {}", service_phase_label(&status.phase)); println!(" PID: {}", status.pid); println!(" Uptime: {} seconds", status.uptime_secs); println!(" Version: {}", status.version); @@ -486,15 +486,16 @@ pub async fn run_status_command(target: &CliTarget) -> Result<(), CliError> { } if matches!(status.phase, ServicePhase::DisabledOverLimit) { println!(" Message:"); - for line in agent_over_limit_title().lines() { - println!(" {line}"); - } - for line in agent_over_limit_guidance().lines() { + for line in crate::problem::render_problem_code( + playit_ipc::model::ServiceErrorCode::AgentDisabledOverLimit, + ) + .lines() + { println!(" {line}"); } } if let Some(error) = status.last_error { - println!(" Last error: {}", error.message); + println!(" Last error: {}", render_problem(&error)); } } Err(_) => return Err(ipc_connection_error()), @@ -503,109 +504,101 @@ pub async fn run_status_command(target: &CliTarget) -> Result<(), CliError> { Ok(()) } -pub async fn ensure_service_waiting_for_secret( +pub async fn begin_service_claim( console: &mut ConsoleUi, target: &CliTarget, service_manager: ServiceManagerMode, -) -> Result<(), CliError> { +) -> Result { if matches!(target, CliTarget::InstalledService) { ensure_installed_service_running_for_cli(Some(console), service_manager).await?; } let mut client = connect_target(target).await?; - let lifecycle = client.lifecycle().await.map_err(|error| { - CliError::IpcError(format!("Failed to read playitd lifecycle: {error}")) - })?; - - match lifecycle { - AgentLifecycle::WaitingForSecret => Ok(()), - AgentLifecycle::HasInvalidSecret(error) => Err(CliError::ServiceError(format!( - "Setup cannot continue because the playit service has an invalid secret: {}. Run `playit reset`, then run `playit setup` again.", - error.message - ))), - AgentLifecycle::DisabledOverLimit(_) => Err(CliError::ServiceError(format!( - "{}\n{}", - "Setup is unavailable because this account is over the agent limit.", - agent_over_limit_guidance() - ))), - AgentLifecycle::Starting => Err(CliError::ServiceError( - "The playit service is still starting. Try setup again in a few seconds.".to_string(), - )), - AgentLifecycle::Running(_) => Err(CliError::ServiceError( - "The playit service already has a configured secret. Run `playit reset` before claiming a new agent." - .to_string(), - )), - AgentLifecycle::Stopping => Err(CliError::ServiceError( - "The playit service is stopping. Try setup again after it stops.".to_string(), - )), - AgentLifecycle::Error(error) => Err(CliError::ServiceError(format!( - "The playit service reported an error and is not ready for setup: {}", - error.message - ))), - } + client.begin_claim().await.map_err(|error| { + CliError::IpcError(format!( + "Failed to begin setup: {}", + render_ipc_error(&error) + )) + }) } -pub async fn provision_service_secret( +pub async fn complete_service_claim( console: &mut ConsoleUi, target: &CliTarget, - secret: &str, - service_manager: ServiceManagerMode, + claim_code: &str, ) -> Result<(), CliError> { - if matches!(target, CliTarget::InstalledService) { - ensure_installed_service_running_for_cli(Some(console), service_manager).await?; - } - let mut client = connect_target(target).await?; - let response = client - .set_secret(secret) - .await - .map_err(|error| CliError::IpcError(format!("Failed to provision secret: {error}")))?; + let _close_guard = crate::signal_handle::get_signal_handle().close_guard(); - if !response.accepted { - return Err(CliError::IpcError( - response - .message - .unwrap_or_else(|| "playitd rejected the secret".to_string()), - )); + loop { + let progress = match client.poll_claim(claim_code).await { + Ok(progress) => progress, + Err(error) => { + console + .write_screen(format!( + "Setup is waiting for the service.\n\nError: {}", + render_ipc_error(&error) + )) + .await; + tokio::time::sleep(Duration::from_secs(2)).await; + continue; + } + }; + match progress { + ClaimProgressResponse::WaitingForVisit => {} + ClaimProgressResponse::WaitingForApproval => { + console + .write_screen("Approve this program in your browser to continue setup.") + .await; + } + ClaimProgressResponse::Approved => { + console + .write_screen("Program approved. Finishing setup...") + .await; + break; + } + ClaimProgressResponse::Rejected => return Err(CliError::AgentClaimRejected), + } + tokio::time::sleep(Duration::from_millis(200)).await; } - Ok(()) + loop { + match client.exchange_claim(claim_code).await.map_err(|error| { + CliError::IpcError(format!( + "Failed to finish setup: {}", + render_ipc_error(&error) + )) + })? { + ClaimExchangeResponse::Accepted => return Ok(()), + ClaimExchangeResponse::Pending(status) => { + console + .write_screen(format!("Waiting for setup to finish: {status}")) + .await; + tokio::time::sleep(Duration::from_secs(2)).await; + } + } + } } pub async fn run_reset_command(target: &CliTarget) -> Result<(), CliError> { let mut client = connect_target(target).await?; - let reset_response = client - .reset_secret() - .await - .map_err(|error| CliError::IpcError(format!("Failed to reset secret: {error}")))?; - - if !reset_response.accepted { - return Err(CliError::IpcError(reset_response.message.unwrap_or_else( - || "playitd rejected the reset request".to_string(), - ))); - } - - let stop_response = client.stop().await.map_err(|error| { + let reset_response = client.reset_secret().await.map_err(|error| { CliError::IpcError(format!( - "Secret was reset, but failed to stop playitd: {error}" + "Failed to reset secret: {}", + render_ipc_error(&error) )) })?; - if !stop_response.accepted { - return Err(CliError::IpcError(stop_response.message.unwrap_or_else( - || "Secret was reset, but playitd rejected the stop request".to_string(), + if !reset_response.accepted { + return Err(CliError::IpcError(reset_response.message.unwrap_or_else( + || "playitd rejected the reset request".to_string(), ))); } let reset_message = reset_response .message .unwrap_or_else(|| "playitd reset the secret file".to_string()); - let stop_message = stop_response - .message - .unwrap_or_else(|| "shutdown requested".to_string()); - println!("{reset_message}"); - println!("playitd stop requested: {stop_message}"); Ok(()) } @@ -706,28 +699,6 @@ fn format_timestamp_millis(millis: u64) -> String { .unwrap_or_else(|| format!("{millis}ms")) } -fn format_service_phase(phase: &ServicePhase) -> &'static str { - match phase { - ServicePhase::WaitingForSecret => "waiting for secret", - ServicePhase::HasInvalidSecret => "invalid secret", - ServicePhase::DisabledOverLimit => "disabled over limit", - ServicePhase::Starting => "starting", - ServicePhase::Running => "running", - ServicePhase::Stopping => "stopping", - ServicePhase::Error => "error", - } -} - -fn agent_over_limit_guidance() -> String { - format!( - "Delete unused agents: {ACCOUNT_AGENTS_URL}\nIncrease your agent limit: {ACCOUNT_UPGRADE_URL}" - ) -} - -fn agent_over_limit_title() -> &'static str { - "The playit service cannot start because this account is over the agent limit." -} - fn format_log_level(level: &ServiceLogLevel) -> &'static str { match level { ServiceLogLevel::Trace => "TRACE", @@ -759,4 +730,54 @@ mod tests { "`playit start` can only start the installed service when run with --systemd or --openrc.\n\nIf you are managing playitd yourself, start it in the background and connect with --socket-path:\n playitd --socket-path=./playit.sock --secret-path=./playit.toml\n playit --socket-path=./playit.sock" ); } + + #[test] + fn user_facing_output_contract_is_stable() { + let phases = [ + ServicePhase::WaitingForSecret, + ServicePhase::HasInvalidSecret, + ServicePhase::DisabledOverLimit, + ServicePhase::Starting, + ServicePhase::Running, + ServicePhase::Stopping, + ServicePhase::Error, + ]; + let mut lines: Vec = phases + .iter() + .map(|phase| service_phase_label(phase).to_string()) + .collect(); + lines.push(crate::problem::over_limit_title().to_string()); + lines.extend( + crate::problem::over_limit_guidance() + .lines() + .map(str::to_string), + ); + lines.push(attach_lost_message( + &CliTarget::InstalledService, + "connection reset", + )); + lines.push(attach_lost_message( + &CliTarget::ExplicitSocket("./fixture.sock".to_string()), + "connection reset", + )); + lines.push(auto_attach_error(&CliTarget::InstalledService, None).to_string()); + lines.push( + auto_attach_error( + &CliTarget::InstalledService, + Some("fixture startup failure"), + ) + .to_string(), + ); + lines.push(format_stdout_log(&playit_ipc::model::LogEntry { + level: ServiceLogLevel::Warn, + target: "playitd::fixture".to_string(), + message: "fixture log".to_string(), + timestamp: 1_700_000_000_123, + })); + + assert_eq!( + lines.join("\n"), + include_str!("../fixtures/output_contract.txt").trim_end() + ); + } } diff --git a/packages/playit-cli/src/linux.rs b/packages/playit-cli/src/linux.rs index a96c21f6..0b575610 100644 --- a/packages/playit-cli/src/linux.rs +++ b/packages/playit-cli/src/linux.rs @@ -1,24 +1,15 @@ -use std::{ - fs, - os::unix::fs::{FileTypeExt, MetadataExt}, - path::Path, - time::Duration, -}; +use std::time::Duration; use playit_ipc::ipc::{IpcClient, get_default_socket_path}; -use playitd::manager::{LinuxServiceManager, installed_service_is_active_with_linux_manager}; -use playitd::unix_account::{ - current_process_has_group, current_user_account, current_user_is_root, effective_gid, - effective_uid, group_info_by_gid, -}; +use playit_platform::linux::{SocketAccessIssue, inspect_socket_access}; +use playit_platform::service::{ServiceManagerKind, ServiceState, installed_service_state}; +use playit_platform::unix::current_user_is_root; use crate::{CliError, ui::ConsoleUi}; -const PLAYIT_GROUP_NAME: &str = "playit"; - pub(crate) async fn prepare_installed_service_for_cli( console: Option<&mut ConsoleUi>, - service_manager: LinuxServiceManager, + service_manager: ServiceManagerKind, ) -> Result { if installed_service_is_active(service_manager)? { for _ in 0..20 { @@ -54,9 +45,10 @@ pub(crate) async fn prepare_installed_service_for_cli( } pub(crate) fn installed_service_is_active( - service_manager: LinuxServiceManager, + service_manager: ServiceManagerKind, ) -> Result { - installed_service_is_active_with_linux_manager(service_manager) + installed_service_state(service_manager) + .map(|state| state == ServiceState::Running) .map_err(|error| CliError::ServiceError(format!("Failed to check service status: {error}"))) } @@ -64,7 +56,7 @@ pub(crate) fn is_linux_socket_access_message(message: &str) -> bool { message.starts_with("The playit service is running, but") } -fn service_start_prompt(service_manager: LinuxServiceManager, is_root: bool) -> String { +fn service_start_prompt(service_manager: ServiceManagerKind, is_root: bool) -> String { let command = service_start_command(service_manager, is_root); let mut prompt = format!( "The playit service is not running.\nStart it now so playit can run in the background?\n\nCommand: {command}", @@ -77,19 +69,20 @@ fn service_start_prompt(service_manager: LinuxServiceManager, is_root: bool) -> prompt } -fn service_start_command(service_manager: LinuxServiceManager, is_root: bool) -> &'static str { +fn service_start_command(service_manager: ServiceManagerKind, is_root: bool) -> &'static str { match (service_manager, is_root) { - (LinuxServiceManager::Systemd, true) => "systemctl start playit", - (LinuxServiceManager::Systemd, false) => "sudo systemctl start playit", - (LinuxServiceManager::OpenRc, true) => "rc-service playit start", - (LinuxServiceManager::OpenRc, false) => "sudo rc-service playit start", + (ServiceManagerKind::Systemd, true) => "systemctl start playit", + (ServiceManagerKind::Systemd, false) => "sudo systemctl start playit", + (ServiceManagerKind::OpenRc, true) => "rc-service playit start", + (ServiceManagerKind::OpenRc, false) => "sudo rc-service playit start", + _ => unreachable!("Linux CLI only selects systemd or OpenRC"), } } -fn installed_service_unreachable_message(service_manager: LinuxServiceManager) -> String { +fn installed_service_unreachable_message(service_manager: ServiceManagerKind) -> String { let socket_path = get_default_socket_path(); - match socket_access_issue(socket_path) { + match inspect_socket_access(socket_path) { Some(issue) => format_socket_access_issue(socket_path, &issue, service_manager), None => format!( "The playit service is running, but this shell cannot reach its IPC socket:\n {socket_path}\n\nTry running `playit status` again in a few seconds. If it still fails, restart the service with:\n {}", @@ -98,127 +91,32 @@ fn installed_service_unreachable_message(service_manager: LinuxServiceManager) - } } -#[derive(Debug, Clone, PartialEq, Eq)] -enum LinuxSocketAccessIssue { - MissingSocket, - InspectFailed(String), - NotASocket, - PlayitGroupJoinRequired, - PlayitGroupRefreshRequired, - GenericPermissionDenied { - current_uid: u32, - current_gid: u32, - socket_uid: u32, - socket_gid: u32, - socket_mode: u32, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct LinuxGroupInfo { - name: String, - members: Vec, -} - -fn socket_access_issue(socket_path: &str) -> Option { - let path = Path::new(socket_path); - let metadata = match fs::metadata(path) { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return Some(LinuxSocketAccessIssue::MissingSocket); - } - Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => { - return socket_parent_access_issue(path) - .or_else(|| Some(LinuxSocketAccessIssue::InspectFailed(error.to_string()))); - } - Err(error) => return Some(LinuxSocketAccessIssue::InspectFailed(error.to_string())), - }; - - if !metadata.file_type().is_socket() { - return Some(LinuxSocketAccessIssue::NotASocket); - } - - let current_uid = effective_uid(); - let current_gid = effective_gid(); - let socket_uid = metadata.uid(); - let socket_gid = metadata.gid(); - let socket_mode = metadata.mode() & 0o777; - let socket_group = lookup_group_info(socket_gid); - let socket_group_name = socket_group.as_ref().map(|group| group.name.as_str()); - - if current_user_can_write_socket(&metadata) { - return None; - } - - if socket_group_name == Some(PLAYIT_GROUP_NAME) { - if let Some(issue) = playit_group_access_issue(socket_gid, socket_group.as_ref()) { - return Some(issue); - } - } - - Some(LinuxSocketAccessIssue::GenericPermissionDenied { - current_uid, - current_gid, - socket_uid, - socket_gid, - socket_mode, - }) -} - -fn socket_parent_access_issue(path: &Path) -> Option { - let parent = path.parent()?; - let metadata = fs::metadata(parent).ok()?; - let parent_gid = metadata.gid(); - let parent_group = lookup_group_info(parent_gid); - - if parent_group.as_ref().map(|group| group.name.as_str()) != Some(PLAYIT_GROUP_NAME) { - return None; - } - - playit_group_access_issue(parent_gid, parent_group.as_ref()) -} - -fn playit_group_access_issue( - target_gid: u32, - group: Option<&LinuxGroupInfo>, -) -> Option { - match current_user_account_is_configured_for_group(target_gid, group) { - Some(true) => Some(LinuxSocketAccessIssue::PlayitGroupRefreshRequired), - Some(false) => Some(LinuxSocketAccessIssue::PlayitGroupJoinRequired), - None => None, - } -} - fn format_socket_access_issue( socket_path: &str, - issue: &LinuxSocketAccessIssue, - service_manager: LinuxServiceManager, + issue: &SocketAccessIssue, + service_manager: ServiceManagerKind, ) -> String { match issue { - LinuxSocketAccessIssue::MissingSocket => { + SocketAccessIssue::MissingSocket => { format!( "The playit service is running, but its IPC socket does not exist yet:\n {socket_path}\n\nRestart the service, then try again:\n {}", service_restart_command(service_manager) ) } - LinuxSocketAccessIssue::InspectFailed(error) => { + SocketAccessIssue::InspectFailed(error) => { format!( "The playit service is running, but playit could not inspect its IPC socket:\n {socket_path}\n\nError: {error}" ) } - LinuxSocketAccessIssue::NotASocket => { + SocketAccessIssue::NotASocket => { format!( "The playit service is running, but this path is not a Unix socket:\n {socket_path}\n\nRemove or rename that file, then restart the service:\n {}", service_restart_command(service_manager) ) } - LinuxSocketAccessIssue::PlayitGroupJoinRequired => { - format_playit_group_join_message(socket_path) - } - LinuxSocketAccessIssue::PlayitGroupRefreshRequired => { - format_playit_group_refresh_message(socket_path) - } - LinuxSocketAccessIssue::GenericPermissionDenied { + SocketAccessIssue::GroupJoinRequired => format_playit_group_join_message(socket_path), + SocketAccessIssue::GroupRefreshRequired => format_playit_group_refresh_message(socket_path), + SocketAccessIssue::PermissionDenied { current_uid, current_gid, socket_uid, @@ -230,10 +128,11 @@ fn format_socket_access_issue( } } -fn service_restart_command(service_manager: LinuxServiceManager) -> &'static str { +fn service_restart_command(service_manager: ServiceManagerKind) -> &'static str { match service_manager { - LinuxServiceManager::Systemd => "sudo systemctl restart playit", - LinuxServiceManager::OpenRc => "sudo rc-service playit restart", + ServiceManagerKind::Systemd => "sudo systemctl restart playit", + ServiceManagerKind::OpenRc => "sudo rc-service playit restart", + _ => unreachable!("Linux CLI only selects systemd or OpenRC"), } } @@ -248,54 +147,3 @@ fn format_playit_group_refresh_message(socket_path: &str) -> String { "The playit service is running, but this shell cannot access its IPC socket:\n {socket_path}\n\nYour user is already in the `playit` group, but this shell has not picked up that membership yet.\n\nRefresh group membership for this shell:\n newgrp playit\n\nThen run:\n playit" ) } - -fn current_user_account_is_configured_for_group( - target_gid: u32, - group: Option<&LinuxGroupInfo>, -) -> Option { - let account = current_user_account()?; - - if account.primary_gid == target_gid { - return Some(true); - } - - let group = group?; - Some( - group - .members - .iter() - .any(|member| member == &account.username), - ) -} - -fn current_user_can_write_socket(metadata: &fs::Metadata) -> bool { - let mode = metadata.mode(); - let uid = metadata.uid(); - let gid = metadata.gid(); - let current_uid = effective_uid(); - - if current_uid == 0 { - return true; - } - - if current_uid == uid { - return mode & 0o200 != 0; - } - - if current_user_in_group(gid) { - return mode & 0o020 != 0; - } - - mode & 0o002 != 0 -} - -fn current_user_in_group(target_gid: u32) -> bool { - current_process_has_group(target_gid) -} - -fn lookup_group_info(group_gid: u32) -> Option { - group_info_by_gid(group_gid).map(|group| LinuxGroupInfo { - name: group.name, - members: group.members, - }) -} diff --git a/packages/playit-cli/src/main.rs b/packages/playit-cli/src/main.rs index af8553fe..80970fbb 100644 --- a/packages/playit-cli/src/main.rs +++ b/packages/playit-cli/src/main.rs @@ -5,21 +5,20 @@ use std::time::Duration; use clap::{Parser, Subcommand}; use client::{ - AttachMode, CliTarget, ensure_service_waiting_for_secret, provision_service_secret, + AttachMode, CliTarget, begin_service_claim, complete_service_claim, run_account_login_url_command, run_attach_command, run_auto_command, run_reset_command, run_secret_path_command, run_start_command, run_status_command, run_stop_command, }; use playit_agent_core::agent_control::platform::current_platform; use playit_agent_core::agent_control::version::{help_register_version, register_platform}; -use rand::Rng; use service::ServiceManagerMode; use tracing_subscriber::EnvFilter; use uuid::Uuid; use playit_agent_core::agent_control::errors::SetupError; +use playit_agent_core::gateway::Platform as RuntimePlatform; use playit_agent_core::utils::now_milli; -use playit_api_client::http_client::HttpClientError; -use playit_api_client::{PlayitApi, api::*}; +use playit_runtime::{ClaimExchange, ClaimMode, ClaimProgress, ClaimService}; use crate::signal_handle::get_signal_handle; use crate::ui::{ConsoleUi, UISettings}; @@ -30,6 +29,7 @@ pub static API_BASE: LazyLock = mod client; #[cfg(target_os = "linux")] mod linux; +mod problem; mod service; pub mod signal_handle; pub mod ui; @@ -143,7 +143,12 @@ enum ClaimCommands { #[tokio::main] async fn main() -> std::process::ExitCode { - match run_cli().await { + let signal_task = tokio::spawn(get_signal_handle().watch()); + let result = run_cli().await; + signal_task.abort(); + let _ = signal_task.await; + + match result { Ok(code) => code, Err(error) => { eprintln!("{error}"); @@ -232,8 +237,7 @@ async fn run_cli() -> Result { } ClaimCommands::Exchange { claim_code, wait } => { let secret_key = - claim_exchange(&mut console, &claim_code, ClaimAgentType::SelfManaged, wait) - .await?; + claim_exchange(&mut console, &claim_code, ClaimMode::SelfManaged, wait).await?; console.write_screen(secret_key).await; } }, @@ -267,7 +271,7 @@ fn init_stdout_tracing() -> tracing_appender::non_blocking::WorkerGuard { EnvFilter::try_from_env("PLAYIT_LOG").unwrap_or_else(|_| EnvFilter::new("info")); let (non_blocking, guard) = tracing_appender::non_blocking(std::io::stdout()); tracing_subscriber::fmt() - .with_ansi(current_platform() == Platform::Linux) + .with_ansi(current_platform() == RuntimePlatform::Linux) .with_writer(non_blocking) .with_env_filter(log_filter) .init(); @@ -279,28 +283,19 @@ pub async fn run_setup_flow( target: &CliTarget, service_manager: ServiceManagerMode, ) -> Result<(), CliError> { - ensure_service_waiting_for_secret(console, target, service_manager).await?; - - let claim_code = claim_generate(); + let claim = begin_service_claim(console, target, service_manager).await?; + let claim_code = claim.claim_code; console .write_screen(format!( "Open this link to finish setting up playit:\n{}", - claim_url(&claim_code)? + claim.claim_url )) .await; - let key = claim_exchange(console, &claim_code, ClaimAgentType::Assignable, 0).await?; - provision_service_secret(console, target, &key, service_manager).await?; - - let api = PlayitApi::create(API_BASE.to_string(), Some(key)); - if let Ok(session) = api.login_guest().await { - console - .write_screen(format!( - "Guest login:\nhttps://playit.gg/login/guest-account/{}", - session.session_key - )) - .await; - tokio::time::sleep(Duration::from_secs(10)).await; + complete_service_claim(console, target, &claim_code).await?; + + if let Err(error) = run_account_login_url_command(target).await { + tracing::debug!(?error, "account login URL is not ready after setup"); } console @@ -310,9 +305,7 @@ pub async fn run_setup_flow( } pub fn claim_generate() -> String { - let mut buffer = [0u8; 5]; - rand::rng().fill(&mut buffer); - hex::encode(&buffer) + ClaimService::begin().code } pub fn claim_url(code: &str) -> Result { @@ -326,10 +319,13 @@ pub fn claim_url(code: &str) -> Result { pub async fn claim_exchange( console: &mut ConsoleUi, claim_code: &str, - agent_type: ClaimAgentType, + claim_mode: ClaimMode, wait_sec: u32, ) -> Result { - let api = PlayitApi::create(API_BASE.to_string(), None); + let claims = ClaimService::new( + API_BASE.to_string(), + format!("playit {}", env!("CARGO_PKG_VERSION")), + ); let end_at = if wait_sec == 0 { u64::MAX @@ -342,13 +338,7 @@ pub async fn claim_exchange( let mut last_message = "Preparing setup...".to_string(); loop { - let setup_res = api - .claim_setup(ReqClaimSetup { - code: claim_code.to_string(), - agent_type, - version: format!("playit {}", env!("CARGO_PKG_VERSION")), - }) - .await; + let setup_res = claims.progress(claim_code, claim_mode).await; let setup = match setup_res { Ok(v) => v, @@ -363,25 +353,25 @@ pub async fn claim_exchange( }; last_message = match setup { - ClaimSetupResponse::WaitingForUserVisit => { + ClaimProgress::WaitingForVisit => { format!( "Open this link to finish setting up playit:\n{}", claim_url(claim_code)? ) } - ClaimSetupResponse::WaitingForUser => { + ClaimProgress::WaitingForApproval => { format!( "Approve this program in your browser:\n{}", claim_url(claim_code)? ) } - ClaimSetupResponse::UserAccepted => { + ClaimProgress::Approved => { console .write_screen("Program approved. Finishing setup...") .await; break; } - ClaimSetupResponse::UserRejected => { + ClaimProgress::Rejected => { console .write_screen("Setup was not approved in the browser.") .await; @@ -396,21 +386,16 @@ pub async fn claim_exchange( } let secret_key = loop { - match api - .claim_exchange(ReqClaimExchange { - code: claim_code.to_string(), - }) - .await - { - Ok(res) => break res.secret_key, - Err(ApiError::Fail(status)) => { + match claims.exchange(claim_code).await { + Ok(ClaimExchange::Accepted(secret)) => break secret, + Ok(ClaimExchange::Pending(status)) => { let msg = format!( - "Waiting for claim code \"{}\" to be approved: {:?}", + "Waiting for claim code \"{}\" to be approved: {}", claim_code, status ); console.write_screen(msg).await; } - Err(error) => return Err(error.into()), + Err(error) => return Err(CliError::ServiceError(error.detail)), }; if now_milli() > end_at { @@ -448,9 +433,6 @@ pub enum CliError { AnswerNotProvided, TunnelOverwrittenAlready(Uuid), ResourceNotFoundAfterCreate(Uuid), - RequestError(HttpClientError), - ApiError(ApiResponseError), - ApiFail(String), TunnelSetupError(SetupError), ServiceError(String), IpcError(String), @@ -461,7 +443,7 @@ impl Error for CliError {} impl Display for CliError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { - Self::ServiceError(message) | Self::IpcError(message) | Self::ApiFail(message) => { + Self::ServiceError(message) | Self::IpcError(message) => { write!(f, "{message}") } _ => write!(f, "{:?}", self), @@ -469,25 +451,6 @@ impl Display for CliError { } } -impl From> for CliError { - fn from(e: ApiError) -> Self { - match e { - ApiError::ApiError(e) => CliError::ApiError(e), - ApiError::ClientError(e) => CliError::RequestError(e), - ApiError::Fail(fail) => CliError::ApiFail(serde_json::to_string(&fail).unwrap()), - } - } -} - -impl From> for CliError { - fn from(e: ApiErrorNoFail) -> Self { - match e { - ApiErrorNoFail::ApiError(e) => CliError::ApiError(e), - ApiErrorNoFail::ClientError(e) => CliError::RequestError(e), - } - } -} - impl From for CliError { fn from(e: SetupError) -> Self { CliError::TunnelSetupError(e) @@ -497,6 +460,25 @@ impl From for CliError { #[cfg(test)] mod tests { use super::*; + use clap::CommandFactory; + + #[cfg(target_os = "linux")] + #[test] + fn top_level_help_output_is_stable() { + assert_eq!( + Cli::command().render_help().to_string(), + include_str!("../fixtures/help.txt") + ); + } + + #[cfg(not(target_os = "linux"))] + #[test] + fn top_level_help_output_is_stable() { + assert_eq!( + Cli::command().render_help().to_string(), + include_str!("../fixtures/help_non_linux.txt") + ); + } #[cfg(target_os = "linux")] #[test] diff --git a/packages/playit-cli/src/problem.rs b/packages/playit-cli/src/problem.rs new file mode 100644 index 00000000..711290f8 --- /dev/null +++ b/packages/playit-cli/src/problem.rs @@ -0,0 +1,142 @@ +use playit_ipc::ipc::IpcError; +use playit_ipc::model::{ + AgentLifecycle, ServiceError, ServiceErrorCode, ServicePhase, ServiceStatus, +}; + +const ACCOUNT_AGENTS_URL: &str = "https://playit.gg/account/agents"; +const ACCOUNT_UPGRADE_URL: &str = "https://playit.gg/account/upgrade"; + +pub fn render_ipc_error(error: &IpcError) -> String { + match error { + IpcError::Service(problem) => render_problem(problem), + _ => error.to_string(), + } +} + +pub fn render_problem(problem: &ServiceError) -> String { + match problem.meaning().code.as_str() { + "agent_disabled_over_limit" => format!( + "{}\n{}", + over_limit_title(), + over_limit_guidance() + ), + "invalid_secret" => "The playit service has an invalid secret. Run `playit reset`, then run setup again." + .to_owned(), + "secret_pinned" => "This command is unavailable while playitd uses an inline --secret. Restart it with a secret file to make setup changes." + .to_owned(), + "secret_write_failed" => { + format!("The playit service could not save its secret: {}", problem.message) + } + "engine_unavailable" | "startup_failed" => { + "The playit service is not ready yet. Try again after it finishes starting.".to_owned() + } + "catalog_unavailable" => { + "Tunnel status could not be refreshed. The last accepted tunnel list is still active." + .to_owned() + } + "shutdown_timed_out" => { + "The playit service did not stop before its shutdown deadline.".to_owned() + } + _ => problem.message.clone(), + } +} + +pub fn render_problem_code(code: ServiceErrorCode) -> String { + let error = ServiceError { + code, + message: String::new(), + retryable: false, + details: None, + }; + render_problem(&error) +} + +pub fn lifecycle_message(lifecycle: &AgentLifecycle) -> Option { + match lifecycle { + AgentLifecycle::Running(_) => None, + AgentLifecycle::WaitingForSecret => { + Some("The playit service is waiting for setup to finish.".to_owned()) + } + AgentLifecycle::HasInvalidSecret(error) + | AgentLifecycle::DisabledOverLimit(error) + | AgentLifecycle::Error(error) => Some(render_problem(error)), + AgentLifecycle::Starting => Some("The playit service is starting...".to_owned()), + AgentLifecycle::Stopping => Some("The playit service is stopping...".to_owned()), + } +} + +pub fn status_message(status: &ServiceStatus) -> Option { + if let Some(error) = &status.last_error { + return Some(render_problem(error)); + } + if matches!(status.phase, ServicePhase::DisabledOverLimit) { + return Some(render_problem_code( + ServiceErrorCode::AgentDisabledOverLimit, + )); + } + if matches!(status.phase, ServicePhase::Running) { + None + } else { + Some(format!( + "playit service status: {}", + service_phase_label(&status.phase) + )) + } +} + +pub fn service_phase_label(phase: &ServicePhase) -> &'static str { + match phase { + ServicePhase::WaitingForSecret => "waiting for secret", + ServicePhase::HasInvalidSecret => "invalid secret", + ServicePhase::DisabledOverLimit => "disabled over limit", + ServicePhase::Starting => "starting", + ServicePhase::Running => "running", + ServicePhase::Stopping => "stopping", + ServicePhase::Error => "error", + } +} + +pub fn over_limit_guidance() -> String { + format!( + "Delete unused agents: {ACCOUNT_AGENTS_URL}\nIncrease your agent limit: {ACCOUNT_UPGRADE_URL}" + ) +} + +pub fn over_limit_title() -> &'static str { + "The playit service cannot start because this account is over the agent limit." +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_wire_problem_has_one_meaning_for_every_client_surface() { + let surfaces = ["cli", "tui", "stdout", "tray"]; + for code in playit_model::ProblemCode::ALL { + let metadata = code.metadata(); + let problem = ServiceError { + code: ServiceErrorCode::Internal, + message: "fixture diagnostic".to_owned(), + retryable: false, + details: Some(serde_json::json!({ + "problem_code": code.as_str(), + "retry": metadata.retry.as_str(), + "action": metadata.action.as_str(), + })), + }; + let meaning = problem.meaning(); + let rendered = render_problem(&problem); + assert!(!meaning.code.is_empty()); + assert!(!meaning.retry.is_empty()); + assert!(!meaning.action.is_empty()); + assert!(!rendered.is_empty()); + assert_eq!(meaning.code, code.as_str()); + assert_eq!(meaning.retry, metadata.retry.as_str()); + assert_eq!(meaning.action, metadata.action.as_str()); + for _surface in surfaces { + assert_eq!(problem.meaning(), meaning); + } + } + } +} diff --git a/packages/playit-cli/src/service.rs b/packages/playit-cli/src/service.rs index 3481d754..ce10f323 100644 --- a/packages/playit-cli/src/service.rs +++ b/packages/playit-cli/src/service.rs @@ -1,13 +1,6 @@ use playit_ipc::ipc::{IpcClient, get_default_socket_path}; -#[cfg(not(target_os = "linux"))] -use playitd::manager::{ - InstalledServiceState as ManagerInstalledServiceState, ensure_installed_service_running, - installed_service_state, stop_installed_service, -}; -#[cfg(target_os = "linux")] -use playitd::manager::{ - LinuxServiceManager, ensure_installed_service_running_with_linux_manager, - stop_installed_service_with_linux_manager, +use playit_platform::service::{ + ReachabilityPolicy, ServiceManagerKind, start_and_wait_until_reachable, }; use crate::CliError; @@ -41,12 +34,6 @@ pub enum InstalledServiceStartState { Started, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum InstalledServiceStopState { - AlreadyStopped, - StopRequested, -} - pub async fn ensure_installed_service_running_for_cli( console: Option<&mut ConsoleUi>, service_manager: ServiceManagerMode, @@ -68,11 +55,11 @@ pub async fn ensure_installed_service_running_for_cli( return Ok(InstalledServiceStartState::AlreadyRunning); } - ensure_installed_service_running_with_linux_manager(linux_manager) + start_and_wait(linux_manager) .await .map_err(|error| CliError::ServiceError(format!("Failed to start service: {error}")))?; - return Ok(InstalledServiceStartState::Started); + Ok(InstalledServiceStartState::Started) } #[cfg(not(target_os = "linux"))] @@ -80,15 +67,19 @@ pub async fn ensure_installed_service_running_for_cli( match service_manager { #[cfg(target_os = "windows")] ServiceManagerMode::WindowsService => { - ensure_installed_service_running().await.map_err(|error| { - CliError::ServiceError(format!("Failed to start service: {error}")) - })?; + start_and_wait(ServiceManagerKind::WindowsScm) + .await + .map_err(|error| { + CliError::ServiceError(format!("Failed to start service: {error}")) + })?; } #[cfg(not(any(target_os = "linux", target_os = "windows")))] ServiceManagerMode::Native => { - ensure_installed_service_running().await.map_err(|error| { - CliError::ServiceError(format!("Failed to start service: {error}")) - })?; + start_and_wait(ServiceManagerKind::MacOsLaunchd) + .await + .map_err(|error| { + CliError::ServiceError(format!("Failed to start service: {error}")) + })?; } } } @@ -97,68 +88,24 @@ pub async fn ensure_installed_service_running_for_cli( Ok(InstalledServiceStartState::Started) } -pub fn stop_installed_service_for_cli( +pub(crate) fn installed_service_manager( service_manager: ServiceManagerMode, -) -> Result { +) -> Result { #[cfg(target_os = "linux")] { - let Some(linux_manager) = linux_service_manager(service_manager) else { - return Err(no_service_manager_selected_error()); - }; - - if !installed_service_is_active_for_cli(service_manager)? { - println!("The playit service is already stopped."); - return Ok(InstalledServiceStopState::AlreadyStopped); - } - - if let Err(error) = stop_installed_service_with_linux_manager(linux_manager) { - tracing::warn!("Failed to stop installed service: {error}"); - } - - return Ok(InstalledServiceStopState::StopRequested); + linux_service_manager(service_manager).ok_or_else(no_service_manager_selected_error) } - #[cfg(not(target_os = "linux"))] + #[cfg(target_os = "windows")] { - match service_manager { - #[cfg(target_os = "windows")] - ServiceManagerMode::WindowsService => { - if let Err(error) = stop_installed_service() { - tracing::warn!("Failed to stop installed service: {error}"); - } - } - #[cfg(not(any(target_os = "linux", target_os = "windows")))] - ServiceManagerMode::Native => { - if let Err(error) = stop_installed_service() { - tracing::warn!("Failed to stop installed service: {error}"); - } - } - } - - Ok(InstalledServiceStopState::StopRequested) - } -} - -pub fn installed_service_is_active_for_cli( - service_manager: ServiceManagerMode, -) -> Result { - #[cfg(target_os = "linux")] - { - let Some(linux_manager) = linux_service_manager(service_manager) else { - return Err(no_service_manager_selected_error()); - }; - - return linux::installed_service_is_active(linux_manager); + let _ = service_manager; + Ok(ServiceManagerKind::WindowsScm) } - #[cfg(not(target_os = "linux"))] + #[cfg(not(any(target_os = "linux", target_os = "windows")))] { let _ = service_manager; - installed_service_state() - .map(|state| state == ManagerInstalledServiceState::Running) - .map_err(|error| { - CliError::ServiceError(format!("Failed to check service status: {error}")) - }) + Ok(ServiceManagerKind::MacOsLaunchd) } } @@ -176,14 +123,23 @@ fn no_service_manager_selected_message() -> String { #[cfg(target_os = "linux")] pub(crate) fn linux_service_manager( service_manager: ServiceManagerMode, -) -> Option { +) -> Option { match service_manager { ServiceManagerMode::None => None, - ServiceManagerMode::Systemd => Some(LinuxServiceManager::Systemd), - ServiceManagerMode::OpenRc => Some(LinuxServiceManager::OpenRc), + ServiceManagerMode::Systemd => Some(ServiceManagerKind::Systemd), + ServiceManagerMode::OpenRc => Some(ServiceManagerKind::OpenRc), } } +async fn start_and_wait( + manager: ServiceManagerKind, +) -> Result<(), playit_platform::service::ServiceError> { + start_and_wait_until_reachable(manager, ReachabilityPolicy::default(), || { + IpcClient::is_running(get_default_socket_path()) + }) + .await +} + #[cfg(test)] mod tests { use super::*; @@ -193,7 +149,7 @@ mod tests { fn linux_service_manager_mode_maps_to_systemd() { assert_eq!( linux_service_manager(ServiceManagerMode::Systemd), - Some(LinuxServiceManager::Systemd) + Some(ServiceManagerKind::Systemd) ); } @@ -202,7 +158,7 @@ mod tests { fn linux_service_manager_mode_maps_to_openrc() { assert_eq!( linux_service_manager(ServiceManagerMode::OpenRc), - Some(LinuxServiceManager::OpenRc) + Some(ServiceManagerKind::OpenRc) ); } diff --git a/packages/playit-cli/src/signal_handle.rs b/packages/playit-cli/src/signal_handle.rs index 77412478..5960a004 100644 --- a/packages/playit-cli/src/signal_handle.rs +++ b/packages/playit-cli/src/signal_handle.rs @@ -2,7 +2,7 @@ use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::{Arc, LazyLock}; use tokio::signal::ctrl_c; -static SIGNAL: LazyLock = LazyLock::new(SignalHandle::setup); +static SIGNAL: LazyLock = LazyLock::new(SignalHandle::new); pub fn get_signal_handle() -> SignalHandle { SIGNAL.clone() @@ -46,36 +46,33 @@ impl SignalHandle { } impl SignalHandle { - fn setup() -> Self { - let signal = SignalHandle { + fn new() -> Self { + Self { inner: Arc::new(Inner { confirm_close: AtomicU32::new(0), close_requested: AtomicBool::new(false), }), - }; - - let inner = signal.inner.clone(); - tokio::spawn(async move { - loop { - if let Err(error) = ctrl_c().await { - tracing::error!(?error, "cannot listen for ctrl_c"); - break; - } + } + } - tracing::info!("received Ctrl+C signal"); + pub(crate) async fn watch(self) { + loop { + if let Err(error) = ctrl_c().await { + tracing::error!(?error, "cannot listen for ctrl_c"); + break; + } - if inner.confirm_close.load(Ordering::SeqCst) == 0 { - tracing::info!("no Ctrl+C handler set, closing program"); - std::process::exit(0); - } + tracing::info!("received Ctrl+C signal"); - if inner.close_requested.swap(true, Ordering::SeqCst) { - tracing::info!("Close requested twice, exiting program"); - std::process::exit(0); - } + if self.inner.confirm_close.load(Ordering::SeqCst) == 0 { + tracing::info!("no Ctrl+C handler set, closing program"); + std::process::exit(0); } - }); - signal + if self.inner.close_requested.swap(true, Ordering::SeqCst) { + tracing::info!("Close requested twice, exiting program"); + std::process::exit(0); + } + } } } diff --git a/packages/playit-cli/src/ui/mod.rs b/packages/playit-cli/src/ui/mod.rs index aa9cce76..98786384 100644 --- a/packages/playit-cli/src/ui/mod.rs +++ b/packages/playit-cli/src/ui/mod.rs @@ -51,10 +51,11 @@ impl ConsoleUi { } fn write_screen_inner(&mut self, content: String) { - if let Some((ts, last_render)) = &self.last_display { - if now_milli() - *ts < 10_000 && content == *last_render { - return; - } + if let Some((ts, last_render)) = &self.last_display + && now_milli() - *ts < 10_000 + && content == *last_render + { + return; } println!("{content}"); @@ -88,10 +89,10 @@ impl ConsoleUi { .map_err(CliError::RenderError)?; let input = line.trim().to_lowercase(); - if input.is_empty() { - if let Some(default) = default_yes { - return Ok(default); - } + if input.is_empty() + && let Some(default) = default_yes + { + return Ok(default); } match input.as_str() { diff --git a/packages/playit-cli/src/ui/tui_app.rs b/packages/playit-cli/src/ui/tui_app.rs index bfc7e887..d12d78a9 100644 --- a/packages/playit-cli/src/ui/tui_app.rs +++ b/packages/playit-cli/src/ui/tui_app.rs @@ -22,11 +22,10 @@ use ratatui::{ use super::widgets::{render_header, render_help_bar, render_stats_bar}; use crate::CliError; +use crate::problem::{lifecycle_message, status_message}; use crate::signal_handle::get_signal_handle; const SERVICE_LOG_CAPACITY: usize = 500; -const ACCOUNT_AGENTS_URL: &str = "https://playit.gg/account/agents"; -const ACCOUNT_UPGRADE_URL: &str = "https://playit.gg/account/upgrade"; /// Data about the running agent #[derive(Clone, Default)] @@ -103,6 +102,12 @@ pub struct TuiApp { terminal: Option>>, } +impl Default for TuiApp { + fn default() -> Self { + Self::new() + } +} + impl TuiApp { pub fn new() -> Self { Self { @@ -143,29 +148,10 @@ impl TuiApp { pub fn apply_lifecycle(&mut self, lifecycle: AgentLifecycle) { match lifecycle { AgentLifecycle::Running(state) => self.set_agent_data(state.into()), - AgentLifecycle::WaitingForSecret => { - self.set_message("The playit service is waiting for setup to finish."); - } - AgentLifecycle::HasInvalidSecret(error) => self.set_message(format!( - "The playit service has an invalid secret: {}", - error.message - )), - AgentLifecycle::DisabledOverLimit(_) => self.set_message(format!( - "{}\n{}", - agent_over_limit_title(), - agent_over_limit_guidance() - )), - AgentLifecycle::Starting => { - self.set_message("The playit service is starting..."); - } - AgentLifecycle::Stopping => { - self.set_message("The playit service is stopping..."); - } - AgentLifecycle::Error(error) => { - self.set_message(format!( - "The playit service reported an error: {}", - error.message - )); + other => { + if let Some(message) = lifecycle_message(&other) { + self.set_message(message); + } } } } @@ -216,10 +202,10 @@ impl TuiApp { self.draw().map_err(CliError::RenderError)?; - if event::poll(Duration::from_millis(50)).map_err(CliError::RenderError)? { - if let Event::Key(key) = event::read().map_err(CliError::RenderError)? { - self.handle_key_event(key); - } + if event::poll(Duration::from_millis(50)).map_err(CliError::RenderError)? + && let Event::Key(key) = event::read().map_err(CliError::RenderError)? + { + self.handle_key_event(key); } let signal = get_signal_handle(); @@ -477,58 +463,6 @@ impl TuiApp { } } -fn status_message(status: &ServiceStatus) -> Option { - if matches!( - status.phase, - playit_ipc::model::ServicePhase::DisabledOverLimit - ) { - return Some(format!( - "{}\n{}", - agent_over_limit_title(), - agent_over_limit_guidance() - )); - } - - if let Some(error) = &status.last_error { - return Some(format!( - "playit service status: {} ({})", - service_phase_label(status), - error.message - )); - } - - if matches!(status.phase, playit_ipc::model::ServicePhase::Running) { - None - } else { - Some(format!( - "playit service status: {}", - service_phase_label(status) - )) - } -} - -fn service_phase_label(status: &ServiceStatus) -> &'static str { - match status.phase { - playit_ipc::model::ServicePhase::WaitingForSecret => "waiting for secret", - playit_ipc::model::ServicePhase::HasInvalidSecret => "invalid secret", - playit_ipc::model::ServicePhase::DisabledOverLimit => "disabled over limit", - playit_ipc::model::ServicePhase::Starting => "starting", - playit_ipc::model::ServicePhase::Running => "running", - playit_ipc::model::ServicePhase::Stopping => "stopping", - playit_ipc::model::ServicePhase::Error => "error", - } -} - -fn agent_over_limit_guidance() -> String { - format!( - "Delete unused agents: {ACCOUNT_AGENTS_URL}\nIncrease your agent limit: {ACCOUNT_UPGRADE_URL}" - ) -} - -fn agent_over_limit_title() -> &'static str { - "The playit service cannot start because this account is over the agent limit." -} - fn level_label(level: &LogLevel) -> &'static str { match level { LogLevel::Trace => "TRACE", diff --git a/packages/playit-ipc/Cargo.toml b/packages/playit-ipc/Cargo.toml index 8495f001..e87d5220 100644 --- a/packages/playit-ipc/Cargo.toml +++ b/packages/playit-ipc/Cargo.toml @@ -12,8 +12,8 @@ tokio-util = { workspace = true } futures-util = { workspace = true, features = ["sink"] } serde = { workspace = true } serde_json = { workspace = true } -dirs = { workspace = true } tracing = { workspace = true } +playit-platform = { path = "../playit-platform", version = "1.0.10" } interprocess = { version = "2.2", features = ["tokio"] } diff --git a/packages/playit-ipc/fixtures/ipc_v2_client_transcript.jsonl b/packages/playit-ipc/fixtures/ipc_v2_client_transcript.jsonl new file mode 100644 index 00000000..5a66bf17 --- /dev/null +++ b/packages/playit-ipc/fixtures/ipc_v2_client_transcript.jsonl @@ -0,0 +1,8 @@ +{"ipc_version":2,"request_id":1,"request":{"type":"subscribe"}} +{"ipc_version":2,"request_id":2,"request":{"type":"get_status"}} +{"ipc_version":2,"request_id":3,"request":{"type":"get_state"}} +{"ipc_version":2,"request_id":4,"request":{"type":"stop"}} +{"ipc_version":2,"request_id":5,"request":{"type":"set_secret","secret":"fixture-secret"}} +{"ipc_version":2,"request_id":6,"request":{"type":"reset_secret"}} +{"ipc_version":2,"request_id":7,"request":{"type":"get_secret_path"}} +{"ipc_version":2,"request_id":8,"request":{"type":"get_account_login_url"}} diff --git a/packages/playit-ipc/fixtures/ipc_v2_lifecycle.jsonl b/packages/playit-ipc/fixtures/ipc_v2_lifecycle.jsonl new file mode 100644 index 00000000..3c0efba1 --- /dev/null +++ b/packages/playit-ipc/fixtures/ipc_v2_lifecycle.jsonl @@ -0,0 +1,7 @@ +{"state":"waiting_for_secret"} +{"state":"has_invalid_secret","data":{"code":"invalid_secret","message":"invalid fixture secret","retryable":true,"details":null}} +{"state":"disabled_over_limit","data":{"code":"agent_disabled_over_limit","message":"fixture account is over limit","retryable":true,"details":{"agents":3,"limit":2}}} +{"state":"starting"} +{"state":"running","data":{"version":"1.0.10","tunnels":[{"display_address":"demo.playit.gg:25565","destination":"127.0.0.1:25565","is_disabled":false,"disabled_reason":null}],"pending_tunnels":[{"id":"pending-1","status_msg":"allocating"}],"notices":[{"priority":"info","message":"fixture notice","resolve_link":"https://playit.gg/account"}],"account_status":"verified","agent_id":"agent-1","login_link":"https://playit.gg/login/fixture","start_time":1700000000000}} +{"state":"stopping"} +{"state":"error","data":{"code":"internal","message":"fixture failure","retryable":false,"details":null}} diff --git a/packages/playit-ipc/fixtures/ipc_v2_server_transcript.jsonl b/packages/playit-ipc/fixtures/ipc_v2_server_transcript.jsonl new file mode 100644 index 00000000..d990cb8e --- /dev/null +++ b/packages/playit-ipc/fixtures/ipc_v2_server_transcript.jsonl @@ -0,0 +1,10 @@ +{"message_kind":"hello","data":{"protocol":{"ipc_version":2,"capabilities":["structured_responses","stream_events","lifecycle_state","rich_status","secret_provisioning"]}}} +{"message_kind":"response","data":{"ipc_version":2,"request_id":1,"response":{"type":"subscribe","data":{"protocol":{"ipc_version":2,"capabilities":["structured_responses","stream_events","lifecycle_state","rich_status","secret_provisioning"]},"snapshot":{"status":{"phase":"running","pid":4242,"uptime_secs":17,"version":"1.0.10","socket_path":"/run/playit/playitd.sock","secret_path":"/etc/playit/playit.toml","has_secret":true,"protocol":{"ipc_version":2,"capabilities":["structured_responses","stream_events","lifecycle_state","rich_status","secret_provisioning"]},"last_error":null},"lifecycle":{"state":"running","data":{"version":"1.0.10","tunnels":[{"display_address":"demo.playit.gg:25565","destination":"127.0.0.1:25565","is_disabled":false,"disabled_reason":null}],"pending_tunnels":[{"id":"pending-1","status_msg":"allocating"}],"notices":[{"priority":"info","message":"fixture notice","resolve_link":"https://playit.gg/account"}],"account_status":"verified","agent_id":"agent-1","login_link":"https://playit.gg/login/fixture","start_time":1700000000000}},"stats":{"bytes_in":10,"bytes_out":20,"active_tcp":1,"active_udp":2}}}}}} +{"message_kind":"response","data":{"ipc_version":2,"request_id":4,"response":{"type":"stop","data":{"accepted":true,"message":"shutdown requested"}}}} +{"message_kind":"response","data":{"ipc_version":2,"request_id":7,"response":{"type":"secret_path","data":{"secret_path":"/etc/playit/playit.toml"}}}} +{"message_kind":"response","data":{"ipc_version":2,"request_id":8,"response":{"type":"account_login_url","data":{"login_url":"https://playit.gg/login/fixture"}}}} +{"message_kind":"response","data":{"ipc_version":2,"request_id":9,"response":{"type":"error","data":{"code":"invalid_request_type","message":"unsupported request","retryable":false,"details":{"request_type":"future_request"}}}}} +{"message_kind":"event","data":{"ipc_version":2,"event":{"type":"status","data":{"phase":"stopping","pid":4242,"uptime_secs":18,"version":"1.0.10","socket_path":"/run/playit/playitd.sock","secret_path":"/etc/playit/playit.toml","has_secret":true,"protocol":{"ipc_version":2,"capabilities":["structured_responses","stream_events","lifecycle_state","rich_status","secret_provisioning"]},"last_error":null}}}} +{"message_kind":"event","data":{"ipc_version":2,"event":{"type":"lifecycle","data":{"state":"stopping"}}}} +{"message_kind":"event","data":{"ipc_version":2,"event":{"type":"stats","data":{"bytes_in":10,"bytes_out":20,"active_tcp":1,"active_udp":2}}}} +{"message_kind":"event","data":{"ipc_version":2,"event":{"type":"log","data":{"level":"warn","target":"playitd::fixture","message":"fixture log","timestamp":1700000000123}}}} diff --git a/packages/playit-ipc/src/endpoint.rs b/packages/playit-ipc/src/endpoint.rs index 912c190a..d8b23496 100644 --- a/packages/playit-ipc/src/endpoint.rs +++ b/packages/playit-ipc/src/endpoint.rs @@ -1,7 +1,5 @@ use std::path::{Path, PathBuf}; -use crate::paths; - #[derive(Debug, Clone, PartialEq, Eq)] pub enum IpcEndpoint { Filesystem(PathBuf), @@ -18,10 +16,6 @@ impl IpcEndpoint { Self::Filesystem(PathBuf::from(value)) } - pub fn default() -> Self { - Self::parse(paths::default_socket_path_string()) - } - pub fn as_display_path(&self) -> String { match self { Self::Filesystem(path) => path.display().to_string(), @@ -45,6 +39,12 @@ impl IpcEndpoint { } } +impl Default for IpcEndpoint { + fn default() -> Self { + Self::parse(playit_platform::paths::default_socket_path_string()) + } +} + impl From<&str> for IpcEndpoint { fn from(value: &str) -> Self { Self::parse(value) diff --git a/packages/playit-ipc/src/ipc.rs b/packages/playit-ipc/src/ipc.rs index a8018b29..a2a9a1e9 100644 --- a/packages/playit-ipc/src/ipc.rs +++ b/packages/playit-ipc/src/ipc.rs @@ -15,8 +15,9 @@ use tokio_util::codec::{FramedRead, FramedWrite, LinesCodec, LinesCodecError}; use crate::endpoint::IpcEndpoint; use crate::model::{ - AccountLoginUrlResponse, AgentLifecycle, CommandResponse, ProtocolInfo, SecretPathResponse, - ServiceError, ServiceStatus, ServiceUpdate, SubscribeResponse, + AccountLoginUrlResponse, AgentLifecycle, ClaimExchangeResponse, ClaimProgressResponse, + ClaimSessionResponse, CommandResponse, ProtocolInfo, SecretPathResponse, ServiceError, + ServiceStatus, ServiceUpdate, SubscribeResponse, }; pub const IPC_VERSION: u32 = 2; @@ -35,6 +36,7 @@ pub enum IpcError { JsonError(serde_json::Error), NotRunning, ProtocolMismatch { expected: u32, actual: u32 }, + Service(ServiceError), ProtocolError(String), } @@ -53,6 +55,7 @@ impl std::fmt::Display for IpcError { "IPC protocol mismatch: expected version {expected}, got {actual}" ) } + Self::Service(error) => write!(f, "{error}"), Self::ProtocolError(msg) => write!(f, "{msg}"), } } @@ -95,19 +98,48 @@ pub struct IncomingRequestEnvelope { pub request: ServiceRequestOrUnknown, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ServiceRequest { Subscribe, GetStatus, GetState, Stop, + BeginClaim, + PollClaim { claim_code: String }, + ExchangeClaim { claim_code: String }, + RefreshCatalog, SetSecret { secret: String }, ResetSecret, GetSecretPath, GetAccountLoginUrl, } +impl std::fmt::Debug for ServiceRequest { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Subscribe => formatter.write_str("Subscribe"), + Self::GetStatus => formatter.write_str("GetStatus"), + Self::GetState => formatter.write_str("GetState"), + Self::Stop => formatter.write_str("Stop"), + Self::BeginClaim => formatter.write_str("BeginClaim"), + Self::PollClaim { claim_code } => formatter + .debug_struct("PollClaim") + .field("claim_code", claim_code) + .finish(), + Self::ExchangeClaim { claim_code } => formatter + .debug_struct("ExchangeClaim") + .field("claim_code", claim_code) + .finish(), + Self::RefreshCatalog => formatter.write_str("RefreshCatalog"), + Self::SetSecret { .. } => formatter.write_str("SetSecret { secret: [REDACTED] }"), + Self::ResetSecret => formatter.write_str("ResetSecret"), + Self::GetSecretPath => formatter.write_str("GetSecretPath"), + Self::GetAccountLoginUrl => formatter.write_str("GetAccountLoginUrl"), + } + } +} + #[derive(Debug, Clone, Deserialize)] #[serde(untagged)] pub enum ServiceRequestOrUnknown { @@ -149,11 +181,17 @@ pub struct IncomingEventEnvelope { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", content = "data", rename_all = "snake_case")] +// Boxing a variant would break the public IPC version 2 Rust API. +#[allow(clippy::large_enum_variant)] pub enum ServiceResponse { Subscribe(SubscribeResponse), Status(ServiceStatus), State(AgentLifecycle), Stop(CommandResponse), + ClaimSession(ClaimSessionResponse), + ClaimProgress(ClaimProgressResponse), + ClaimExchange(ClaimExchangeResponse), + RefreshCatalog(CommandResponse), SetSecret(CommandResponse), ResetSecret(CommandResponse), SecretPath(SecretPathResponse), @@ -163,6 +201,8 @@ pub enum ServiceResponse { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "message_kind", content = "data", rename_all = "snake_case")] +// Boxing a variant would break the public IPC version 2 Rust API. +#[allow(clippy::large_enum_variant)] pub enum ServerEnvelope { Hello(HelloEnvelope), Response(ResponseEnvelope), @@ -171,6 +211,8 @@ pub enum ServerEnvelope { #[derive(Debug, Clone, Deserialize)] #[serde(tag = "message_kind", content = "data", rename_all = "snake_case")] +// Match ServerEnvelope's stable representation during decoding. +#[allow(clippy::large_enum_variant)] enum IncomingServerEnvelope { Hello(HelloEnvelope), Response(ResponseEnvelope), @@ -206,7 +248,7 @@ pub fn protocol_info() -> ProtocolInfo { } pub fn get_default_socket_path() -> &'static str { - crate::paths::default_socket_path_static() + playit_platform::paths::default_socket_path() } pub fn get_default_endpoint() -> IpcEndpoint { @@ -471,6 +513,62 @@ impl IpcClient { ) } + pub async fn begin_claim(&mut self) -> Result { + expect_response( + self.request(ServiceRequest::BeginClaim).await?, + "begin claim response", + |response| match response { + ServiceResponse::ClaimSession(response) => Some(response), + _ => None, + }, + ) + } + + pub async fn poll_claim( + &mut self, + claim_code: &str, + ) -> Result { + expect_response( + self.request(ServiceRequest::PollClaim { + claim_code: claim_code.to_owned(), + }) + .await?, + "claim progress response", + |response| match response { + ServiceResponse::ClaimProgress(response) => Some(response), + _ => None, + }, + ) + } + + pub async fn exchange_claim( + &mut self, + claim_code: &str, + ) -> Result { + expect_response( + self.request(ServiceRequest::ExchangeClaim { + claim_code: claim_code.to_owned(), + }) + .await?, + "claim exchange response", + |response| match response { + ServiceResponse::ClaimExchange(response) => Some(response), + _ => None, + }, + ) + } + + pub async fn refresh_catalog(&mut self) -> Result { + expect_response( + self.request(ServiceRequest::RefreshCatalog).await?, + "refresh catalog response", + |response| match response { + ServiceResponse::RefreshCatalog(response) => Some(response), + _ => None, + }, + ) + } + pub async fn set_secret(&mut self, secret: &str) -> Result { expect_response( self.request(ServiceRequest::SetSecret { @@ -600,8 +698,8 @@ fn expect_response( expected: &str, extract: impl FnOnce(ServiceResponse) -> Option, ) -> Result { - if let ServiceResponse::Error(error) = &response { - return Err(IpcError::ProtocolError(error.to_string())); + if let ServiceResponse::Error(error) = response { + return Err(IpcError::Service(error)); } let debug = format!("{response:?}"); @@ -623,13 +721,13 @@ fn validate_incoming_server_envelope(envelope: &IncomingServerEnvelope) -> Resul } IncomingServerEnvelope::Response(_) => {} IncomingServerEnvelope::Event(event) => { - if let ServiceUpdateOrUnknown::Unknown(unknown) = &event.event { - if is_known_update_type(&unknown.type_name) { - return Err(IpcError::ProtocolError(format!( - "invalid IPC event payload for {}", - unknown.type_name - ))); - } + if let ServiceUpdateOrUnknown::Unknown(unknown) = &event.event + && is_known_update_type(&unknown.type_name) + { + return Err(IpcError::ProtocolError(format!( + "invalid IPC event payload for {}", + unknown.type_name + ))); } } } @@ -643,6 +741,10 @@ pub fn is_known_request_type(type_name: &str) -> bool { | "get_status" | "get_state" | "stop" + | "begin_claim" + | "poll_claim" + | "exchange_claim" + | "refresh_catalog" | "set_secret" | "reset_secret" | "get_secret_path" @@ -878,8 +980,7 @@ mod tests { }); let error = decode_incoming_server_envelope(&serde_json::to_string(&line).unwrap()) - .err() - .expect("malformed known event should fail"); + .expect_err("malformed known event should fail"); assert!(matches!(error, IpcError::ProtocolError(_))); } diff --git a/packages/playit-ipc/src/lib.rs b/packages/playit-ipc/src/lib.rs index 8e8396ae..ef13d696 100644 --- a/packages/playit-ipc/src/lib.rs +++ b/packages/playit-ipc/src/lib.rs @@ -1,4 +1,3 @@ pub mod endpoint; pub mod ipc; pub mod model; -pub mod paths; diff --git a/packages/playit-ipc/src/model.rs b/packages/playit-ipc/src/model.rs index faec422b..f3a81a25 100644 --- a/packages/playit-ipc/src/model.rs +++ b/packages/playit-ipc/src/model.rs @@ -55,6 +55,61 @@ pub enum ServiceErrorCode { SecretWriteFailed, } +impl ServiceErrorCode { + pub const ALL: [Self; 9] = [ + Self::Internal, + Self::UnsupportedProtocol, + Self::InvalidRequest, + Self::InvalidRequestType, + Self::AgentDisabledOverLimit, + Self::InvalidSecret, + Self::SecretPinned, + Self::ProvisioningUnavailable, + Self::SecretWriteFailed, + ]; + + pub const fn as_str(&self) -> &'static str { + match self { + Self::Internal => "internal", + Self::UnsupportedProtocol => "unsupported_protocol", + Self::InvalidRequest => "invalid_request", + Self::InvalidRequestType => "invalid_request_type", + Self::AgentDisabledOverLimit => "agent_disabled_over_limit", + Self::InvalidSecret => "invalid_secret", + Self::SecretPinned => "secret_pinned", + Self::ProvisioningUnavailable => "provisioning_unavailable", + Self::SecretWriteFailed => "secret_write_failed", + } + } + + pub const fn fallback_retry(&self) -> &'static str { + match self { + Self::AgentDisabledOverLimit => "after_backoff", + Self::InvalidSecret | Self::SecretWriteFailed => "user_initiated", + Self::Internal => "user_initiated", + _ => "never", + } + } + + pub const fn fallback_action(&self) -> &'static str { + match self { + Self::AgentDisabledOverLimit => "review_account", + Self::InvalidSecret | Self::SecretWriteFailed => "provision_secret", + Self::SecretPinned => "restart_with_configuration", + Self::Internal => "restart_service", + _ => "none", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProblemMeaning { + pub code: String, + pub retry: String, + pub action: String, + pub retry_at_millis: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ServiceError { pub code: ServiceErrorCode, @@ -69,6 +124,27 @@ impl std::fmt::Display for ServiceError { } } +impl ServiceError { + pub fn meaning(&self) -> ProblemMeaning { + let details = self.details.as_ref(); + ProblemMeaning { + code: detail_string(details, "problem_code") + .unwrap_or_else(|| self.code.as_str().to_owned()), + retry: detail_string(details, "retry") + .unwrap_or_else(|| self.code.fallback_retry().to_owned()), + action: detail_string(details, "action") + .unwrap_or_else(|| self.code.fallback_action().to_owned()), + retry_at_millis: details + .and_then(|value| value.get("retry_at_millis")) + .and_then(serde_json::Value::as_u64), + } + } +} + +fn detail_string(details: Option<&serde_json::Value>, key: &str) -> Option { + details?.get(key)?.as_str().map(str::to_owned) +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct TunnelState { pub display_address: String, @@ -176,6 +252,37 @@ pub struct AccountLoginUrlResponse { pub login_url: String, } +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ClaimSessionResponse { + pub claim_code: String, + pub claim_url: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ClaimProgressResponse { + WaitingForVisit, + WaitingForApproval, + Approved, + Rejected, +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "status", content = "value", rename_all = "snake_case")] +pub enum ClaimExchangeResponse { + Pending(String), + Accepted, +} + +impl std::fmt::Debug for ClaimExchangeResponse { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Pending(status) => formatter.debug_tuple("Pending").field(status).finish(), + Self::Accepted => formatter.write_str("Accepted"), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct SubscribeResponse { pub protocol: ProtocolInfo, diff --git a/packages/playit-ipc/src/paths.rs b/packages/playit-ipc/src/paths.rs deleted file mode 100644 index e6656cac..00000000 --- a/packages/playit-ipc/src/paths.rs +++ /dev/null @@ -1,60 +0,0 @@ -use std::path::PathBuf; -#[cfg(target_os = "macos")] -use std::sync::LazyLock; - -pub fn playit_config_dir() -> PathBuf { - dirs::config_local_dir() - .unwrap_or_else(|| ".".into()) - .join("playit_gg") -} - -pub fn default_socket_path_string() -> String { - #[cfg(target_os = "linux")] - { - "/run/playit/playitd.sock".to_string() - } - - #[cfg(target_os = "macos")] - { - macos_launch_agent_socket_path().display().to_string() - } - - #[cfg(target_os = "windows")] - { - r"\\.\pipe\playitd-system".to_string() - } - - #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] - { - "./playitd.sock".to_string() - } -} - -pub(crate) fn default_socket_path_static() -> &'static str { - #[cfg(target_os = "linux")] - { - "/run/playit/playitd.sock" - } - - #[cfg(target_os = "macos")] - { - static MACOS_DEFAULT_SOCKET_PATH: LazyLock = - LazyLock::new(default_socket_path_string); - MACOS_DEFAULT_SOCKET_PATH.as_str() - } - - #[cfg(target_os = "windows")] - { - r"\\.\pipe\playitd-system" - } - - #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] - { - "./playitd.sock" - } -} - -#[cfg(target_os = "macos")] -pub fn macos_launch_agent_socket_path() -> PathBuf { - playit_config_dir().join("playitd.sock") -} diff --git a/packages/playit-ipc/tests/ipc_v2_contract.rs b/packages/playit-ipc/tests/ipc_v2_contract.rs new file mode 100644 index 00000000..982b6714 --- /dev/null +++ b/packages/playit-ipc/tests/ipc_v2_contract.rs @@ -0,0 +1,236 @@ +use playit_ipc::ipc::{ + EventEnvelope, HelloEnvelope, IPC_VERSION, RequestEnvelope, ResponseEnvelope, ServerEnvelope, + ServiceRequest, ServiceResponse, protocol_info, +}; +use playit_ipc::model::{ + AccountLoginUrlResponse, AccountStatus, AgentLifecycle, AgentState, CommandResponse, + ConnectionStats, LogEntry, LogLevel, NoticeState, PendingTunnelState, SecretPathResponse, + ServiceError, ServiceErrorCode, ServicePhase, ServiceStatus, ServiceUpdate, SubscribeResponse, + SubscriptionSnapshot, TunnelState, +}; + +fn fixture_error( + code: ServiceErrorCode, + message: &str, + retryable: bool, + details: Option, +) -> ServiceError { + ServiceError { + code, + message: message.to_string(), + retryable, + details, + } +} + +fn running_state() -> AgentState { + AgentState { + version: "1.0.10".to_string(), + tunnels: vec![TunnelState { + display_address: "demo.playit.gg:25565".to_string(), + destination: "127.0.0.1:25565".to_string(), + is_disabled: false, + disabled_reason: None, + }], + pending_tunnels: vec![PendingTunnelState { + id: "pending-1".to_string(), + status_msg: "allocating".to_string(), + }], + notices: vec![NoticeState { + priority: "info".to_string(), + message: "fixture notice".to_string(), + resolve_link: Some("https://playit.gg/account".to_string()), + }], + account_status: AccountStatus::Verified, + agent_id: "agent-1".to_string(), + login_link: Some("https://playit.gg/login/fixture".to_string()), + start_time: 1_700_000_000_000, + } +} + +fn fixture_status(phase: ServicePhase, uptime_secs: u64) -> ServiceStatus { + ServiceStatus { + phase, + pid: 4242, + uptime_secs, + version: "1.0.10".to_string(), + socket_path: "/run/playit/playitd.sock".to_string(), + secret_path: Some("/etc/playit/playit.toml".to_string()), + has_secret: true, + protocol: protocol_info(), + last_error: None, + } +} + +fn serialize_lines(values: &[T]) -> Vec { + values + .iter() + .map(|value| serde_json::to_string(value).unwrap()) + .collect() +} + +fn fixture_lines(fixture: &str) -> Vec { + fixture.lines().map(str::to_string).collect() +} + +#[test] +fn client_transcript_remains_ipc_v2_compatible() { + let requests = vec![ + ServiceRequest::Subscribe, + ServiceRequest::GetStatus, + ServiceRequest::GetState, + ServiceRequest::Stop, + ServiceRequest::SetSecret { + secret: "fixture-secret".to_string(), + }, + ServiceRequest::ResetSecret, + ServiceRequest::GetSecretPath, + ServiceRequest::GetAccountLoginUrl, + ]; + let envelopes: Vec<_> = requests + .into_iter() + .enumerate() + .map(|(index, request)| RequestEnvelope { + ipc_version: IPC_VERSION, + request_id: index as u64 + 1, + request, + }) + .collect(); + + let actual = serialize_lines(&envelopes); + let fixture = include_str!("../fixtures/ipc_v2_client_transcript.jsonl"); + assert_eq!(actual, fixture_lines(fixture)); + + for line in fixture.lines() { + let decoded: RequestEnvelope = serde_json::from_str(line).unwrap(); + assert_eq!(serde_json::to_string(&decoded).unwrap(), line); + } +} + +#[test] +fn server_transcript_remains_ipc_v2_compatible() { + let status = fixture_status(ServicePhase::Running, 17); + let state = AgentLifecycle::Running(running_state()); + let stats = ConnectionStats { + bytes_in: 10, + bytes_out: 20, + active_tcp: 1, + active_udp: 2, + }; + let envelopes = vec![ + ServerEnvelope::Hello(HelloEnvelope { + protocol: protocol_info(), + }), + ServerEnvelope::Response(ResponseEnvelope { + ipc_version: IPC_VERSION, + request_id: 1, + response: ServiceResponse::Subscribe(SubscribeResponse { + protocol: protocol_info(), + snapshot: SubscriptionSnapshot { + status, + lifecycle: state, + stats: stats.clone(), + }, + }), + }), + ServerEnvelope::Response(ResponseEnvelope { + ipc_version: IPC_VERSION, + request_id: 4, + response: ServiceResponse::Stop(CommandResponse { + accepted: true, + message: Some("shutdown requested".to_string()), + }), + }), + ServerEnvelope::Response(ResponseEnvelope { + ipc_version: IPC_VERSION, + request_id: 7, + response: ServiceResponse::SecretPath(SecretPathResponse { + secret_path: Some("/etc/playit/playit.toml".to_string()), + }), + }), + ServerEnvelope::Response(ResponseEnvelope { + ipc_version: IPC_VERSION, + request_id: 8, + response: ServiceResponse::AccountLoginUrl(AccountLoginUrlResponse { + login_url: "https://playit.gg/login/fixture".to_string(), + }), + }), + ServerEnvelope::Response(ResponseEnvelope { + ipc_version: IPC_VERSION, + request_id: 9, + response: ServiceResponse::Error(fixture_error( + ServiceErrorCode::InvalidRequestType, + "unsupported request", + false, + Some(serde_json::json!({"request_type": "future_request"})), + )), + }), + ServerEnvelope::Event(EventEnvelope { + ipc_version: IPC_VERSION, + event: ServiceUpdate::Status(fixture_status(ServicePhase::Stopping, 18)), + }), + ServerEnvelope::Event(EventEnvelope { + ipc_version: IPC_VERSION, + event: ServiceUpdate::Lifecycle(AgentLifecycle::Stopping), + }), + ServerEnvelope::Event(EventEnvelope { + ipc_version: IPC_VERSION, + event: ServiceUpdate::Stats(stats), + }), + ServerEnvelope::Event(EventEnvelope { + ipc_version: IPC_VERSION, + event: ServiceUpdate::Log(LogEntry { + level: LogLevel::Warn, + target: "playitd::fixture".to_string(), + message: "fixture log".to_string(), + timestamp: 1_700_000_000_123, + }), + }), + ]; + + let actual = serialize_lines(&envelopes); + let fixture = include_str!("../fixtures/ipc_v2_server_transcript.jsonl"); + assert_eq!(actual, fixture_lines(fixture)); + + for line in fixture.lines() { + let decoded: ServerEnvelope = serde_json::from_str(line).unwrap(); + assert_eq!(serde_json::to_string(&decoded).unwrap(), line); + } +} + +#[test] +fn lifecycle_json_remains_ipc_v2_compatible() { + let states = vec![ + AgentLifecycle::WaitingForSecret, + AgentLifecycle::HasInvalidSecret(fixture_error( + ServiceErrorCode::InvalidSecret, + "invalid fixture secret", + true, + None, + )), + AgentLifecycle::DisabledOverLimit(fixture_error( + ServiceErrorCode::AgentDisabledOverLimit, + "fixture account is over limit", + true, + Some(serde_json::json!({"agents": 3, "limit": 2})), + )), + AgentLifecycle::Starting, + AgentLifecycle::Running(running_state()), + AgentLifecycle::Stopping, + AgentLifecycle::Error(fixture_error( + ServiceErrorCode::Internal, + "fixture failure", + false, + None, + )), + ]; + + let actual = serialize_lines(&states); + let fixture = include_str!("../fixtures/ipc_v2_lifecycle.jsonl"); + assert_eq!(actual, fixture_lines(fixture)); + + for line in fixture.lines() { + let decoded: AgentLifecycle = serde_json::from_str(line).unwrap(); + assert_eq!(serde_json::to_string(&decoded).unwrap(), line); + } +} diff --git a/packages/playit-ipc/tests/path_contract.rs b/packages/playit-ipc/tests/path_contract.rs new file mode 100644 index 00000000..78292de8 --- /dev/null +++ b/packages/playit-ipc/tests/path_contract.rs @@ -0,0 +1,50 @@ +use std::path::Path; + +use playit_ipc::endpoint::IpcEndpoint; +use playit_ipc::ipc::{get_default_endpoint, get_default_socket_path}; + +#[test] +fn endpoint_parsing_preserves_filesystem_and_namespace_forms() { + let filesystem = IpcEndpoint::parse("./run/playitd.sock"); + assert_eq!(filesystem.as_display_path(), "./run/playitd.sock"); + assert_eq!( + filesystem.filesystem_path(), + Some(Path::new("./run/playitd.sock")) + ); + assert!(filesystem.is_filesystem()); + + let namespaced = IpcEndpoint::parse("@playitd"); + assert_eq!(namespaced.as_display_path(), "@playitd"); + assert_eq!(namespaced.filesystem_path(), None); + assert!(!namespaced.is_filesystem()); +} + +#[test] +fn default_endpoint_matches_default_socket_path() { + assert_eq!( + get_default_endpoint().as_display_path(), + get_default_socket_path() + ); +} + +#[cfg(target_os = "linux")] +#[test] +fn linux_installed_socket_path_is_stable() { + assert_eq!(get_default_socket_path(), "/run/playit/playitd.sock"); +} + +#[cfg(target_os = "windows")] +#[test] +fn windows_installed_pipe_path_is_stable() { + assert_eq!(get_default_socket_path(), r"\\.\pipe\playitd-system"); + assert!(get_default_endpoint().is_windows_named_pipe()); +} + +#[cfg(target_os = "macos")] +#[test] +fn macos_socket_stays_below_the_playit_config_directory() { + assert_eq!( + Path::new(get_default_socket_path()), + playit_platform::paths::playit_config_dir().join("playitd.sock") + ); +} diff --git a/packages/playit-model/Cargo.toml b/packages/playit-model/Cargo.toml new file mode 100644 index 00000000..9f0bafd8 --- /dev/null +++ b/packages/playit-model/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "playit-model" +version.workspace = true +edition = "2024" +description = "Pure domain model for the playit agent application" +license = "BSD-2-Clause" +repository = "https://github.com/playit-cloud/playit-agent" + +[dependencies] diff --git a/packages/playit-model/src/app.rs b/packages/playit-model/src/app.rs new file mode 100644 index 00000000..b88078c2 --- /dev/null +++ b/packages/playit-model/src/app.rs @@ -0,0 +1,678 @@ +use std::fmt; + +use crate::problem::{Problem, ProblemCode}; +use crate::tunnel::TunnelCatalog; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SecretNeed { + Missing, + Invalid, + PersistenceFailed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Deadline { + AtMillis(u64), + Unscheduled, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentIdentity { + id: String, +} + +impl AgentIdentity { + pub fn new(id: impl Into) -> Self { + Self { id: id.into() } + } + + pub fn as_str(&self) -> &str { + &self.id + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Phase { + Booting, + NeedsSecret { + reason: SecretNeed, + }, + Starting { + attempt: u32, + }, + Blocked { + problem: Problem, + retry_at: Deadline, + }, + Online { + agent: AgentIdentity, + }, + Stopping, + Stopped, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ServiceInfo { + pub process_id: Option, + pub uptime_secs: u64, + pub started_at_millis: u64, + pub version: Option, + pub ipc_protocol: u32, + pub ipc_endpoint: Option, + pub secret_location: Option, + pub has_secret: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AccountStatus { + #[default] + Unknown, + Guest, + EmailNotVerified, + Verified, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct CatalogView { + pub accepted: TunnelCatalog, + pub pending: Vec, + pub account_status: AccountStatus, + pub login_url: Option, + pub last_problem: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PendingTunnel { + pub id: String, + pub status: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct TrafficSnapshot { + pub bytes_in: u64, + pub bytes_out: u64, + pub active_tcp: u32, + pub active_udp: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Notice { + pub problem: Problem, + pub content: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProblemReport { + pub problem: Problem, + pub detail: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NoticeContent { + pub priority: NoticePriority, + pub message: String, + pub resolve_url: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NoticePriority { + Info, + Critical, + High, + Low, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppSnapshot { + pub revision: u64, + pub phase: Phase, + pub service: ServiceInfo, + pub catalog: CatalogView, + pub traffic: TrafficSnapshot, + pub notices: Vec, + pub last_problem: Option, +} + +impl AppSnapshot { + pub fn booting() -> Self { + Self { + revision: 0, + phase: Phase::Booting, + service: ServiceInfo::default(), + catalog: CatalogView::default(), + traffic: TrafficSnapshot::default(), + notices: Vec::new(), + last_problem: None, + } + } + + pub fn revise(&mut self) { + self.revision = self.revision.saturating_add(1); + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppState { + pub snapshot: AppSnapshot, +} + +impl Default for AppState { + fn default() -> Self { + Self { + snapshot: AppSnapshot::booting(), + } + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct SecretInput(String); + +impl SecretInput { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + (!value.trim().is_empty()) + .then_some(Self(value)) + .ok_or(SecretInputError::Empty) + } + + pub fn expose(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for SecretInput { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("SecretInput([REDACTED])") + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SecretInputError { + Empty, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Command { + BeginClaim, + PollClaim { code: String }, + ExchangeClaim { code: String }, + ProvisionSecret { secret: SecretInput }, + ResetSecret, + CreateLoginUrl, + RefreshCatalog, + Stop, +} + +impl Command { + pub const fn kind(&self) -> CommandKind { + match self { + Self::BeginClaim => CommandKind::BeginClaim, + Self::PollClaim { .. } => CommandKind::PollClaim, + Self::ExchangeClaim { .. } => CommandKind::ExchangeClaim, + Self::ProvisionSecret { .. } => CommandKind::ProvisionSecret, + Self::ResetSecret => CommandKind::ResetSecret, + Self::CreateLoginUrl => CommandKind::CreateLoginUrl, + Self::RefreshCatalog => CommandKind::RefreshCatalog, + Self::Stop => CommandKind::Stop, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommandKind { + BeginClaim, + PollClaim, + ExchangeClaim, + ProvisionSecret, + ResetSecret, + CreateLoginUrl, + RefreshCatalog, + Stop, +} + +impl CommandKind { + pub const ALL: [Self; 8] = [ + Self::BeginClaim, + Self::PollClaim, + Self::ExchangeClaim, + Self::ProvisionSecret, + Self::ResetSecret, + Self::CreateLoginUrl, + Self::RefreshCatalog, + Self::Stop, + ]; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Event { + Boot, + SecretMissing, + SecretInvalid, + SecretLoaded, + SecretPersisted, + SecretPersistenceFailed, + EngineStarted { + agent: AgentIdentity, + }, + EngineStartFailed { + problem: Problem, + retry_at: Deadline, + }, + RetryElapsed { + attempt: u32, + }, + CatalogLoaded { + catalog: TunnelCatalog, + }, + CatalogLoadFailed { + retry_at: Deadline, + }, + ShutdownComplete, + ShutdownTimedOut, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Effect { + BindIpc, + LoadSecret, + StartClaim, + PollClaim, + ExchangeClaim, + PersistSecret(SecretInput), + ResetSecret, + CreateLoginUrl, + StartEngine, + StopEngine, + FetchCatalog, + ScheduleRetry(Deadline), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CommandDecision { + Accepted { + state: AppState, + effects: Vec, + }, + Rejected { + state: AppState, + problem: Problem, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EventDecision { + Applied { + state: AppState, + effects: Vec, + }, + Rejected { + state: AppState, + problem: Problem, + }, +} + +pub fn decide_command(mut state: AppState, command: Command) -> CommandDecision { + let phase = &state.snapshot.phase; + let effects = match command { + Command::BeginClaim if matches!(phase, Phase::NeedsSecret { .. }) => { + vec![Effect::StartClaim] + } + Command::PollClaim { .. } if matches!(phase, Phase::NeedsSecret { .. }) => { + vec![Effect::PollClaim] + } + Command::ExchangeClaim { .. } if matches!(phase, Phase::NeedsSecret { .. }) => { + vec![Effect::ExchangeClaim] + } + Command::ProvisionSecret { secret } if matches!(phase, Phase::NeedsSecret { .. }) => { + set_phase(&mut state, Phase::Starting { attempt: 1 }); + vec![Effect::PersistSecret(secret)] + } + Command::ResetSecret + if matches!( + phase, + Phase::NeedsSecret { .. } + | Phase::Starting { .. } + | Phase::Blocked { .. } + | Phase::Online { .. } + ) => + { + set_phase(&mut state, Phase::Stopping); + vec![Effect::ResetSecret] + } + Command::CreateLoginUrl + if matches!(phase, Phase::NeedsSecret { .. } | Phase::Online { .. }) => + { + vec![Effect::CreateLoginUrl] + } + Command::RefreshCatalog if matches!(phase, Phase::Online { .. }) => { + vec![Effect::FetchCatalog] + } + Command::Stop + if matches!( + phase, + Phase::Booting + | Phase::NeedsSecret { .. } + | Phase::Starting { .. } + | Phase::Blocked { .. } + | Phase::Online { .. } + ) => + { + set_phase(&mut state, Phase::Stopping); + vec![Effect::StopEngine] + } + _ => { + let problem = match &state.snapshot.phase { + Phase::Blocked { problem, .. } => problem.clone(), + _ => Problem::new(ProblemCode::CommandNotAllowed), + }; + return CommandDecision::Rejected { state, problem }; + } + }; + + CommandDecision::Accepted { state, effects } +} + +pub fn reduce(mut state: AppState, event: Event) -> EventDecision { + let phase = &state.snapshot.phase; + let effects = match event { + Event::Boot if matches!(phase, Phase::Booting) => { + vec![Effect::BindIpc, Effect::LoadSecret] + } + Event::SecretMissing if matches!(phase, Phase::Booting | Phase::Starting { .. }) => { + set_phase( + &mut state, + Phase::NeedsSecret { + reason: SecretNeed::Missing, + }, + ); + Vec::new() + } + Event::SecretInvalid if matches!(phase, Phase::Booting | Phase::Starting { .. }) => { + set_phase( + &mut state, + Phase::NeedsSecret { + reason: SecretNeed::Invalid, + }, + ); + Vec::new() + } + Event::SecretLoaded if matches!(phase, Phase::Booting | Phase::NeedsSecret { .. }) => { + set_phase(&mut state, Phase::Starting { attempt: 1 }); + vec![Effect::StartEngine] + } + Event::SecretPersisted if matches!(phase, Phase::Starting { .. }) => { + vec![Effect::StartEngine] + } + Event::SecretPersistenceFailed if matches!(phase, Phase::Starting { .. }) => { + state.snapshot.phase = Phase::NeedsSecret { + reason: SecretNeed::PersistenceFailed, + }; + state.snapshot.notices.push(Notice { + problem: Problem::new(ProblemCode::SecretWriteFailed), + content: None, + }); + state.snapshot.revise(); + Vec::new() + } + Event::EngineStarted { agent } if matches!(phase, Phase::Starting { .. }) => { + set_phase(&mut state, Phase::Online { agent }); + vec![Effect::FetchCatalog] + } + Event::EngineStartFailed { problem, retry_at } + if matches!(phase, Phase::Starting { .. }) => + { + set_phase(&mut state, Phase::Blocked { problem, retry_at }); + vec![Effect::ScheduleRetry(retry_at)] + } + Event::RetryElapsed { attempt } if matches!(phase, Phase::Blocked { .. }) => { + set_phase( + &mut state, + Phase::Starting { + attempt: attempt.max(1), + }, + ); + vec![Effect::StartEngine] + } + Event::CatalogLoaded { catalog } if matches!(phase, Phase::Online { .. }) => { + state.snapshot.catalog.accepted = catalog; + state.snapshot.catalog.last_problem = None; + state.snapshot.revise(); + Vec::new() + } + Event::CatalogLoadFailed { retry_at } if matches!(phase, Phase::Online { .. }) => { + state.snapshot.catalog.last_problem = + Some(Problem::new(ProblemCode::CatalogUnavailable)); + state.snapshot.revise(); + vec![Effect::ScheduleRetry(retry_at)] + } + Event::ShutdownComplete if matches!(phase, Phase::Stopping) => { + set_phase(&mut state, Phase::Stopped); + Vec::new() + } + Event::ShutdownTimedOut if matches!(phase, Phase::Stopping) => { + state.snapshot.notices.push(Notice { + problem: Problem::new(ProblemCode::ShutdownTimedOut), + content: None, + }); + set_phase(&mut state, Phase::Stopped); + Vec::new() + } + _ => { + return EventDecision::Rejected { + state, + problem: Problem::new(ProblemCode::EventNotApplicable), + }; + } + }; + + EventDecision::Applied { state, effects } +} + +fn set_phase(state: &mut AppState, phase: Phase) { + if state.snapshot.phase != phase { + state.snapshot.phase = phase; + state.snapshot.revise(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn phases() -> Vec { + vec![ + Phase::Booting, + Phase::NeedsSecret { + reason: SecretNeed::Missing, + }, + Phase::Starting { attempt: 1 }, + Phase::Blocked { + problem: Problem::new(ProblemCode::AgentDisabledOverLimit), + retry_at: Deadline::AtMillis(100), + }, + Phase::Online { + agent: AgentIdentity::new("agent-1"), + }, + Phase::Stopping, + Phase::Stopped, + ] + } + + fn state(phase: Phase) -> AppState { + AppState { + snapshot: AppSnapshot { + phase, + ..AppSnapshot::booting() + }, + } + } + + fn command(kind: CommandKind) -> Command { + match kind { + CommandKind::BeginClaim => Command::BeginClaim, + CommandKind::PollClaim => Command::PollClaim { + code: "fixture".to_owned(), + }, + CommandKind::ExchangeClaim => Command::ExchangeClaim { + code: "fixture".to_owned(), + }, + CommandKind::ProvisionSecret => Command::ProvisionSecret { + secret: SecretInput::new("secret").unwrap(), + }, + CommandKind::ResetSecret => Command::ResetSecret, + CommandKind::CreateLoginUrl => Command::CreateLoginUrl, + CommandKind::RefreshCatalog => Command::RefreshCatalog, + CommandKind::Stop => Command::Stop, + } + } + + fn accepted(command: CommandKind, phase: &Phase) -> bool { + match command { + CommandKind::BeginClaim + | CommandKind::PollClaim + | CommandKind::ExchangeClaim + | CommandKind::ProvisionSecret => { + matches!(phase, Phase::NeedsSecret { .. }) + } + CommandKind::ResetSecret => { + matches!( + phase, + Phase::NeedsSecret { .. } + | Phase::Starting { .. } + | Phase::Blocked { .. } + | Phase::Online { .. } + ) + } + CommandKind::CreateLoginUrl => { + matches!(phase, Phase::NeedsSecret { .. } | Phase::Online { .. }) + } + CommandKind::RefreshCatalog => matches!(phase, Phase::Online { .. }), + CommandKind::Stop => !matches!(phase, Phase::Stopping | Phase::Stopped), + } + } + + #[test] + fn every_command_and_phase_pair_has_an_explicit_decision() { + let mut covered = 0; + let phases = phases(); + for phase in &phases { + for command_kind in CommandKind::ALL { + covered += 1; + let before = state(phase.clone()); + let expected = accepted(command_kind, phase); + let decision = decide_command(before.clone(), command(command_kind)); + match decision { + CommandDecision::Accepted { state, effects } => { + assert!( + expected, + "unexpected acceptance for {command_kind:?} in {phase:?}" + ); + assert!(!effects.is_empty()); + if matches!( + command_kind, + CommandKind::ProvisionSecret + | CommandKind::ResetSecret + | CommandKind::Stop + ) { + assert!(state.snapshot.revision > before.snapshot.revision); + } + } + CommandDecision::Rejected { state, problem } => { + assert!( + !expected, + "unexpected rejection for {command_kind:?} in {phase:?}" + ); + assert_eq!(state, before); + let expected_code = if matches!(phase, Phase::Blocked { .. }) { + ProblemCode::AgentDisabledOverLimit + } else { + ProblemCode::CommandNotAllowed + }; + assert_eq!(problem.code, expected_code); + } + } + } + } + assert_eq!(covered, phases.len() * CommandKind::ALL.len()); + } + + #[test] + fn provisioning_failure_returns_to_secret_input() { + let provision = decide_command( + state(Phase::NeedsSecret { + reason: SecretNeed::Missing, + }), + Command::ProvisionSecret { + secret: SecretInput::new("secret").unwrap(), + }, + ); + let CommandDecision::Accepted { state, .. } = provision else { + panic!("provisioning must be accepted"); + }; + let EventDecision::Applied { state, .. } = reduce(state, Event::SecretPersistenceFailed) + else { + panic!("persistence result must apply"); + }; + assert!(matches!( + state.snapshot.phase, + Phase::NeedsSecret { + reason: SecretNeed::PersistenceFailed + } + )); + assert_eq!( + state.snapshot.notices.last().unwrap().problem.code, + ProblemCode::SecretWriteFailed + ); + } + + #[test] + fn catalog_failure_keeps_last_accepted_catalog() { + let mut online = state(Phase::Online { + agent: AgentIdentity::new("agent-1"), + }); + online.snapshot.catalog.accepted = TunnelCatalog::try_new(7, 100, Vec::new()).unwrap(); + let EventDecision::Applied { state, effects } = reduce( + online, + Event::CatalogLoadFailed { + retry_at: Deadline::AtMillis(200), + }, + ) else { + panic!("catalog failure must apply while online"); + }; + assert_eq!(state.snapshot.catalog.accepted.revision(), 7); + assert_eq!( + state.snapshot.catalog.last_problem.unwrap().code, + ProblemCode::CatalogUnavailable + ); + assert_eq!( + effects, + vec![Effect::ScheduleRetry(Deadline::AtMillis(200))] + ); + } + + #[test] + fn invalid_events_do_not_mutate_state() { + let before = state(Phase::Stopped); + assert_eq!( + reduce(before.clone(), Event::SecretLoaded), + EventDecision::Rejected { + state: before, + problem: Problem::new(ProblemCode::EventNotApplicable), + } + ); + } + + #[test] + fn retry_elapsed_preserves_the_supervisors_attempt_count() { + let blocked = state(Phase::Blocked { + problem: Problem::new(ProblemCode::AgentDisabledOverLimit), + retry_at: Deadline::AtMillis(100), + }); + let EventDecision::Applied { state, .. } = + reduce(blocked, Event::RetryElapsed { attempt: 3 }) + else { + panic!("retry event must apply while blocked"); + }; + assert_eq!(state.snapshot.phase, Phase::Starting { attempt: 3 }); + } +} diff --git a/packages/playit-model/src/config.rs b/packages/playit-model/src/config.rs new file mode 100644 index 00000000..420a19c7 --- /dev/null +++ b/packages/playit-model/src/config.rs @@ -0,0 +1,286 @@ +use std::num::{NonZeroU32, NonZeroUsize}; +use std::time::Duration; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppConfig { + pub api_base: ApiEndpoint, + pub control: ControlPolicy, + pub tcp: AdmissionPolicy, + pub udp: AdmissionPolicy, + pub shutdown: ShutdownPolicy, + pub paths: RuntimeLocations, +} + +impl AppConfig { + pub fn validate(raw: RawAppConfig) -> Result { + Ok(Self { + api_base: ApiEndpoint::parse(raw.api_base)?, + control: ControlPolicy { + connect_timeout: nonzero_duration( + raw.control_connect_timeout_millis, + ConfigField::ControlConnectTimeout, + )?, + retry_delay: nonzero_duration( + raw.control_retry_delay_millis, + ConfigField::ControlRetryDelay, + )?, + event_queue_capacity: nonzero_usize( + raw.control_event_queue_capacity, + ConfigField::ControlEventQueueCapacity, + )?, + }, + tcp: AdmissionPolicy::validate( + raw.tcp_per_second, + raw.tcp_burst, + raw.tcp_queue_capacity, + ConfigField::TcpPerSecond, + ConfigField::TcpBurst, + ConfigField::TcpQueueCapacity, + )?, + udp: AdmissionPolicy::validate( + raw.udp_per_second, + raw.udp_burst, + raw.udp_queue_capacity, + ConfigField::UdpPerSecond, + ConfigField::UdpBurst, + ConfigField::UdpQueueCapacity, + )?, + shutdown: ShutdownPolicy { + drain_timeout: nonzero_duration( + raw.shutdown_drain_timeout_millis, + ConfigField::ShutdownDrainTimeout, + )?, + }, + paths: RuntimeLocations { + secret: RuntimeLocation::parse(raw.secret_location, ConfigField::SecretLocation)?, + ipc: RuntimeLocation::parse(raw.ipc_location, ConfigField::IpcLocation)?, + }, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RawAppConfig { + pub api_base: String, + pub control_connect_timeout_millis: u64, + pub control_retry_delay_millis: u64, + pub control_event_queue_capacity: usize, + pub tcp_per_second: u32, + pub tcp_burst: u32, + pub tcp_queue_capacity: usize, + pub udp_per_second: u32, + pub udp_burst: u32, + pub udp_queue_capacity: usize, + pub shutdown_drain_timeout_millis: u64, + pub secret_location: String, + pub ipc_location: String, +} + +impl Default for RawAppConfig { + fn default() -> Self { + Self { + api_base: "https://api.playit.gg".to_owned(), + control_connect_timeout_millis: 10_000, + control_retry_delay_millis: 2_000, + control_event_queue_capacity: 1_024, + tcp_per_second: 100, + tcp_burst: 300, + tcp_queue_capacity: 1_024, + udp_per_second: 16, + udp_burst: 32, + udp_queue_capacity: 2_048, + shutdown_drain_timeout_millis: 5_000, + secret_location: "playit.toml".to_owned(), + ipc_location: "playit.sock".to_owned(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ApiEndpoint(String); + +impl ApiEndpoint { + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + let valid = ["http://", "https://"] + .iter() + .find_map(|prefix| value.strip_prefix(prefix)) + .is_some_and(|authority| { + !authority.is_empty() + && !authority.starts_with('/') + && !authority.bytes().any(|byte| byte.is_ascii_whitespace()) + }); + valid.then_some(Self(value)).ok_or(ConfigError { + field: ConfigField::ApiBase, + kind: ConfigErrorKind::InvalidEndpoint, + }) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeLocations { + pub secret: RuntimeLocation, + pub ipc: RuntimeLocation, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeLocation(String); + +impl RuntimeLocation { + pub fn parse(value: impl Into, field: ConfigField) -> Result { + let value = value.into(); + (!value.trim().is_empty()) + .then_some(Self(value)) + .ok_or(ConfigError { + field, + kind: ConfigErrorKind::Empty, + }) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ControlPolicy { + pub connect_timeout: Duration, + pub retry_delay: Duration, + pub event_queue_capacity: NonZeroUsize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AdmissionPolicy { + pub per_second: NonZeroU32, + pub burst: NonZeroU32, + pub queue_capacity: NonZeroUsize, +} + +impl AdmissionPolicy { + fn validate( + per_second: u32, + burst: u32, + queue_capacity: usize, + per_second_field: ConfigField, + burst_field: ConfigField, + queue_field: ConfigField, + ) -> Result { + Ok(Self { + per_second: nonzero_u32(per_second, per_second_field)?, + burst: nonzero_u32(burst, burst_field)?, + queue_capacity: nonzero_usize(queue_capacity, queue_field)?, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ShutdownPolicy { + pub drain_timeout: Duration, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConfigError { + pub field: ConfigField, + pub kind: ConfigErrorKind, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConfigErrorKind { + Zero, + Empty, + InvalidEndpoint, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConfigField { + ApiBase, + ControlConnectTimeout, + ControlRetryDelay, + ControlEventQueueCapacity, + TcpPerSecond, + TcpBurst, + TcpQueueCapacity, + UdpPerSecond, + UdpBurst, + UdpQueueCapacity, + ShutdownDrainTimeout, + SecretLocation, + IpcLocation, +} + +fn nonzero_u32(value: u32, field: ConfigField) -> Result { + NonZeroU32::new(value).ok_or(ConfigError { + field, + kind: ConfigErrorKind::Zero, + }) +} + +fn nonzero_usize(value: usize, field: ConfigField) -> Result { + NonZeroUsize::new(value).ok_or(ConfigError { + field, + kind: ConfigErrorKind::Zero, + }) +} + +fn nonzero_duration(value: u64, field: ConfigField) -> Result { + (value != 0) + .then_some(Duration::from_millis(value)) + .ok_or(ConfigError { + field, + kind: ConfigErrorKind::Zero, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_preserve_current_admission_limits() { + let config = AppConfig::validate(RawAppConfig::default()).unwrap(); + assert_eq!(config.tcp.per_second.get(), 100); + assert_eq!(config.tcp.burst.get(), 300); + assert_eq!(config.udp.per_second.get(), 16); + assert_eq!(config.udp.burst.get(), 32); + } + + #[test] + fn zero_capacities_are_rejected_before_runtime_creation() { + let raw = RawAppConfig { + udp_queue_capacity: 0, + ..RawAppConfig::default() + }; + assert_eq!( + AppConfig::validate(raw), + Err(ConfigError { + field: ConfigField::UdpQueueCapacity, + kind: ConfigErrorKind::Zero, + }) + ); + } + + #[test] + fn durations_and_endpoints_are_validated() { + let zero_timeout = RawAppConfig { + shutdown_drain_timeout_millis: 0, + ..RawAppConfig::default() + }; + assert_eq!( + AppConfig::validate(zero_timeout).unwrap_err().field, + ConfigField::ShutdownDrainTimeout + ); + + let invalid_endpoint = RawAppConfig { + api_base: "api.playit.gg".to_owned(), + ..RawAppConfig::default() + }; + assert_eq!( + AppConfig::validate(invalid_endpoint).unwrap_err().kind, + ConfigErrorKind::InvalidEndpoint + ); + } +} diff --git a/packages/playit-model/src/lib.rs b/packages/playit-model/src/lib.rs new file mode 100644 index 00000000..be7e2246 --- /dev/null +++ b/packages/playit-model/src/lib.rs @@ -0,0 +1,9 @@ +pub mod app; +pub mod config; +pub mod problem; +pub mod tunnel; + +pub use app::*; +pub use config::*; +pub use problem::*; +pub use tunnel::*; diff --git a/packages/playit-model/src/problem.rs b/packages/playit-model/src/problem.rs new file mode 100644 index 00000000..3d64fa43 --- /dev/null +++ b/packages/playit-model/src/problem.rs @@ -0,0 +1,252 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum ProblemCode { + Internal, + UnsupportedProtocol, + InvalidRequest, + AgentDisabledOverLimit, + InvalidSecret, + SecretPinned, + ProvisioningUnavailable, + SecretWriteFailed, + StartupFailed, + EngineUnavailable, + CatalogUnavailable, + CatalogInvalid, + TunnelDisabled, + RemoteNotice, + ShutdownTimedOut, + CommandNotAllowed, + EventNotApplicable, +} + +impl ProblemCode { + pub const ALL: [Self; 17] = [ + Self::Internal, + Self::UnsupportedProtocol, + Self::InvalidRequest, + Self::AgentDisabledOverLimit, + Self::InvalidSecret, + Self::SecretPinned, + Self::ProvisioningUnavailable, + Self::SecretWriteFailed, + Self::StartupFailed, + Self::EngineUnavailable, + Self::CatalogUnavailable, + Self::CatalogInvalid, + Self::TunnelDisabled, + Self::RemoteNotice, + Self::ShutdownTimedOut, + Self::CommandNotAllowed, + Self::EventNotApplicable, + ]; + + pub const fn as_str(self) -> &'static str { + match self { + Self::Internal => "internal", + Self::UnsupportedProtocol => "unsupported_protocol", + Self::InvalidRequest => "invalid_request", + Self::AgentDisabledOverLimit => "agent_disabled_over_limit", + Self::InvalidSecret => "invalid_secret", + Self::SecretPinned => "secret_pinned", + Self::ProvisioningUnavailable => "provisioning_unavailable", + Self::SecretWriteFailed => "secret_write_failed", + Self::StartupFailed => "startup_failed", + Self::EngineUnavailable => "engine_unavailable", + Self::CatalogUnavailable => "catalog_unavailable", + Self::CatalogInvalid => "catalog_invalid", + Self::TunnelDisabled => "tunnel_disabled", + Self::RemoteNotice => "remote_notice", + Self::ShutdownTimedOut => "shutdown_timed_out", + Self::CommandNotAllowed => "command_not_allowed", + Self::EventNotApplicable => "event_not_applicable", + } + } + + pub const fn metadata(self) -> ProblemMetadata { + match self { + Self::AgentDisabledOverLimit => ProblemMetadata { + severity: Severity::Error, + retry: RetryPolicy::AfterBackoff, + action: UserAction::ReviewAccount, + }, + Self::InvalidSecret | Self::SecretWriteFailed => ProblemMetadata { + severity: Severity::Error, + retry: RetryPolicy::UserInitiated, + action: UserAction::ProvisionSecret, + }, + Self::SecretPinned => ProblemMetadata { + severity: Severity::Error, + retry: RetryPolicy::Never, + action: UserAction::RestartWithConfiguration, + }, + Self::UnsupportedProtocol + | Self::InvalidRequest + | Self::CommandNotAllowed + | Self::EventNotApplicable => ProblemMetadata { + severity: Severity::Warning, + retry: RetryPolicy::Never, + action: UserAction::None, + }, + Self::ProvisioningUnavailable + | Self::CatalogUnavailable + | Self::EngineUnavailable + | Self::StartupFailed => ProblemMetadata { + severity: Severity::Error, + retry: RetryPolicy::AfterBackoff, + action: UserAction::Retry, + }, + Self::CatalogInvalid | Self::TunnelDisabled | Self::RemoteNotice => ProblemMetadata { + severity: Severity::Warning, + retry: RetryPolicy::UserInitiated, + action: UserAction::ReviewConfiguration, + }, + Self::ShutdownTimedOut => ProblemMetadata { + severity: Severity::Error, + retry: RetryPolicy::Never, + action: UserAction::RestartService, + }, + Self::Internal => ProblemMetadata { + severity: Severity::Error, + retry: RetryPolicy::UserInitiated, + action: UserAction::RestartService, + }, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProblemMetadata { + pub severity: Severity, + pub retry: RetryPolicy, + pub action: UserAction, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Severity { + Info, + Warning, + Error, +} + +impl Severity { + pub const fn as_str(self) -> &'static str { + match self { + Self::Info => "info", + Self::Warning => "warning", + Self::Error => "error", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RetryPolicy { + Never, + Immediate, + AfterBackoff, + UserInitiated, +} + +impl RetryPolicy { + pub const fn as_str(self) -> &'static str { + match self { + Self::Never => "never", + Self::Immediate => "immediate", + Self::AfterBackoff => "after_backoff", + Self::UserInitiated => "user_initiated", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UserAction { + None, + ProvisionSecret, + ReviewAccount, + ReviewConfiguration, + Retry, + RestartService, + RestartWithConfiguration, +} + +impl UserAction { + pub const fn as_str(self) -> &'static str { + match self { + Self::None => "none", + Self::ProvisionSecret => "provision_secret", + Self::ReviewAccount => "review_account", + Self::ReviewConfiguration => "review_configuration", + Self::Retry => "retry", + Self::RestartService => "restart_service", + Self::RestartWithConfiguration => "restart_with_configuration", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProblemSubject { + pub kind: SubjectKind, + pub id: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SubjectKind { + Tunnel, + ConfigurationField, + ChildService, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Problem { + pub code: ProblemCode, + pub metadata: ProblemMetadata, + pub subject: Option, +} + +impl Problem { + pub const fn new(code: ProblemCode) -> Self { + Self { + code, + metadata: code.metadata(), + subject: None, + } + } + + pub const fn with_subject(mut self, subject: ProblemSubject) -> Self { + self.subject = Some(subject); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stable_codes_are_unique() { + let mut values: Vec<_> = ProblemCode::ALL + .into_iter() + .map(ProblemCode::as_str) + .collect(); + let count = values.len(); + values.sort_unstable(); + values.dedup(); + assert_eq!(values.len(), count); + } + + #[test] + fn metadata_encodes_actions_without_copy() { + assert_eq!( + ProblemCode::AgentDisabledOverLimit.metadata(), + ProblemMetadata { + severity: Severity::Error, + retry: RetryPolicy::AfterBackoff, + action: UserAction::ReviewAccount, + } + ); + assert_eq!( + Problem::new(ProblemCode::InvalidSecret).metadata.action, + UserAction::ProvisionSecret + ); + } +} diff --git a/packages/playit-model/src/tunnel.rs b/packages/playit-model/src/tunnel.rs new file mode 100644 index 00000000..c6d3d164 --- /dev/null +++ b/packages/playit-model/src/tunnel.rs @@ -0,0 +1,317 @@ +use std::collections::HashSet; +use std::net::IpAddr; +use std::num::{NonZeroU16, NonZeroU64}; + +use crate::problem::{Problem, ProblemCode, ProblemSubject, SubjectKind}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct TunnelId(NonZeroU64); + +impl TunnelId { + pub const fn new(value: NonZeroU64) -> Self { + Self(value) + } + + pub const fn get(self) -> u64 { + self.0.get() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TunnelProtocol { + Tcp, + Udp, + Both, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProxyProtocol { + None, + V1, + V2, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OriginHost { + Ip(IpAddr), + Hostname(Hostname), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Hostname(String); + +impl Hostname { + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + let valid = !value.is_empty() + && value.len() <= 253 + && !value.starts_with('.') + && !value.ends_with('.') + && value.split('.').all(valid_label); + valid + .then_some(Self(value)) + .ok_or(TunnelValidationError::InvalidHostname) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +fn valid_label(label: &str) -> bool { + !label.is_empty() + && label.len() <= 63 + && !label.starts_with('-') + && !label.ends_with('-') + && label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OriginTarget { + Port { + host: OriginHost, + first_port: NonZeroU16, + }, + Https { + host: OriginHost, + http_port: NonZeroU16, + https_port: NonZeroU16, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TunnelSpec { + pub id: TunnelId, + pub protocol: TunnelProtocol, + pub target: OriginTarget, + pub port_count: NonZeroU16, + pub proxy_protocol: ProxyProtocol, +} + +impl TunnelSpec { + pub fn target_port(&self, offset: u16) -> Option { + if offset >= self.port_count.get() { + return None; + } + + match &self.target { + OriginTarget::Port { first_port, .. } => first_port + .get() + .checked_add(offset) + .and_then(NonZeroU16::new), + OriginTarget::Https { + http_port, + https_port, + .. + } => match offset { + 0 => Some(*http_port), + 1 => Some(*https_port), + _ => None, + }, + } + } + + pub fn validate(&self) -> Result<(), TunnelValidationError> { + if matches!(self.target, OriginTarget::Https { .. }) && self.port_count.get() != 2 { + return Err(TunnelValidationError::HttpsPortCount); + } + + if let OriginTarget::Port { first_port, .. } = &self.target { + let last_offset = self.port_count.get() - 1; + if first_port.get().checked_add(last_offset).is_none() { + return Err(TunnelValidationError::PortRangeOverflow); + } + } + + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TunnelAvailability { + Active, + Disabled(Problem), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CatalogTunnel { + pub spec: TunnelSpec, + pub availability: TunnelAvailability, + pub public_address: String, + pub disabled_reason: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct TunnelCatalog { + revision: u64, + accepted_at_millis: Option, + tunnels: Vec, +} + +impl TunnelCatalog { + pub fn try_new( + revision: u64, + accepted_at_millis: u64, + tunnels: Vec, + ) -> Result { + let mut ids = HashSet::with_capacity(tunnels.len()); + for tunnel in &tunnels { + tunnel + .spec + .validate() + .map_err(|error| CatalogValidationError::InvalidTunnel { + id: tunnel.spec.id, + error, + })?; + if !ids.insert(tunnel.spec.id) { + return Err(CatalogValidationError::DuplicateTunnel(tunnel.spec.id)); + } + } + + Ok(Self { + revision, + accepted_at_millis: Some(accepted_at_millis), + tunnels, + }) + } + + pub const fn revision(&self) -> u64 { + self.revision + } + + pub const fn accepted_at_millis(&self) -> Option { + self.accepted_at_millis + } + + pub fn tunnels(&self) -> &[CatalogTunnel] { + &self.tunnels + } + + pub fn problem_for(error: &CatalogValidationError) -> Problem { + let subject = match error { + CatalogValidationError::DuplicateTunnel(id) + | CatalogValidationError::InvalidTunnel { id, .. } => Some(ProblemSubject { + kind: SubjectKind::Tunnel, + id: id.get(), + }), + }; + let mut problem = Problem::new(ProblemCode::CatalogInvalid); + problem.subject = subject; + problem + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TunnelValidationError { + InvalidHostname, + PortRangeOverflow, + HttpsPortCount, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CatalogValidationError { + DuplicateTunnel(TunnelId), + InvalidTunnel { + id: TunnelId, + error: TunnelValidationError, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn id(value: u64) -> TunnelId { + TunnelId::new(NonZeroU64::new(value).unwrap()) + } + + fn tunnel(value: u64, first_port: u16, count: u16) -> CatalogTunnel { + CatalogTunnel { + spec: TunnelSpec { + id: id(value), + protocol: TunnelProtocol::Tcp, + target: OriginTarget::Port { + host: OriginHost::Ip("127.0.0.1".parse().unwrap()), + first_port: NonZeroU16::new(first_port).unwrap(), + }, + port_count: NonZeroU16::new(count).unwrap(), + proxy_protocol: ProxyProtocol::None, + }, + availability: TunnelAvailability::Active, + public_address: format!("demo-{value}.playit.gg:{first_port}"), + disabled_reason: None, + } + } + + #[test] + fn hostnames_are_validated_without_network_access() { + assert_eq!( + Hostname::parse("origin.internal").unwrap().as_str(), + "origin.internal" + ); + assert_eq!( + Hostname::parse("bad host"), + Err(TunnelValidationError::InvalidHostname) + ); + assert_eq!( + Hostname::parse("-bad.example"), + Err(TunnelValidationError::InvalidHostname) + ); + } + + #[test] + fn port_ranges_are_checked_before_publication() { + let error = TunnelCatalog::try_new(1, 10, vec![tunnel(1, u16::MAX, 2)]).unwrap_err(); + assert_eq!( + error, + CatalogValidationError::InvalidTunnel { + id: id(1), + error: TunnelValidationError::PortRangeOverflow, + } + ); + } + + #[test] + fn catalogs_reject_duplicate_ids_atomically() { + let error = + TunnelCatalog::try_new(1, 10, vec![tunnel(7, 80, 1), tunnel(7, 81, 1)]).unwrap_err(); + assert_eq!(error, CatalogValidationError::DuplicateTunnel(id(7))); + } + + #[test] + fn target_port_respects_normalized_count() { + let tunnel = tunnel(1, 4000, 2); + assert_eq!(tunnel.spec.target_port(0).unwrap().get(), 4000); + assert_eq!(tunnel.spec.target_port(1).unwrap().get(), 4001); + assert_eq!(tunnel.spec.target_port(2), None); + } + + #[test] + fn https_tunnels_require_both_routing_ports() { + let tunnel = CatalogTunnel { + spec: TunnelSpec { + id: id(1), + protocol: TunnelProtocol::Tcp, + target: OriginTarget::Https { + host: OriginHost::Ip("127.0.0.1".parse().unwrap()), + http_port: NonZeroU16::new(80).unwrap(), + https_port: NonZeroU16::new(443).unwrap(), + }, + port_count: NonZeroU16::MIN, + proxy_protocol: ProxyProtocol::None, + }, + availability: TunnelAvailability::Active, + public_address: "demo.playit.gg".to_owned(), + disabled_reason: None, + }; + assert_eq!( + TunnelCatalog::try_new(1, 10, vec![tunnel]), + Err(CatalogValidationError::InvalidTunnel { + id: id(1), + error: TunnelValidationError::HttpsPortCount, + }) + ); + } +} diff --git a/packages/playit-platform/Cargo.toml b/packages/playit-platform/Cargo.toml new file mode 100644 index 00000000..7620eeaf --- /dev/null +++ b/packages/playit-platform/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "playit-platform" +version.workspace = true +edition = "2024" +description = "Operating-system integration mechanisms for playit" +license = "BSD-2-Clause" +repository = "https://github.com/playit-cloud/playit-agent" + +[dependencies] +dirs = { workspace = true } +service-manager = "0.10" +tokio = { workspace = true } + +[target.'cfg(unix)'.dependencies] +libc = "0.2" +uzers = { version = "0.12.2", default-features = false } + +[target.'cfg(windows)'.dependencies] +interprocess = { version = "2.2", features = ["tokio"] } +widestring = "1.2" +windows = { version = "0.62.2", features = [ + "Win32_Foundation", + "Win32_System_Com", + "Win32_System_Environment", + "Win32_System_Registry", + "Win32_UI_Shell", +] } +windows-sys = { version = "0.59.0", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Storage_FileSystem", + "Win32_System_Services", + "Win32_System_Threading", +] } diff --git a/packages/playit-platform/src/lib.rs b/packages/playit-platform/src/lib.rs new file mode 100644 index 00000000..f00ac396 --- /dev/null +++ b/packages/playit-platform/src/lib.rs @@ -0,0 +1,15 @@ +pub mod paths; +pub mod secret; +pub mod service; + +#[cfg(any(target_os = "windows", test))] +mod migration; + +#[cfg(target_os = "linux")] +pub mod linux; +#[cfg(unix)] +pub mod unix; +#[cfg(target_os = "windows")] +pub mod windows; + +pub use paths::default_secret_path; diff --git a/packages/playit-platform/src/linux.rs b/packages/playit-platform/src/linux.rs new file mode 100644 index 00000000..0d8eff7b --- /dev/null +++ b/packages/playit-platform/src/linux.rs @@ -0,0 +1,204 @@ +use std::ffi::CString; +use std::fs; +use std::io; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt}; +use std::path::Path; + +use crate::unix::{ + current_process_has_group, current_user_account, effective_gid, effective_uid, + group_gid_by_name, group_info_by_gid, +}; + +pub const SOCKET_GROUP_NAME: &str = "playit"; +pub const SOCKET_MODE: u32 = 0o660; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SocketAccessIssue { + MissingSocket, + InspectFailed(String), + NotASocket, + GroupJoinRequired, + GroupRefreshRequired, + PermissionDenied { + current_uid: u32, + current_gid: u32, + socket_uid: u32, + socket_gid: u32, + socket_mode: u32, + }, +} + +pub fn configure_socket_permissions(socket_path: &str) -> io::Result<()> { + let Some(target) = socket_permission_target(socket_path, effective_uid()) else { + return Ok(()); + }; + if !Path::new(target.path).exists() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!("IPC socket {} was not created", target.path), + )); + } + + let group_gid = if target.chown_group { + group_gid_by_name(target.group_name) + } else { + None + }; + apply_socket_permissions(target.path, group_gid, target.mode) +} + +pub fn inspect_socket_access(socket_path: &str) -> Option { + let path = Path::new(socket_path); + let metadata = match fs::metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Some(SocketAccessIssue::MissingSocket); + } + Err(error) if error.kind() == io::ErrorKind::PermissionDenied => { + return inspect_parent_access(path) + .or_else(|| Some(SocketAccessIssue::InspectFailed(error.to_string()))); + } + Err(error) => return Some(SocketAccessIssue::InspectFailed(error.to_string())), + }; + + if !metadata.file_type().is_socket() { + return Some(SocketAccessIssue::NotASocket); + } + if current_user_can_write(&metadata) { + return None; + } + + let socket_gid = metadata.gid(); + if group_info_by_gid(socket_gid) + .as_ref() + .map(|group| group.name.as_str()) + == Some(SOCKET_GROUP_NAME) + && let Some(issue) = group_access_issue(socket_gid) + { + return Some(issue); + } + + Some(SocketAccessIssue::PermissionDenied { + current_uid: effective_uid(), + current_gid: effective_gid(), + socket_uid: metadata.uid(), + socket_gid, + socket_mode: metadata.mode() & 0o777, + }) +} + +fn inspect_parent_access(path: &Path) -> Option { + let metadata = fs::metadata(path.parent()?).ok()?; + let gid = metadata.gid(); + let group = group_info_by_gid(gid)?; + (group.name == SOCKET_GROUP_NAME) + .then(|| group_access_issue(gid)) + .flatten() +} + +fn group_access_issue(target_gid: u32) -> Option { + let account = current_user_account()?; + let configured = if account.primary_gid == target_gid { + true + } else { + group_info_by_gid(target_gid)? + .members + .iter() + .any(|member| member == &account.username) + }; + Some(if configured { + SocketAccessIssue::GroupRefreshRequired + } else { + SocketAccessIssue::GroupJoinRequired + }) +} + +fn current_user_can_write(metadata: &fs::Metadata) -> bool { + let uid = effective_uid(); + if uid == 0 { + return true; + } + let mode = metadata.mode(); + if uid == metadata.uid() { + mode & 0o200 != 0 + } else if current_process_has_group(metadata.gid()) { + mode & 0o020 != 0 + } else { + mode & 0o002 != 0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SocketPermissionTarget<'a> { + path: &'a str, + group_name: &'static str, + mode: u32, + chown_group: bool, +} + +fn socket_permission_target( + socket_path: &str, + effective_uid: u32, +) -> Option> { + if socket_path.starts_with('@') || socket_path.starts_with(r"\\.\pipe\") { + return None; + } + Some(SocketPermissionTarget { + path: socket_path, + group_name: SOCKET_GROUP_NAME, + mode: SOCKET_MODE, + chown_group: effective_uid == 0, + }) +} + +fn apply_socket_permissions(path: &str, group_gid: Option, mode: u32) -> io::Result<()> { + fs::set_permissions(path, fs::Permissions::from_mode(mode)).map_err(|error| { + io::Error::new( + error.kind(), + format!("failed to chmod IPC socket {path} to {mode:o}: {error}"), + ) + })?; + let Some(group_gid) = group_gid else { + return Ok(()); + }; + let path_cstr = CString::new(Path::new(path).as_os_str().as_bytes()).map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("invalid IPC socket path {path:?}: {error}"), + ) + })?; + if unsafe { libc::chown(path_cstr.as_ptr(), u32::MAX, group_gid) } != 0 { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!( + "failed to chown IPC socket {path} to group gid {group_gid}: {}", + io::Error::last_os_error() + ), + )) + } else { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn installed_socket_permissions_are_stable() { + let target = socket_permission_target("/run/playit/playitd.sock", 0).unwrap(); + assert_eq!(target.group_name, "playit"); + assert_eq!(target.mode, 0o660); + assert!(target.chown_group); + } + + #[test] + fn non_filesystem_endpoints_are_ignored() { + assert_eq!(socket_permission_target("@playitd", 0), None); + assert_eq!( + socket_permission_target(r"\\.\pipe\playitd-system", 0), + None + ); + } +} diff --git a/packages/playit-platform/src/migration.rs b/packages/playit-platform/src/migration.rs new file mode 100644 index 00000000..288cc1d2 --- /dev/null +++ b/packages/playit-platform/src/migration.rs @@ -0,0 +1,143 @@ +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MigrationStatus { + AlreadyConfigured, + MissingLegacy, + Copied, +} + +pub(crate) fn migrate_v17_secret_from_profile( + new_path: &Path, + profile_dir: &Path, +) -> Result { + if new_path.exists() { + return Ok(MigrationStatus::AlreadyConfigured); + } + let old_path = v17_secret_path_from_profile(profile_dir); + if !old_path.exists() { + return Ok(MigrationStatus::MissingLegacy); + } + if let Some(parent) = new_path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "Failed to create playit service config directory at {}: {error}", + parent.display() + ) + })?; + } + let content = fs::read(&old_path).map_err(|error| { + format!( + "Failed to read legacy playit config at {}: {error}", + old_path.display() + ) + })?; + let mut file = match fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(new_path) + { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + return Ok(MigrationStatus::AlreadyConfigured); + } + Err(error) => { + return Err(format!( + "Failed to create migrated playit config at {}: {error}", + new_path.display() + )); + } + }; + file.write_all(&content).map_err(|error| { + format!( + "Failed to write migrated playit config to {}: {error}", + new_path.display() + ) + })?; + Ok(MigrationStatus::Copied) +} + +fn v17_secret_path_from_profile(profile_dir: &Path) -> PathBuf { + profile_dir + .join("AppData") + .join("Local") + .join("playit_gg") + .join("playit.toml") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn legacy_path_and_contents_are_preserved() { + let temp = TempDir::new("legacy-copy"); + let profile = temp.path().join("profile"); + let old_path = v17_secret_path_from_profile(&profile); + let new_path = temp.path().join("program-data").join("playit.toml"); + fs::create_dir_all(old_path.parent().unwrap()).unwrap(); + fs::write(&old_path, "secret_key = \"abc\"\n").unwrap(); + + assert_eq!( + migrate_v17_secret_from_profile(&new_path, &profile).unwrap(), + MigrationStatus::Copied + ); + assert_eq!( + fs::read_to_string(new_path).unwrap(), + "secret_key = \"abc\"\n" + ); + assert!( + old_path.ends_with( + PathBuf::from("AppData") + .join("Local") + .join("playit_gg") + .join("playit.toml") + ) + ); + } + + #[test] + fn migration_never_overwrites_existing_configuration() { + let temp = TempDir::new("existing-config"); + let profile = temp.path().join("profile"); + let new_path = temp.path().join("program-data").join("playit.toml"); + fs::create_dir_all(new_path.parent().unwrap()).unwrap(); + fs::write(&new_path, "new").unwrap(); + + assert_eq!( + migrate_v17_secret_from_profile(&new_path, &profile).unwrap(), + MigrationStatus::AlreadyConfigured + ); + assert_eq!(fs::read_to_string(new_path).unwrap(), "new"); + } + + struct TempDir(PathBuf); + + impl TempDir { + fn new(name: &str) -> Self { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "playit-platform-{name}-{}-{nanos}", + std::process::id() + )); + fs::create_dir_all(&path).unwrap(); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } +} diff --git a/packages/playit-platform/src/paths.rs b/packages/playit-platform/src/paths.rs new file mode 100644 index 00000000..687a6c15 --- /dev/null +++ b/packages/playit-platform/src/paths.rs @@ -0,0 +1,135 @@ +use std::path::{Path, PathBuf}; +#[cfg(target_os = "macos")] +use std::sync::LazyLock; + +pub const LINUX_SERVICE_NAME: &str = "playit"; +pub const WINDOWS_SERVICE_NAME: &str = "playitd"; +pub const NATIVE_SERVICE_LABEL: &str = "gg.playit.playitd"; +pub const LINUX_INSTALLED_SECRET_PATH: &str = "/etc/playit/playit.toml"; +pub const LINUX_INSTALLED_SOCKET_PATH: &str = "/run/playit/playitd.sock"; +pub const LINUX_INSTALLED_LOG_PATH: &str = "/var/log/playit/playit.log"; +pub const WINDOWS_DATA_DIR_NAME: &str = "playit_gg"; +pub const WINDOWS_TRAY_SHORTCUT_NAME: &str = "Playit Tray.lnk"; + +pub fn default_secret_path() -> PathBuf { + if Path::new("playit.toml").exists() { + return PathBuf::from("playit.toml"); + } + + #[cfg(target_os = "linux")] + if let Some(path) = linux_default_secret_path() { + return path; + } + + playit_config_dir().join("playit.toml") +} + +pub fn playit_config_dir() -> PathBuf { + dirs::config_local_dir() + .unwrap_or_else(|| ".".into()) + .join(WINDOWS_DATA_DIR_NAME) +} + +pub fn default_socket_path_string() -> String { + #[cfg(target_os = "linux")] + { + LINUX_INSTALLED_SOCKET_PATH.to_owned() + } + #[cfg(target_os = "macos")] + { + macos_launch_agent_socket_path().display().to_string() + } + #[cfg(target_os = "windows")] + { + r"\\.\pipe\playitd-system".to_owned() + } + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + { + "./playitd.sock".to_owned() + } +} + +pub fn default_socket_path() -> &'static str { + #[cfg(target_os = "linux")] + { + LINUX_INSTALLED_SOCKET_PATH + } + #[cfg(target_os = "macos")] + { + static PATH: LazyLock = LazyLock::new(default_socket_path_string); + PATH.as_str() + } + #[cfg(target_os = "windows")] + { + r"\\.\pipe\playitd-system" + } + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + { + "./playitd.sock" + } +} + +#[cfg(target_os = "linux")] +pub fn linux_default_secret_path() -> Option { + let path = PathBuf::from(LINUX_INSTALLED_SECRET_PATH); + path.exists().then_some(path) +} + +#[cfg(target_os = "macos")] +pub fn macos_launch_agent_data_dir() -> PathBuf { + playit_config_dir() +} + +#[cfg(target_os = "macos")] +pub fn macos_launch_agent_secret_path() -> PathBuf { + macos_launch_agent_data_dir().join("playit.toml") +} + +#[cfg(target_os = "macos")] +pub fn macos_launch_agent_socket_path() -> PathBuf { + macos_launch_agent_data_dir().join("playitd.sock") +} + +#[cfg(target_os = "macos")] +pub fn macos_launch_agent_log_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| ".".into()) + .join("Library") + .join("Logs") + .join("playit") +} + +#[cfg(target_os = "macos")] +pub fn macos_launch_agent_log_path() -> PathBuf { + macos_launch_agent_log_dir().join("playitd.log") +} + +#[cfg(target_os = "windows")] +pub fn windows_service_data_dir() -> PathBuf { + std::env::var_os("PROGRAMDATA") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\ProgramData")) + .join(WINDOWS_DATA_DIR_NAME) +} + +#[cfg(target_os = "windows")] +pub fn windows_service_secret_path() -> PathBuf { + windows_service_data_dir().join("playit.toml") +} + +#[cfg(target_os = "windows")] +pub fn windows_service_log_path() -> PathBuf { + windows_service_data_dir().join("logs").join("playitd.log") +} + +#[cfg(target_os = "windows")] +pub fn windows_installer_log_path() -> PathBuf { + windows_service_data_dir() + .join("logs") + .join("playit-installer.log") +} + +#[cfg(target_os = "windows")] +pub fn windows_installed_user_sid_path() -> PathBuf { + windows_service_data_dir().join("installed_user.sid") +} diff --git a/packages/playit-platform/src/secret.rs b/packages/playit-platform/src/secret.rs new file mode 100644 index 00000000..3a2db01b --- /dev/null +++ b/packages/playit-platform/src/secret.rs @@ -0,0 +1,121 @@ +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +pub async fn atomic_write_secret(path: &Path, content: &[u8]) -> io::Result<()> { + let path = path.to_path_buf(); + let content = content.to_vec(); + tokio::task::spawn_blocking(move || atomic_write_secret_blocking(&path, &content)) + .await + .map_err(io::Error::other)? +} + +pub fn atomic_write_secret_blocking(path: &Path, content: &[u8]) -> io::Result<()> { + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + std::fs::create_dir_all(parent)?; + + let temp = temporary_path(path, parent); + let result = (|| { + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + + let mut file = options.open(&temp)?; + file.write_all(content)?; + file.sync_all()?; + drop(file); + + replace_file(&temp, path)?; + secure_secret_permissions(path)?; + sync_parent(parent)?; + Ok(()) + })(); + + if result.is_err() { + let _ = std::fs::remove_file(&temp); + } + result +} + +fn temporary_path(path: &Path, parent: &Path) -> PathBuf { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("playit.toml"); + let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + parent.join(format!( + ".{file_name}.tmp-{}-{nanos}-{sequence}", + std::process::id() + )) +} + +#[cfg(not(target_os = "windows"))] +fn replace_file(temp: &Path, path: &Path) -> io::Result<()> { + std::fs::rename(temp, path) +} + +#[cfg(target_os = "windows")] +fn replace_file(temp: &Path, path: &Path) -> io::Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, + }; + + let temp = temp + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let path = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let moved = unsafe { + MoveFileExW( + temp.as_ptr(), + path.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + if moved == 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} + +#[cfg(unix)] +fn secure_secret_permissions(path: &Path) -> io::Result<()> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) +} + +#[cfg(not(unix))] +fn secure_secret_permissions(_path: &Path) -> io::Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn sync_parent(parent: &Path) -> io::Result<()> { + std::fs::File::open(parent)?.sync_all() +} + +#[cfg(not(unix))] +fn sync_parent(_parent: &Path) -> io::Result<()> { + Ok(()) +} diff --git a/packages/playit-platform/src/service.rs b/packages/playit-platform/src/service.rs new file mode 100644 index 00000000..ca47ca1d --- /dev/null +++ b/packages/playit-platform/src/service.rs @@ -0,0 +1,538 @@ +use std::future::Future; +use std::process::{Command, Output}; +use std::time::Duration; + +use service_manager::{ServiceLabel, ServiceManager, ServiceStartCtx, ServiceStopCtx}; + +use crate::paths::{LINUX_SERVICE_NAME, NATIVE_SERVICE_LABEL, WINDOWS_SERVICE_NAME}; + +#[derive(Debug)] +pub enum ServiceError { + NotAvailable(String), + StartFailed(String), + StopFailed(String), + NotFound, + Io(std::io::Error), +} + +impl std::fmt::Display for ServiceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotAvailable(message) => write!(f, "Service manager not available: {message}"), + Self::StartFailed(message) => write!(f, "Failed to start service: {message}"), + Self::StopFailed(message) => write!(f, "Failed to stop service: {message}"), + Self::NotFound => write!(f, "Service not found"), + Self::Io(error) => write!(f, "IO error: {error}"), + } + } +} + +impl std::error::Error for ServiceError {} + +impl From for ServiceError { + fn from(error: std::io::Error) -> Self { + Self::Io(error) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ServiceState { + Running, + Stopped, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ServiceManagerKind { + Systemd, + OpenRc, + MacOsLaunchd, + WindowsScm, + Native, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ReachabilityPolicy { + pub attempts: usize, + pub interval: Duration, +} + +impl Default for ReachabilityPolicy { + fn default() -> Self { + Self { + attempts: 50, + interval: Duration::from_millis(100), + } + } +} + +pub const fn installed_service_label() -> &'static str { + if cfg!(target_os = "windows") { + WINDOWS_SERVICE_NAME + } else { + NATIVE_SERVICE_LABEL + } +} + +pub const fn native_service_manager() -> ServiceManagerKind { + if cfg!(target_os = "linux") { + ServiceManagerKind::Systemd + } else if cfg!(target_os = "macos") { + ServiceManagerKind::MacOsLaunchd + } else if cfg!(target_os = "windows") { + ServiceManagerKind::WindowsScm + } else { + ServiceManagerKind::Native + } +} + +pub fn start_installed_service(manager: ServiceManagerKind) -> Result<(), ServiceError> { + match manager { + ServiceManagerKind::Systemd => { + run_systemctl(&systemd_start_args(), ServiceError::StartFailed) + } + ServiceManagerKind::OpenRc => { + run_rc_service(&openrc_start_args(), ServiceError::StartFailed) + } + ServiceManagerKind::MacOsLaunchd | ServiceManagerKind::Native => { + native_controller()?.start() + } + ServiceManagerKind::WindowsScm => windows_start_installed_service(), + } +} + +pub fn stop_installed_service(manager: ServiceManagerKind) -> Result<(), ServiceError> { + match manager { + ServiceManagerKind::Systemd => { + run_systemctl(&systemd_stop_args(), ServiceError::StopFailed) + } + ServiceManagerKind::OpenRc => run_rc_service(&openrc_stop_args(), ServiceError::StopFailed), + ServiceManagerKind::MacOsLaunchd | ServiceManagerKind::Native => { + native_controller()?.stop() + } + ServiceManagerKind::WindowsScm => windows_stop_installed_service(), + } +} + +pub fn installed_service_state(manager: ServiceManagerKind) -> Result { + match manager { + ServiceManagerKind::Systemd => systemd_service_state(), + ServiceManagerKind::OpenRc => openrc_service_state(), + ServiceManagerKind::MacOsLaunchd => macos_service_state(), + ServiceManagerKind::WindowsScm => windows_installed_service_state(), + ServiceManagerKind::Native => Err(ServiceError::NotAvailable( + "service status queries are not supported on this platform".to_owned(), + )), + } +} + +pub async fn wait_until_reachable( + policy: ReachabilityPolicy, + mut probe: F, +) -> Result<(), ServiceError> +where + F: FnMut() -> Fut, + Fut: Future, +{ + for attempt in 0..policy.attempts { + if probe().await { + return Ok(()); + } + if attempt + 1 < policy.attempts { + tokio::time::sleep(policy.interval).await; + } + } + + Err(ServiceError::StartFailed( + "Service did not become reachable within timeout".to_owned(), + )) +} + +pub async fn start_and_wait_until_reachable( + manager: ServiceManagerKind, + policy: ReachabilityPolicy, + probe: F, +) -> Result<(), ServiceError> +where + F: FnMut() -> Fut, + Fut: Future, +{ + start_installed_service(manager)?; + wait_until_reachable(policy, probe).await +} + +fn native_controller() -> Result { + let manager = ::native() + .map_err(|error| ServiceError::NotAvailable(error.to_string()))?; + let label = installed_service_label() + .parse::() + .map_err(|error| ServiceError::NotAvailable(error.to_string()))?; + Ok(NativeController { manager, label }) +} + +struct NativeController { + manager: Box, + label: ServiceLabel, +} + +impl NativeController { + fn start(&self) -> Result<(), ServiceError> { + self.manager + .start(ServiceStartCtx { + label: self.label.clone(), + }) + .map_err(|error| ServiceError::StartFailed(error.to_string())) + } + + fn stop(&self) -> Result<(), ServiceError> { + self.manager + .stop(ServiceStopCtx { + label: self.label.clone(), + }) + .map_err(|error| ServiceError::StopFailed(error.to_string())) + } +} + +fn systemd_service_state() -> Result { + let args = systemd_status_args(); + let output = Command::new("systemctl") + .args(args) + .output() + .map_err(|error| ServiceError::NotAvailable(format!("Failed to run systemctl: {error}")))?; + + match output.status.code() { + Some(0) => Ok(ServiceState::Running), + Some(3) | Some(4) => Ok(ServiceState::Stopped), + _ => Err(ServiceError::NotAvailable(format!( + "systemctl {} failed: {}", + args.join(" "), + command_output_detail(&output) + ))), + } +} + +fn openrc_service_state() -> Result { + let args = openrc_status_args(); + let output = Command::new("rc-service") + .args(args) + .output() + .map_err(|error| { + ServiceError::NotAvailable(format!("Failed to run rc-service: {error}")) + })?; + Ok(if output.status.success() { + ServiceState::Running + } else { + ServiceState::Stopped + }) +} + +#[cfg(target_os = "macos")] +fn macos_service_state() -> Result { + let uid = crate::unix::effective_uid(); + let target = format!("gui/{uid}/{NATIVE_SERVICE_LABEL}"); + let output = Command::new("launchctl") + .args(["print", &target]) + .output() + .map_err(|error| ServiceError::NotAvailable(format!("Failed to run launchctl: {error}")))?; + Ok(if output.status.success() { + ServiceState::Running + } else { + ServiceState::Stopped + }) +} + +#[cfg(not(target_os = "macos"))] +fn macos_service_state() -> Result { + Err(ServiceError::NotAvailable( + "launchd is only available on macOS".to_owned(), + )) +} + +fn run_systemctl(args: &[&str], error: fn(String) -> ServiceError) -> Result<(), ServiceError> { + run_command("systemctl", args, error) +} + +fn systemd_start_args() -> [&'static str; 2] { + ["start", LINUX_SERVICE_NAME] +} + +fn systemd_status_args() -> [&'static str; 3] { + ["is-active", "--quiet", LINUX_SERVICE_NAME] +} + +fn systemd_stop_args() -> [&'static str; 2] { + ["stop", LINUX_SERVICE_NAME] +} + +fn openrc_start_args() -> [&'static str; 2] { + [LINUX_SERVICE_NAME, "start"] +} + +fn openrc_status_args() -> [&'static str; 2] { + [LINUX_SERVICE_NAME, "status"] +} + +fn openrc_stop_args() -> [&'static str; 2] { + [LINUX_SERVICE_NAME, "stop"] +} + +fn run_rc_service(args: &[&str], error: fn(String) -> ServiceError) -> Result<(), ServiceError> { + run_command("rc-service", args, error) +} + +fn run_command( + program: &str, + args: &[&str], + error: fn(String) -> ServiceError, +) -> Result<(), ServiceError> { + let output = Command::new(program) + .args(args) + .output() + .map_err(|source| error(format!("Failed to run {program}: {source}")))?; + if output.status.success() { + Ok(()) + } else { + Err(error(format!( + "{program} {} failed: {}", + args.join(" "), + command_output_detail(&output) + ))) + } +} + +fn command_output_detail(output: &Output) -> String { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + if !stderr.is_empty() { + stderr + } else if !stdout.is_empty() { + stdout + } else { + format!("exit status {}", output.status) + } +} + +#[cfg(target_os = "windows")] +fn windows_start_installed_service() -> Result<(), ServiceError> { + windows_scm::start() +} + +#[cfg(not(target_os = "windows"))] +fn windows_start_installed_service() -> Result<(), ServiceError> { + Err(ServiceError::NotAvailable( + "Windows SCM is only available on Windows".to_owned(), + )) +} + +#[cfg(target_os = "windows")] +fn windows_stop_installed_service() -> Result<(), ServiceError> { + windows_scm::stop() +} + +#[cfg(not(target_os = "windows"))] +fn windows_stop_installed_service() -> Result<(), ServiceError> { + Err(ServiceError::NotAvailable( + "Windows SCM is only available on Windows".to_owned(), + )) +} + +#[cfg(target_os = "windows")] +fn windows_installed_service_state() -> Result { + windows_scm::state() +} + +#[cfg(not(target_os = "windows"))] +fn windows_installed_service_state() -> Result { + Err(ServiceError::NotAvailable( + "Windows SCM is only available on Windows".to_owned(), + )) +} + +#[cfg(target_os = "windows")] +mod windows_scm { + use super::{ServiceError, ServiceState}; + use crate::paths::WINDOWS_SERVICE_NAME; + use windows_sys::Win32::Foundation::{ + ERROR_SERVICE_ALREADY_RUNNING, ERROR_SERVICE_DOES_NOT_EXIST, ERROR_SERVICE_NOT_ACTIVE, + }; + use windows_sys::Win32::System::Services::{ + CloseServiceHandle, ControlService, OpenSCManagerW, OpenServiceW, QueryServiceStatusEx, + SC_HANDLE, SC_MANAGER_CONNECT, SC_STATUS_PROCESS_INFO, SERVICE_CONTROL_STOP, + SERVICE_QUERY_STATUS, SERVICE_RUNNING, SERVICE_START, SERVICE_STATUS, + SERVICE_STATUS_PROCESS, SERVICE_STOP, StartServiceW, + }; + + pub(super) fn start() -> Result<(), ServiceError> { + unsafe { + let manager = open_manager()?; + let service = OpenServiceW( + manager.0, + wide(WINDOWS_SERVICE_NAME).as_ptr(), + SERVICE_START | SERVICE_QUERY_STATUS, + ); + if service.is_null() { + return Err(open_service_error(Action::Start)); + } + let service = ServiceHandle(service); + if StartServiceW(service.0, 0, std::ptr::null()) != 0 { + return Ok(()); + } + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(ERROR_SERVICE_ALREADY_RUNNING as i32) { + Ok(()) + } else { + Err(ServiceError::StartFailed(error.to_string())) + } + } + } + + pub(super) fn stop() -> Result<(), ServiceError> { + unsafe { + let manager = open_manager()?; + let service = OpenServiceW( + manager.0, + wide(WINDOWS_SERVICE_NAME).as_ptr(), + SERVICE_STOP | SERVICE_QUERY_STATUS, + ); + if service.is_null() { + return Err(open_service_error(Action::Stop)); + } + let service = ServiceHandle(service); + let mut status = std::mem::zeroed::(); + if ControlService(service.0, SERVICE_CONTROL_STOP, &mut status) != 0 { + return Ok(()); + } + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(ERROR_SERVICE_NOT_ACTIVE as i32) { + Ok(()) + } else { + Err(ServiceError::StopFailed(error.to_string())) + } + } + } + + pub(super) fn state() -> Result { + unsafe { + let manager = open_manager()?; + let service = OpenServiceW( + manager.0, + wide(WINDOWS_SERVICE_NAME).as_ptr(), + SERVICE_QUERY_STATUS, + ); + if service.is_null() { + let error = std::io::Error::last_os_error(); + return if error.raw_os_error() == Some(ERROR_SERVICE_DOES_NOT_EXIST as i32) { + Ok(ServiceState::Stopped) + } else { + Err(ServiceError::NotAvailable(format!( + "failed to open Windows service: {error}" + ))) + }; + } + let service = ServiceHandle(service); + let mut status = std::mem::zeroed::(); + let mut needed = 0; + if QueryServiceStatusEx( + service.0, + SC_STATUS_PROCESS_INFO, + (&mut status as *mut SERVICE_STATUS_PROCESS).cast(), + std::mem::size_of::() as u32, + &mut needed, + ) == 0 + { + return Err(ServiceError::NotAvailable(format!( + "failed to query Windows service status: {}", + std::io::Error::last_os_error() + ))); + } + Ok(if status.dwCurrentState == SERVICE_RUNNING { + ServiceState::Running + } else { + ServiceState::Stopped + }) + } + } + + fn open_manager() -> Result { + let manager = + unsafe { OpenSCManagerW(std::ptr::null(), std::ptr::null(), SC_MANAGER_CONNECT) }; + if manager.is_null() { + Err(ServiceError::NotAvailable(format!( + "failed to open Windows service manager: {}", + std::io::Error::last_os_error() + ))) + } else { + Ok(ServiceHandle(manager)) + } + } + + enum Action { + Start, + Stop, + } + + fn open_service_error(action: Action) -> ServiceError { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(ERROR_SERVICE_DOES_NOT_EXIST as i32) { + return ServiceError::NotFound; + } + let message = format!("failed to open Windows service: {error}"); + match action { + Action::Start => ServiceError::StartFailed(message), + Action::Stop => ServiceError::StopFailed(message), + } + } + + struct ServiceHandle(SC_HANDLE); + + impl Drop for ServiceHandle { + fn drop(&mut self) { + unsafe { + let _ = CloseServiceHandle(self.0); + } + } + } + + fn wide(value: &str) -> Vec { + value.encode_utf16().chain(std::iter::once(0)).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn installed_names_and_command_arguments_are_stable() { + #[cfg(target_os = "windows")] + assert_eq!(installed_service_label(), WINDOWS_SERVICE_NAME); + #[cfg(not(target_os = "windows"))] + assert_eq!(installed_service_label(), NATIVE_SERVICE_LABEL); + assert_eq!(LINUX_SERVICE_NAME, "playit"); + assert_eq!(WINDOWS_SERVICE_NAME, "playitd"); + assert_eq!(systemd_start_args(), ["start", "playit"]); + assert_eq!(systemd_status_args(), ["is-active", "--quiet", "playit"]); + assert_eq!(systemd_stop_args(), ["stop", "playit"]); + assert_eq!(openrc_start_args(), ["playit", "start"]); + assert_eq!(openrc_status_args(), ["playit", "status"]); + assert_eq!(openrc_stop_args(), ["playit", "stop"]); + } + + #[tokio::test] + async fn reachability_policy_uses_bounded_attempts() { + let mut attempts = 0; + wait_until_reachable( + ReachabilityPolicy { + attempts: 3, + interval: Duration::ZERO, + }, + || { + attempts += 1; + std::future::ready(attempts == 3) + }, + ) + .await + .unwrap(); + assert_eq!(attempts, 3); + } +} diff --git a/packages/playitd/src/unix_account.rs b/packages/playit-platform/src/unix.rs similarity index 94% rename from packages/playitd/src/unix_account.rs rename to packages/playit-platform/src/unix.rs index 4c0470af..28e300b9 100644 --- a/packages/playitd/src/unix_account.rs +++ b/packages/playit-platform/src/unix.rs @@ -26,7 +26,7 @@ pub fn current_user_is_root() -> bool { } pub fn group_gid_by_name(name: &str) -> Option { - uzers::get_group_by_name(name).map(|group| group.gid() as u32) + uzers::get_group_by_name(name).map(|group| group.gid()) } pub fn group_info_by_gid(gid: u32) -> Option { @@ -54,8 +54,7 @@ pub fn current_process_has_group(gid: u32) -> bool { if effective_gid() == gid { return true; } - uzers::group_access_list() - .map(|groups| groups.into_iter().any(|group| group.gid() as u32 == gid)) + .map(|groups| groups.into_iter().any(|group| group.gid() == gid)) .unwrap_or(false) } diff --git a/packages/playit-platform/src/windows/installer.rs b/packages/playit-platform/src/windows/installer.rs new file mode 100644 index 00000000..25993e32 --- /dev/null +++ b/packages/playit-platform/src/windows/installer.rs @@ -0,0 +1,276 @@ +use std::ffi::OsString; +use std::fs; +use std::os::windows::process::CommandExt; +use std::path::PathBuf; +use std::process::{Command, Output}; + +use windows::Win32::Foundation::{ERROR_FILE_NOT_FOUND, ERROR_MORE_DATA, ERROR_SUCCESS}; +use windows::Win32::System::Environment::ExpandEnvironmentStringsW; +use windows::Win32::System::Registry::{ + HKEY_LOCAL_MACHINE, REG_VALUE_TYPE, RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegGetValueW, +}; +use windows::core::PCWSTR; + +use super::normalize_sid; +use crate::migration::migrate_v17_secret_from_profile; +use crate::paths::{ + WINDOWS_SERVICE_NAME, windows_installed_user_sid_path, windows_service_log_path, + windows_service_secret_path, +}; + +const CREATE_NO_WINDOW: u32 = 0x08000000; +const AUTHENTICATED_USERS_ICACLS_SID: &str = "*S-1-5-11"; +const SERVICE_ACCESS_ACE: &str = "(A;;LCRPWPLO;;;AU)"; +const PROFILE_LIST_REGISTRY_KEY: &str = r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList"; +const PROFILE_IMAGE_PATH_VALUE: &str = "ProfileImagePath"; + +pub fn apply_installer_permissions(installed_user_sid: Option<&str>) -> Result<(), String> { + let mut errors = Vec::new(); + if let Err(error) = grant_log_folder_permissions() { + errors.push(error); + } + if let Err(error) = grant_service_permissions() { + errors.push(error); + } + if let Err(error) = write_installed_user_sid(installed_user_sid) { + errors.push(error); + } + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join("; ")) + } +} + +pub fn migrate_v17_secret(installed_user_sid: Option<&str>) -> Result<(), String> { + let sid = required_sid(installed_user_sid)?; + let new_path = windows_service_secret_path(); + if new_path.exists() { + return Ok(()); + } + let Some(profile_dir) = profile_dir_for_sid(sid)? else { + return Ok(()); + }; + migrate_v17_secret_from_profile(&new_path, &profile_dir).map(|_| ()) +} + +fn required_sid(value: Option<&str>) -> Result<&str, String> { + let value = value + .map(str::trim) + .filter(|sid| !sid.is_empty()) + .ok_or_else(|| "MSI did not provide the installing user's SID".to_owned())?; + normalize_sid(value) + .ok_or_else(|| format!("MSI provided an invalid installing user SID: {value}")) +} + +fn write_installed_user_sid(value: Option<&str>) -> Result<(), String> { + let sid = required_sid(value)?; + let path = windows_installed_user_sid_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "Failed to create installed user SID directory at {}: {error}", + parent.display() + ) + })?; + } + fs::write(&path, format!("{sid}\n")).map_err(|error| { + format!( + "Failed to write installed user SID to {}: {error}", + path.display() + ) + }) +} + +fn grant_log_folder_permissions() -> Result<(), String> { + let log_dir = windows_service_log_path() + .parent() + .ok_or_else(|| "Failed to resolve playit service log directory".to_owned())? + .to_path_buf(); + fs::create_dir_all(&log_dir).map_err(|error| { + format!( + "Failed to create playit service log directory at {}: {error}", + log_dir.display() + ) + })?; + run_command( + "icacls.exe", + vec![ + log_dir.as_os_str().to_os_string(), + OsString::from("/grant"), + OsString::from(format!("{AUTHENTICATED_USERS_ICACLS_SID}:(OI)(CI)M")), + ], + ) +} + +fn grant_service_permissions() -> Result<(), String> { + let current = service_security_descriptor()?; + let updated = add_service_access_ace(¤t)?; + if updated == current { + return Ok(()); + } + run_command( + "sc.exe", + vec![ + OsString::from("sdset"), + OsString::from(WINDOWS_SERVICE_NAME), + OsString::from(updated), + ], + ) +} + +fn service_security_descriptor() -> Result { + let output = run_command_with_output( + "sc.exe", + vec![ + OsString::from("sdshow"), + OsString::from(WINDOWS_SERVICE_NAME), + ], + )?; + String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .find(|line| line.starts_with("D:")) + .map(str::to_owned) + .ok_or_else(|| { + format!( + "Failed to find a DACL in sc.exe output: {}", + output_text(&output) + ) + }) +} + +fn add_service_access_ace(sddl: &str) -> Result { + if sddl.contains(SERVICE_ACCESS_ACE) { + return Ok(sddl.to_owned()); + } + if !sddl.starts_with("D:") { + return Err(format!("Service security descriptor has no DACL: {sddl}")); + } + let insert_at = sddl.find("S:").unwrap_or(sddl.len()); + let mut updated = String::with_capacity(sddl.len() + SERVICE_ACCESS_ACE.len()); + updated.push_str(&sddl[..insert_at]); + updated.push_str(SERVICE_ACCESS_ACE); + updated.push_str(&sddl[insert_at..]); + Ok(updated) +} + +fn profile_dir_for_sid(sid: &str) -> Result, String> { + let subkey = wide_null(&format!("{PROFILE_LIST_REGISTRY_KEY}\\{sid}")); + let value = wide_null(PROFILE_IMAGE_PATH_VALUE); + let flags = RRF_RT_REG_EXPAND_SZ | RRF_RT_REG_SZ; + let mut value_type = REG_VALUE_TYPE::default(); + let mut bytes = 0u32; + let status = unsafe { + RegGetValueW( + HKEY_LOCAL_MACHINE, + PCWSTR(subkey.as_ptr()), + PCWSTR(value.as_ptr()), + flags, + Some(&mut value_type), + None, + Some(&mut bytes), + ) + }; + if status == ERROR_FILE_NOT_FOUND { + return Ok(None); + } + if status != ERROR_SUCCESS && status != ERROR_MORE_DATA { + return Err(format!( + "Failed to read Windows profile path size: {}", + status.0 + )); + } + let mut buffer = vec![0u16; bytes.div_ceil(2) as usize]; + let status = unsafe { + RegGetValueW( + HKEY_LOCAL_MACHINE, + PCWSTR(subkey.as_ptr()), + PCWSTR(value.as_ptr()), + flags, + Some(&mut value_type), + Some(buffer.as_mut_ptr().cast()), + Some(&mut bytes), + ) + }; + if status == ERROR_FILE_NOT_FOUND { + return Ok(None); + } + if status != ERROR_SUCCESS { + return Err(format!("Failed to read Windows profile path: {}", status.0)); + } + buffer.truncate(bytes.div_ceil(2) as usize); + while buffer.last() == Some(&0) { + buffer.pop(); + } + let value = String::from_utf16(&buffer) + .map_err(|error| format!("Failed to decode Windows profile path: {error}"))?; + expand_environment_strings(&value) + .map(PathBuf::from) + .map(Some) +} + +fn expand_environment_strings(value: &str) -> Result { + let source = wide_null(value); + let needed = unsafe { ExpandEnvironmentStringsW(PCWSTR(source.as_ptr()), None) }; + if needed == 0 { + return Err(format!( + "Failed to expand environment variables in profile path {value}: {}", + std::io::Error::last_os_error() + )); + } + let mut buffer = vec![0u16; needed as usize]; + let written = unsafe { ExpandEnvironmentStringsW(PCWSTR(source.as_ptr()), Some(&mut buffer)) }; + if written == 0 || written as usize > buffer.len() { + return Err(format!("Failed to expand profile path {value}")); + } + buffer.truncate(written.saturating_sub(1) as usize); + String::from_utf16(&buffer) + .map_err(|error| format!("Failed to decode expanded profile path: {error}")) +} + +fn run_command(program: &str, args: Vec) -> Result<(), String> { + let output = run_command_with_output(program, args)?; + if output.status.success() { + Ok(()) + } else { + Err(output_text(&output)) + } +} + +fn run_command_with_output(program: &str, args: Vec) -> Result { + Command::new(program) + .args(args) + .creation_flags(CREATE_NO_WINDOW) + .output() + .map_err(|error| format!("Failed to run {program}: {error}")) +} + +fn output_text(output: &Output) -> String { + format!( + "exit_status={} stdout=\"{}\" stderr=\"{}\"", + output.status, + String::from_utf8_lossy(&output.stdout).trim(), + String::from_utf8_lossy(&output.stderr).trim() + ) +} + +fn wide_null(value: &str) -> Vec { + value.encode_utf16().chain(std::iter::once(0)).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn service_access_ace_is_idempotent_and_precedes_sacl() { + let source = "D:(A;;LCRP;;;SY)S:(AU;FA;LCRP;;;WD)"; + let updated = add_service_access_ace(source).unwrap(); + assert_eq!( + updated, + "D:(A;;LCRP;;;SY)(A;;LCRPWPLO;;;AU)S:(AU;FA;LCRP;;;WD)" + ); + assert_eq!(add_service_access_ace(&updated).unwrap(), updated); + } +} diff --git a/packages/playit-platform/src/windows/mod.rs b/packages/playit-platform/src/windows/mod.rs new file mode 100644 index 00000000..0a2026eb --- /dev/null +++ b/packages/playit-platform/src/windows/mod.rs @@ -0,0 +1,13 @@ +mod installer; +mod shortcut; +mod sid; + +pub use installer::{apply_installer_permissions, migrate_v17_secret}; +pub use shortcut::{ + ensure_tray_startup_shortcut, remove_tray_startup_shortcut, tray_startup_shortcut_exists, + tray_startup_shortcut_path, +}; +pub use sid::{ + current_process_user_sid, normalize_sid, pipe_security_sddl, read_installed_user_sid, + restricted_pipe_security_descriptor, write_current_user_sid, +}; diff --git a/packages/playitd-windows-setup/src/startup_shortcut.rs b/packages/playit-platform/src/windows/shortcut.rs similarity index 55% rename from packages/playitd-windows-setup/src/startup_shortcut.rs rename to packages/playit-platform/src/windows/shortcut.rs index 483ef51f..bf81f304 100644 --- a/packages/playitd-windows-setup/src/startup_shortcut.rs +++ b/packages/playit-platform/src/windows/shortcut.rs @@ -10,53 +10,21 @@ use windows::Win32::System::Com::{ use windows::Win32::UI::Shell::{ FOLDERID_Startup, IShellLinkW, KF_FLAG_DEFAULT, SHGetKnownFolderPath, ShellLink, }; -use windows::core::{GUID, Interface, PCWSTR}; +use windows::core::{Interface, PCWSTR}; + +use crate::paths::WINDOWS_TRAY_SHORTCUT_NAME; -const TRAY_SHORTCUT_NAME: &str = "Playit Tray.lnk"; const TRAY_SHORTCUT_DESCRIPTION: &str = "Shows the Playit tray icon when the background service is running."; -struct ComInitialization { - should_uninitialize: bool, -} - -impl Drop for ComInitialization { - fn drop(&mut self) { - if self.should_uninitialize { - unsafe { - CoUninitialize(); - } - } - } -} - -pub(crate) fn remove_startup_shortcut() -> Result<(), String> { - let shortcut_path = startup_shortcut_path()?; - - if !shortcut_path.exists() { - return Ok(()); - } - - fs::remove_file(&shortcut_path).map_err(|error| { - format!( - "Failed to delete startup shortcut at {}: {error}", - shortcut_path.display() - ) - }) -} - -pub(crate) fn ensure_startup_shortcut() -> Result<(), String> { - let shortcut_path = startup_shortcut_path()?; - let setup_path = std::env::current_exe() - .map_err(|error| format!("Failed to resolve playitd-windows-setup.exe path: {error}"))?; - let working_directory = setup_path.parent().ok_or_else(|| { +pub fn ensure_tray_startup_shortcut(tray_path: &Path) -> Result { + let shortcut_path = tray_startup_shortcut_path()?; + let working_directory = tray_path.parent().ok_or_else(|| { format!( "Failed to resolve the working directory for {}", - setup_path.display() + tray_path.display() ) })?; - let tray_path = working_directory.join("playitd-tray.exe"); - if let Some(parent) = shortcut_path.parent() { fs::create_dir_all(parent).map_err(|error| { format!( @@ -67,46 +35,54 @@ pub(crate) fn ensure_startup_shortcut() -> Result<(), String> { } let _com = initialize_com()?; - create_startup_shortcut(&tray_path, working_directory, &shortcut_path) + create_shortcut(tray_path, working_directory, &shortcut_path)?; + Ok(shortcut_path) } -fn startup_shortcut_path() -> Result { - known_folder_shortcut_path(&FOLDERID_Startup, "the current user's Startup folder") +pub fn remove_tray_startup_shortcut() -> Result<(), String> { + let path = tray_startup_shortcut_path()?; + if path.exists() { + fs::remove_file(&path).map_err(|error| { + format!( + "Failed to delete startup shortcut at {}: {error}", + path.display() + ) + })?; + } + Ok(()) } -fn known_folder_shortcut_path(folder_id: &GUID, folder_name: &str) -> Result { - unsafe { - let wide_path = SHGetKnownFolderPath(folder_id, KF_FLAG_DEFAULT, None) - .map_err(|error| format!("Failed to resolve {folder_name}: {error}"))?; +pub fn tray_startup_shortcut_exists() -> Result { + Ok(tray_startup_shortcut_path()?.exists()) +} +pub fn tray_startup_shortcut_path() -> Result { + unsafe { + let wide_path = + SHGetKnownFolderPath(&FOLDERID_Startup, KF_FLAG_DEFAULT, None).map_err(|error| { + format!("Failed to resolve the current user's Startup folder: {error}") + })?; if wide_path.is_null() { - return Err(format!("{folder_name} path was empty")); + return Err("the current user's Startup folder path was empty".to_owned()); } - - let path = wide_path - .to_string() - .map_err(|error| format!("Failed to read {folder_name} path: {error}"))?; + let path = wide_path.to_string().map_err(|error| { + format!("Failed to read the current user's Startup folder: {error}") + })?; CoTaskMemFree(Some(wide_path.0.cast())); - - Ok(PathBuf::from(path).join(TRAY_SHORTCUT_NAME)) + Ok(PathBuf::from(path).join(WINDOWS_TRAY_SHORTCUT_NAME)) } } -fn create_startup_shortcut( - tray_path: &Path, - working_directory: &Path, - shortcut_path: &Path, -) -> Result<(), String> { +fn create_shortcut(target: &Path, working_directory: &Path, path: &Path) -> Result<(), String> { unsafe { let shell_link: IShellLinkW = CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER) .map_err(|error| format!("Failed to create the ShellLink COM object: {error}"))?; - shell_link - .SetPath(path_pcwstr(tray_path)?.as_pcwstr()) + .SetPath(path_pcwstr(target)?.as_pcwstr()) .map_err(|error| { format!( "Failed to set the tray shortcut target to {}: {error}", - tray_path.display() + target.display() ) })?; shell_link @@ -120,39 +96,43 @@ fn create_startup_shortcut( shell_link .SetDescription(wide(TRAY_SHORTCUT_DESCRIPTION).as_pcwstr()) .map_err(|error| format!("Failed to set the tray shortcut description: {error}"))?; - let persist_file: IPersistFile = shell_link.cast().map_err(|error| { format!("Failed to query the tray shortcut persistence interface: {error}") })?; persist_file - .Save(path_pcwstr(shortcut_path)?.as_pcwstr(), true) + .Save(path_pcwstr(path)?.as_pcwstr(), true) .map_err(|error| { format!( "Failed to save the startup shortcut at {}: {error}", - shortcut_path.display() + path.display() ) }) } } +struct ComInitialization(bool); + +impl Drop for ComInitialization { + fn drop(&mut self) { + if self.0 { + unsafe { CoUninitialize() }; + } + } +} + fn initialize_com() -> Result { unsafe { let result = CoInitializeEx(None, COINIT_APARTMENTTHREADED); if result == S_OK || result == S_FALSE { - return Ok(ComInitialization { - should_uninitialize: true, - }); + Ok(ComInitialization(true)) + } else if result == RPC_E_CHANGED_MODE { + Ok(ComInitialization(false)) + } else { + Err(format!( + "Failed to initialize COM for the tray shortcut helper (HRESULT {:#x})", + result.0 + )) } - if result == RPC_E_CHANGED_MODE { - return Ok(ComInitialization { - should_uninitialize: false, - }); - } - - Err(format!( - "Failed to initialize COM for the tray shortcut helper (HRESULT {:#x})", - result.0 - )) } } diff --git a/packages/playitd/src/windows.rs b/packages/playit-platform/src/windows/sid.rs similarity index 52% rename from packages/playitd/src/windows.rs rename to packages/playit-platform/src/windows/sid.rs index ddb67aeb..e56a660d 100644 --- a/packages/playitd/src/windows.rs +++ b/packages/playit-platform/src/windows/sid.rs @@ -1,44 +1,25 @@ use std::ffi::OsString; use std::io; use std::os::windows::ffi::OsStringExt; -use std::path::PathBuf; use std::ptr::{NonNull, null_mut}; use interprocess::os::windows::security_descriptor::SecurityDescriptor; -use playit_ipc::ipc::IpcError; use widestring::U16CString; use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, LocalFree}; use windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW; use windows_sys::Win32::Security::{GetTokenInformation, TOKEN_QUERY, TOKEN_USER, TokenUser}; use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; -use crate::windows_service_data_dir; - -pub fn installed_user_sid_path() -> PathBuf { - windows_service_data_dir().join("installed_user.sid") -} +use crate::paths::windows_installed_user_sid_path; pub fn read_installed_user_sid() -> Option { - let content = match std::fs::read_to_string(installed_user_sid_path()) { - Ok(content) => content, - Err(error) => { - tracing::debug!("failed to read installed user SID: {error}"); - return None; - } - }; - - match normalize_sid(content.trim()) { - Some(sid) => Some(sid.to_string()), - None => { - tracing::warn!("installed user SID file is invalid, ignoring it"); - None - } - } + let content = std::fs::read_to_string(windows_installed_user_sid_path()).ok()?; + normalize_sid(content.trim()).map(str::to_owned) } -pub fn write_current_user_sid() -> io::Result { +pub fn write_current_user_sid() -> io::Result { let sid = current_process_user_sid()?; - let path = installed_user_sid_path(); + let path = windows_installed_user_sid_path(); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } @@ -51,31 +32,45 @@ pub fn current_process_user_sid() -> io::Result { if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 { return Err(io::Error::last_os_error()); } - let token = Handle::new(token).ok_or_else(io::Error::last_os_error)?; token_user_sid_string(token.raw()) } -pub fn restricted_pipe_security_descriptor() -> Result { - let mut user_sid = read_installed_user_sid(); - if user_sid.is_none() { - match current_process_user_sid() { - Ok(sid) => user_sid = Some(sid), - Err(error) => { - tracing::warn!("failed to read current process SID for IPC ACL fallback: {error}"); - } - } - } +pub fn restricted_pipe_security_descriptor() -> io::Result { + let user_sid = read_installed_user_sid().or_else(|| current_process_user_sid().ok()); + let sddl = U16CString::from_str(pipe_security_sddl(user_sid.as_deref())) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + SecurityDescriptor::deserialize(&sddl) +} - let sddl = pipe_security_sddl(user_sid.as_deref()); - let sddl = U16CString::from_str(&sddl).map_err(|error| { - IpcError::BindFailed(io::Error::new( - io::ErrorKind::InvalidInput, - format!("invalid pipe security descriptor string: {error}"), - )) - })?; +pub fn normalize_sid(sid: &str) -> Option<&str> { + if !sid.starts_with("S-1-") + || sid + .chars() + .any(|character| character.is_whitespace() || matches!(character, '(' | ')' | ';')) + || !sid + .chars() + .all(|character| character.is_ascii_digit() || matches!(character, 'S' | '-')) + { + return None; + } + let mut parts = sid.split('-'); + if parts.next() != Some("S") || parts.next() != Some("1") { + return None; + } + parts + .all(|part| !part.is_empty() && part.chars().all(|character| character.is_ascii_digit())) + .then_some(sid) +} - SecurityDescriptor::deserialize(&sddl).map_err(IpcError::BindFailed) +pub fn pipe_security_sddl(user_sid: Option<&str>) -> String { + let mut sddl = String::from("D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GA;;;AU)"); + if let Some(user_sid) = user_sid.and_then(normalize_sid) { + sddl.push_str("(A;;GA;;;"); + sddl.push_str(user_sid); + sddl.push(')'); + } + sddl } fn token_user_sid_string(token: HANDLE) -> io::Result { @@ -83,12 +78,12 @@ fn token_user_sid_string(token: HANDLE) -> io::Result { unsafe { GetTokenInformation(token, TokenUser, null_mut(), 0, &mut needed); } - if needed == 0 { return Err(io::Error::last_os_error()); } - - let mut buffer = vec![0u8; needed as usize]; + let word_size = std::mem::size_of::(); + let word_count = (needed as usize).div_ceil(word_size); + let mut buffer = vec![0usize; word_count]; if unsafe { GetTokenInformation( token, @@ -101,7 +96,6 @@ fn token_user_sid_string(token: HANDLE) -> io::Result { { return Err(io::Error::last_os_error()); } - let token_user = unsafe { &*(buffer.as_ptr().cast::()) }; sid_to_string(token_user.User.Sid) } @@ -111,51 +105,10 @@ fn sid_to_string(sid: *mut std::ffi::c_void) -> io::Result { if unsafe { ConvertSidToStringSidW(sid, &mut string_sid) } == 0 { return Err(io::Error::last_os_error()); } - let string_sid = LocalString::new(string_sid).ok_or_else(io::Error::last_os_error)?; Ok(string_sid.to_string()) } -fn normalize_sid(sid: &str) -> Option<&str> { - if !sid.starts_with("S-1-") { - return None; - } - - if sid - .chars() - .any(|c| c.is_whitespace() || matches!(c, '(' | ')' | ';')) - { - return None; - } - - if !sid - .chars() - .all(|c| c.is_ascii_digit() || matches!(c, 'S' | '-')) - { - return None; - } - - let mut parts = sid.split('-'); - if parts.next() != Some("S") || parts.next() != Some("1") { - return None; - } - if !parts.all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit())) { - return None; - } - - Some(sid) -} - -fn pipe_security_sddl(user_sid: Option<&str>) -> String { - let mut sddl = String::from("D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GA;;;AU)"); - if let Some(user_sid) = user_sid.and_then(normalize_sid) { - sddl.push_str("(A;;GA;;;"); - sddl.push_str(user_sid); - sddl.push(')'); - } - sddl -} - struct Handle(NonNull); impl Handle { @@ -170,17 +123,15 @@ impl Handle { impl Drop for Handle { fn drop(&mut self) { - unsafe { - CloseHandle(self.raw()); - } + unsafe { CloseHandle(self.raw()) }; } } struct LocalString(NonNull); impl LocalString { - fn new(ptr: *mut u16) -> Option { - NonNull::new(ptr).map(Self) + fn new(pointer: *mut u16) -> Option { + NonNull::new(pointer).map(Self) } fn to_string(&self) -> String { @@ -206,32 +157,18 @@ impl Drop for LocalString { #[cfg(test)] mod tests { - use super::{normalize_sid, pipe_security_sddl}; + use super::*; #[test] - fn pipe_sddl_allows_service_admins_authenticated_users_and_installed_user() { - let sddl = pipe_security_sddl(Some("S-1-5-21-1-2-3-1001")); - + fn sid_validation_and_pipe_acl_are_stable() { + let sid = "S-1-5-21-1-2-3-1001"; + assert_eq!(normalize_sid(sid), Some(sid)); + assert_eq!(normalize_sid("S-1-5-21;)"), None); + let sddl = pipe_security_sddl(Some(sid)); assert!(sddl.contains("(A;;GA;;;SY)")); assert!(sddl.contains("(A;;GA;;;BA)")); assert!(sddl.contains("(A;;GA;;;AU)")); - assert!(sddl.contains("(A;;GA;;;S-1-5-21-1-2-3-1001)")); + assert!(sddl.contains(sid)); assert!(!sddl.contains("WD")); - assert!(!sddl.contains("BU")); - } - - #[test] - fn sid_validation_rejects_sddl_breakout_characters() { - assert_eq!( - normalize_sid("S-1-5-21-1-2-3-1001"), - Some("S-1-5-21-1-2-3-1001") - ); - assert_eq!(normalize_sid(""), None); - assert_eq!(normalize_sid("S-1-"), None); - assert_eq!(normalize_sid(" S-1-5-21-1-2-3-1001"), None); - assert_eq!(normalize_sid("S-1-5-21-1-2-3-1001 "), None); - assert_eq!(normalize_sid("S-1-5-21-1-2-3-1001)"), None); - assert_eq!(normalize_sid("S-1-5-21-1-2-3-1001;"), None); - assert_eq!(normalize_sid("S-1-5-21-1-2-3-1001("), None); } } diff --git a/packages/playit-platform/tests/contracts.rs b/packages/playit-platform/tests/contracts.rs new file mode 100644 index 00000000..85216d21 --- /dev/null +++ b/packages/playit-platform/tests/contracts.rs @@ -0,0 +1,81 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use playit_platform::paths::*; + +#[test] +fn installed_names_paths_and_file_formats_are_stable() { + assert_eq!(LINUX_SERVICE_NAME, "playit"); + assert_eq!(WINDOWS_SERVICE_NAME, "playitd"); + assert_eq!(NATIVE_SERVICE_LABEL, "gg.playit.playitd"); + assert_eq!(LINUX_INSTALLED_SECRET_PATH, "/etc/playit/playit.toml"); + assert_eq!(LINUX_INSTALLED_SOCKET_PATH, "/run/playit/playitd.sock"); + assert_eq!(LINUX_INSTALLED_LOG_PATH, "/var/log/playit/playit.log"); + assert_eq!(WINDOWS_DATA_DIR_NAME, "playit_gg"); + assert_eq!(WINDOWS_TRAY_SHORTCUT_NAME, "Playit Tray.lnk"); +} + +#[test] +fn windows_mechanisms_are_cfg_guarded_and_shortcut_has_one_owner() { + let service = include_str!("../src/service.rs"); + let shortcut = include_str!("../src/windows/shortcut.rs"); + let sid = include_str!("../src/windows/sid.rs"); + + assert!(service.contains("OpenSCManagerW")); + assert!(service.contains("QueryServiceStatusEx")); + assert_eq!(shortcut.matches("CoCreateInstance").count(), 2); + assert!(shortcut.contains("WINDOWS_TRAY_SHORTCUT_NAME")); + assert!(sid.contains("D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GA;;;AU)")); +} + +#[test] +fn atomic_secret_write_replaces_content_and_leaves_no_temp_file() { + let temp = TempDir::new("secret-contract"); + let path = temp.path().join("playit.toml"); + playit_platform::secret::atomic_write_secret_blocking(&path, b"secret_key = \"first\"\n") + .unwrap(); + playit_platform::secret::atomic_write_secret_blocking(&path, b"secret_key = \"second\"\n") + .unwrap(); + + assert_eq!( + fs::read_to_string(&path).unwrap(), + "secret_key = \"second\"\n" + ); + assert_eq!(fs::read_dir(temp.path()).unwrap().count(), 1); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } +} + +struct TempDir(PathBuf); + +impl TempDir { + fn new(name: &str) -> Self { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "playit-platform-{name}-{}-{nanos}", + std::process::id() + )); + fs::create_dir_all(&path).unwrap(); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} diff --git a/packages/playit-runtime/Cargo.toml b/packages/playit-runtime/Cargo.toml new file mode 100644 index 00000000..226abb57 --- /dev/null +++ b/packages/playit-runtime/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "playit-runtime" +version.workspace = true +edition = "2024" +description = "Application ports and runtime adapters for playit" +license = "BSD-2-Clause" +repository = "https://github.com/playit-cloud/playit-agent" + +[features] +default = ["application"] +application = [ + "dep:async-trait", + "dep:hex", + "dep:playit-api-client", + "dep:playit-agent-core", + "dep:playit-model", + "dep:playit-platform", + "dep:rand", + "dep:serde", + "dep:serde_json", + "dep:tokio-util", + "dep:toml", + "dep:tracing", +] + +[dependencies] +async-trait = { version = "0.1", optional = true } +hex = { workspace = true, optional = true } +playit-api-client = { path = "../api_client", version = "0.2.0", optional = true } +playit-agent-core = { path = "../agent_core", version = "1.0.10", optional = true } +playit-model = { path = "../playit-model", version = "1.0.10", optional = true } +playit-platform = { path = "../playit-platform", version = "1.0.10", optional = true } +rand = { workspace = true, optional = true } +serde = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } +tokio = { workspace = true } +tokio-util = { workspace = true, optional = true } +toml = { workspace = true, optional = true } +tracing = { workspace = true, optional = true } + +[dev-dependencies] +tokio = { workspace = true } +uuid = { workspace = true } + +[[example]] +name = "mtu_control" +required-features = ["application"] diff --git a/packages/agent_core/examples/mtu_control.rs b/packages/playit-runtime/examples/mtu_control.rs similarity index 88% rename from packages/agent_core/examples/mtu_control.rs rename to packages/playit-runtime/examples/mtu_control.rs index 896e4831..63625e54 100644 --- a/packages/agent_core/examples/mtu_control.rs +++ b/packages/playit-runtime/examples/mtu_control.rs @@ -1,16 +1,16 @@ -// Example: // PLAYIT_API_URL="https://api.playit.gg" \ // PLAYIT_SECRET_KEY="..." \ // PLAYIT_MTU_DC_IDS="1,2" \ -// cargo run -p playit-agent-core --example mtu_control +// cargo run -p playit-runtime --example mtu_control // -// The example connects to control, sends a few MTU probes, waits briefly for -// responses, then prints both pending and committed MTU discovery state. +// This example sends MTU probes through the generated API adapter. use std::env; +use std::sync::Arc; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use playit_agent_core::agent_control::address_selector::AddressSelector; -use playit_agent_core::agent_control::{AuthApi, AuthResource, DualStackUdpSocket}; +use playit_agent_core::agent_control::{AuthResource, DualStackUdpSocket, GatewayAuth}; +use playit_runtime::GeneratedClientGateway; const CHECK_MTU_SIZES: [u32; 5] = [1200, 1300, 1400, 1420, 1480]; const MTU_TEST_SIZES: [u32; 5] = [1200, 1300, 1400, 1420, 1450]; @@ -30,13 +30,13 @@ async fn run() -> Result<(), String> { let secret_key = required_env("PLAYIT_SECRET_KEY")?; let data_center_ids = parse_data_center_ids()?; - println!("creating udp io"); + println!("creating UDP IO"); let io = DualStackUdpSocket::new() .await .map_err(|error| format!("failed to create UDP socket: {error}"))?; println!("fetching control addresses"); - let auth = AuthApi::new(api_url, secret_key); + let auth = GatewayAuth::new(Arc::new(GeneratedClientGateway::new(api_url, secret_key))); let control_addresses = auth .get_control_addresses() .await @@ -55,14 +55,12 @@ async fn run() -> Result<(), String> { .map_err(|error| format!("failed to authenticate control: {error:?}"))?; control.clear_pending_mtu_data(); - let mut next_request_id = time_id_seed(); println!("sending CheckMtuReceived probes"); for message_size in CHECK_MTU_SIZES { let request_id = take_next_id(&mut next_request_id); let test_id = take_next_id(&mut next_request_id); - control .send_check_mtu_received(request_id, test_id, message_size) .await @@ -76,7 +74,6 @@ async fn run() -> Result<(), String> { for udp_payload_length in MTU_TEST_SIZES { let request_id = take_next_id(&mut next_request_id); let test_id = take_next_id(&mut next_request_id); - control .send_mtu_test(request_id, test_id, *data_center_id, udp_payload_length) .await @@ -101,7 +98,6 @@ async fn run() -> Result<(), String> { println!("pending_mtu_data: {:#?}", control.pending_mtu_data()); control.commit_pending_mtu_data(); println!("known_mtu_data: {:#?}", control.known_mtu_data()); - Ok(()) } @@ -112,25 +108,22 @@ fn required_env(name: &str) -> Result { fn parse_data_center_ids() -> Result, String> { let raw = required_env("PLAYIT_MTU_DC_IDS")?; let mut ids = Vec::new(); - for part in raw.split(',') { let trimmed = part.trim(); if trimmed.is_empty() { continue; } - - let parsed = trimmed - .parse::() - .map_err(|error| format!("invalid data center id `{trimmed}`: {error}"))?; - ids.push(parsed); + ids.push( + trimmed + .parse::() + .map_err(|error| format!("invalid data center id `{trimmed}`: {error}"))?, + ); } - if ids.is_empty() { return Err( "`PLAYIT_MTU_DC_IDS` must contain at least one comma-separated data center id".into(), ); } - Ok(ids) } diff --git a/packages/playit-runtime/src/claim.rs b/packages/playit-runtime/src/claim.rs new file mode 100644 index 00000000..33776f00 --- /dev/null +++ b/packages/playit-runtime/src/claim.rs @@ -0,0 +1,147 @@ +use std::fmt; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use playit_model::{Problem, ProblemCode}; +use rand::Rng; + +use crate::GeneratedClientGateway; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClaimMode { + Assignable, + SelfManaged, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClaimProgress { + WaitingForVisit, + WaitingForApproval, + Approved, + Rejected, +} + +#[derive(Clone, PartialEq, Eq)] +pub enum ClaimExchange { + Pending(String), + Accepted(String), +} + +impl fmt::Debug for ClaimExchange { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Pending(status) => formatter.debug_tuple("Pending").field(status).finish(), + Self::Accepted(_) => formatter.write_str("Accepted([REDACTED])"), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClaimSession { + pub code: String, + pub url: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClaimFailure { + pub problem: Problem, + pub detail: String, +} + +pub struct ClaimService { + gateway: Arc, + version: String, + timeout: Duration, +} + +impl ClaimService { + pub fn new(api_base: String, version: String) -> Self { + Self { + gateway: Arc::new(GeneratedClientGateway::without_secret(api_base)), + version, + timeout: Duration::from_secs(10), + } + } + + #[cfg(test)] + pub(crate) fn with_gateway(gateway: Arc, version: String) -> Self { + Self { + gateway, + version, + timeout: Duration::from_secs(1), + } + } + + pub fn begin() -> ClaimSession { + let mut buffer = [0u8; 5]; + rand::rng().fill(&mut buffer); + let code = hex::encode(buffer); + ClaimSession { + url: format!("https://playit.gg/claim/{code}"), + code, + } + } + + pub async fn progress( + &self, + code: &str, + mode: ClaimMode, + ) -> Result { + tokio::time::timeout( + self.timeout, + self.gateway.progress(code, mode, &self.version), + ) + .await + .unwrap_or_else(|_| Err(claim_timeout())) + } + + pub async fn exchange(&self, code: &str) -> Result { + tokio::time::timeout(self.timeout, self.gateway.exchange(code)) + .await + .unwrap_or_else(|_| Err(claim_timeout())) + } +} + +fn claim_timeout() -> ClaimFailure { + ClaimFailure { + problem: Problem::new(ProblemCode::ProvisioningUnavailable), + detail: "claim request timed out".to_owned(), + } +} + +#[async_trait] +pub(crate) trait ClaimGateway: Send + Sync { + async fn progress( + &self, + code: &str, + mode: ClaimMode, + version: &str, + ) -> Result; + + async fn exchange(&self, code: &str) -> Result; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generated_claim_session_has_stable_url_shape() { + let session = ClaimService::begin(); + assert_eq!(session.code.len(), 10); + assert!(hex::decode(&session.code).is_ok()); + assert_eq!( + session.url, + format!("https://playit.gg/claim/{}", session.code) + ); + } + + #[test] + fn accepted_claim_debug_output_redacts_the_secret() { + let exchange = ClaimExchange::Accepted("sensitive-agent-key".to_owned()); + let output = format!("{exchange:?}"); + assert_eq!(output, "Accepted([REDACTED])"); + assert!(!output.contains("sensitive-agent-key")); + } +} diff --git a/packages/playit-runtime/src/engine.rs b/packages/playit-runtime/src/engine.rs new file mode 100644 index 00000000..3cd03d2d --- /dev/null +++ b/packages/playit-runtime/src/engine.rs @@ -0,0 +1,113 @@ +use std::sync::Arc; + +use playit_agent_core::agent_control::errors::SetupError; +use playit_agent_core::gateway::{GatewayErrorCode, GatewayOrigin, PlayitGateway}; +use playit_agent_core::network::origin_lookup::OriginLookup; +use playit_agent_core::playit_agent::{PlayitAgent, PlayitAgentSettings}; +use playit_agent_core::stats::AgentStats; +use playit_model::TrafficSnapshot; + +use crate::{EngineChild, EnginePort, EngineStartError, OriginPublisher, TrafficSource}; + +#[derive(Clone, Debug, Default)] +pub struct SupervisedEnginePort { + settings: PlayitAgentSettings, +} + +impl SupervisedEnginePort { + pub fn new(settings: PlayitAgentSettings) -> Self { + Self { settings } + } +} + +#[async_trait::async_trait] +impl EnginePort for SupervisedEnginePort { + async fn start( + &self, + gateway: Arc, + origins: &[GatewayOrigin], + ) -> Result { + let lookup = Arc::new(OriginLookup::default()); + lookup.update_from_gateway(origins).await; + let agent = PlayitAgent::new(self.settings.clone(), lookup.clone(), gateway) + .await + .map_err(map_engine_start_error)?; + let stats = agent.stats(); + let cancel = agent.cancellation_token(); + let exit = tokio::spawn(agent.run()); + Ok(EngineChild::new( + cancel, + exit, + Arc::new(AgentOrigins(lookup)), + Arc::new(AgentTraffic(stats)), + )) + } +} + +struct AgentOrigins(Arc); + +#[async_trait::async_trait] +impl OriginPublisher for AgentOrigins { + async fn replace(&self, origins: &[GatewayOrigin]) { + self.0.update_from_gateway(origins).await; + } +} + +struct AgentTraffic(AgentStats); + +impl TrafficSource for AgentTraffic { + fn snapshot(&self) -> TrafficSnapshot { + let snapshot = self.0.snapshot(); + TrafficSnapshot { + bytes_in: snapshot.bytes_in, + bytes_out: snapshot.bytes_out, + active_tcp: snapshot.active_tcp, + active_udp: snapshot.active_udp, + } + } +} + +fn map_engine_start_error(error: SetupError) -> EngineStartError { + match &error { + SetupError::Gateway(error) if error.code == GatewayErrorCode::InvalidSecret => { + EngineStartError::InvalidSecret( + "The configured playit secret is no longer valid. Run `playit setup` to provision a new secret." + .to_owned(), + ) + } + SetupError::Gateway(error) + if error.code == GatewayErrorCode::AgentDisabledOverLimit => + { + EngineStartError::AgentDisabledOverLimit(agent_limit_message()) + } + _ => EngineStartError::Failed(setup_error_message(&error)), + } +} + +pub fn setup_error_message(error: &SetupError) -> String { + match error { + SetupError::FailedToConnect => { + "Could not connect to playit tunnel servers. Check your internet connection, firewall, VPN, or DNS settings, then restart playit." + .to_owned() + } + SetupError::Timeout(_) => { + "Timed out while connecting to playit. Check your network/firewall and try again." + .to_owned() + } + SetupError::IoError(error) => format!("Could not open a required network socket: {error}"), + SetupError::Gateway(error) => match error.code { + GatewayErrorCode::AgentDisabledOverLimit => agent_limit_message(), + GatewayErrorCode::Transport => { + "Could not reach the playit API. Check your internet connection or try again later." + .to_owned() + } + _ => format!("Failed to start the playit agent: {error}"), + }, + _ => format!("Failed to start the playit agent: {error}"), + } +} + +fn agent_limit_message() -> String { + "This account is over the agent limit. Delete an unused agent or upgrade the account, then the service will retry." + .to_owned() +} diff --git a/packages/playit-runtime/src/generated_gateway.rs b/packages/playit-runtime/src/generated_gateway.rs new file mode 100644 index 00000000..6d0f82c8 --- /dev/null +++ b/packages/playit-runtime/src/generated_gateway.rs @@ -0,0 +1,579 @@ +use std::net::IpAddr; +use std::num::{NonZeroU16, NonZeroU64}; +use std::str::FromStr; +use std::sync::Arc; + +use async_trait::async_trait; +use playit_agent_core::gateway::{ + AgentVersion, GatewayError, GatewayErrorCode, GatewayOrigin, GatewayOriginHost, + GatewayOriginTarget, GatewayRunData, Platform, PlayitGateway, RegisterRequest, RunDataContext, + SignedAgentKey, +}; +use playit_api_client::PlayitApi; +use playit_api_client::api::{ + AccountStatus as ApiAccountStatus, AgentRunDataV1, AgentTunnelV1, ApiError, ApiErrorNoFail, + ApiResponseError, AuthError, ClaimAgentType, ClaimSetupResponse, Platform as ApiPlatform, + PortType, ProtoRegisterError, ProxyProtocol as ApiProxyProtocol, ReqAgentsRoutingGet, + ReqClaimExchange, ReqClaimSetup, ReqProtoRegister, TunnelType, +}; +use playit_api_client::http_client::HttpClientError; +use playit_model::{ + AccountStatus, CatalogTunnel, Hostname, Notice, NoticeContent, NoticePriority, OriginHost, + OriginTarget, PendingTunnel, Problem, ProblemCode, ProblemSubject, ProxyProtocol, SubjectKind, + TunnelAvailability, TunnelCatalog, TunnelId, TunnelProtocol, TunnelSpec, +}; +use tokio::sync::RwLock; + +use crate::{ClaimExchange, ClaimFailure, ClaimGateway, ClaimMode, ClaimProgress}; + +const LOGIN_CACHE_MILLIS: u64 = 15_000; + +#[derive(Clone)] +pub struct GeneratedClientGateway { + client: PlayitApi, + login_cache: Arc, +} + +#[derive(Default)] +struct LoginUrlCache { + entry: RwLock>, +} + +impl LoginUrlCache { + async fn get(&self, now_millis: u64) -> Option { + let entry = self.entry.read().await; + match &*entry { + Some((url, cached_at)) + if now_millis.saturating_sub(*cached_at) < LOGIN_CACHE_MILLIS => + { + Some(url.clone()) + } + _ => None, + } + } + + async fn store(&self, url: String, now_millis: u64) { + *self.entry.write().await = Some((url, now_millis)); + } +} + +impl GeneratedClientGateway { + pub fn new(api_base: String, secret: String) -> Self { + Self { + client: PlayitApi::create(api_base, Some(secret)), + login_cache: Arc::new(LoginUrlCache::default()), + } + } + + pub(crate) fn without_secret(api_base: String) -> Self { + Self { + client: PlayitApi::create(api_base, None), + login_cache: Arc::new(LoginUrlCache::default()), + } + } + + async fn cached_login_url(&self, now_millis: u64) -> Result { + if let Some(url) = self.login_cache.get(now_millis).await { + return Ok(url); + } + + let session = self.client.login_guest().await.map_err(map_login_error)?; + let url = format!( + "https://playit.gg/login/guest-account/{}", + session.session_key + ); + self.login_cache.store(url.clone(), now_millis).await; + Ok(url) + } +} + +#[async_trait] +impl ClaimGateway for GeneratedClientGateway { + async fn progress( + &self, + code: &str, + mode: ClaimMode, + version: &str, + ) -> Result { + let agent_type = match mode { + ClaimMode::Assignable => ClaimAgentType::Assignable, + ClaimMode::SelfManaged => ClaimAgentType::SelfManaged, + }; + self.client + .claim_setup(ReqClaimSetup { + code: code.to_owned(), + agent_type, + version: version.to_owned(), + }) + .await + .map(|progress| match progress { + ClaimSetupResponse::WaitingForUserVisit => ClaimProgress::WaitingForVisit, + ClaimSetupResponse::WaitingForUser => ClaimProgress::WaitingForApproval, + ClaimSetupResponse::UserAccepted => ClaimProgress::Approved, + ClaimSetupResponse::UserRejected => ClaimProgress::Rejected, + }) + .map_err(claim_error) + } + + async fn exchange(&self, code: &str) -> Result { + match self + .client + .claim_exchange(ReqClaimExchange { + code: code.to_owned(), + }) + .await + { + Ok(response) => Ok(ClaimExchange::Accepted(response.secret_key)), + Err(ApiError::Fail(status)) => Ok(ClaimExchange::Pending(format!("{status:?}"))), + Err(error) => Err(claim_error(error)), + } + } +} + +fn claim_error(error: impl std::fmt::Debug) -> ClaimFailure { + ClaimFailure { + problem: Problem::new(ProblemCode::ProvisioningUnavailable), + detail: format!("{error:?}"), + } +} + +#[async_trait] +impl PlayitGateway for GeneratedClientGateway { + async fn register(&self, request: RegisterRequest) -> Result { + let registered = self + .client + .proto_register(ReqProtoRegister { + agent_version: None, + proto_version: request.proto_version, + version: api_version(request.version), + platform: api_platform(request.platform), + client_addr: request.client_addr, + tunnel_addr: request.tunnel_addr, + }) + .await + .map_err(map_register_error)?; + Ok(SignedAgentKey { + key: registered.key, + }) + } + + async fn control_addresses(&self) -> Result, GatewayError> { + let routing = self + .client + .agents_routing_get(ReqAgentsRoutingGet { agent_id: None }) + .await + .map_err(|error| map_api_error("control_addresses", error))?; + Ok(routing + .targets6 + .into_iter() + .map(|address| std::net::SocketAddr::new(address.into(), 5525)) + .chain( + routing + .targets4 + .into_iter() + .map(|address| std::net::SocketAddr::new(address.into(), 5525)), + ) + .collect()) + } + + async fn run_data(&self, context: RunDataContext) -> Result { + let data = self + .client + .v1_agents_rundata() + .await + .map_err(|error| map_no_fail_error("run_data", error))?; + convert_run_data(self, data, context).await + } + + async fn account_login_url(&self) -> Result { + self.cached_login_url(now_millis()).await + } +} + +async fn convert_run_data( + gateway: &GeneratedClientGateway, + mut data: AgentRunDataV1, + context: RunDataContext, +) -> Result { + let mut origins = Vec::with_capacity(data.tunnels.len()); + let mut catalog_tunnels = Vec::with_capacity(data.tunnels.len()); + + for tunnel in &data.tunnels { + let converted = convert_tunnel(tunnel)?; + origins.push(converted.origin); + catalog_tunnels.push(converted.catalog); + } + + let catalog = TunnelCatalog::try_new( + context.catalog_revision, + context.accepted_at_millis, + catalog_tunnels, + ) + .map_err(|error| { + GatewayError::new( + GatewayErrorCode::InvalidRunData, + "run_data", + format!("invalid tunnel catalog: {error:?}"), + ) + })?; + let account_status = map_account_status(data.permissions.account_status); + let login_link = if account_status == AccountStatus::Guest { + gateway + .cached_login_url(context.accepted_at_millis) + .await + .ok() + } else { + None + }; + + data.notices.sort_by_key(|notice| notice.priority); + let notices = data + .notices + .into_iter() + .map(|notice| Notice { + problem: Problem::new(ProblemCode::RemoteNotice), + content: Some(NoticeContent { + priority: match notice.priority { + playit_api_client::api::AgentNoticePriority::Critical => { + NoticePriority::Critical + } + playit_api_client::api::AgentNoticePriority::High => NoticePriority::High, + playit_api_client::api::AgentNoticePriority::Low => NoticePriority::Low, + }, + message: notice.message.into_owned(), + resolve_url: notice.resolve_link, + }), + }) + .collect(); + + Ok(GatewayRunData { + agent_id: data.agent_id.to_string(), + origins, + catalog, + account_status, + pending: data + .pending + .into_iter() + .map(|pending| PendingTunnel { + id: pending.id.to_string(), + status: pending.status_msg, + }) + .collect(), + notices, + login_url: login_link, + }) +} + +#[derive(Debug)] +struct ConvertedTunnel { + origin: GatewayOrigin, + catalog: CatalogTunnel, +} + +fn convert_tunnel(tunnel: &AgentTunnelV1) -> Result { + let target = parse_target(tunnel)?; + let protocol = match tunnel.port_type { + PortType::Tcp => TunnelProtocol::Tcp, + PortType::Udp => TunnelProtocol::Udp, + PortType::Both => TunnelProtocol::Both, + }; + let proxy_protocol = parse_proxy_protocol(tunnel)?; + let id = + NonZeroU64::new(tunnel.internal_id).ok_or_else(|| invalid_tunnel(tunnel, "zero id"))?; + let id = TunnelId::new(id); + let subject = ProblemSubject { + kind: SubjectKind::Tunnel, + id: id.get(), + }; + let catalog_target = match &target { + GatewayOriginTarget::Port { host, port } => OriginTarget::Port { + host: catalog_host(host, tunnel)?, + first_port: NonZeroU16::new(*port) + .ok_or_else(|| invalid_tunnel(tunnel, "zero local port"))?, + }, + GatewayOriginTarget::Https { + host, + http_port, + https_port, + } => OriginTarget::Https { + host: catalog_host(host, tunnel)?, + http_port: NonZeroU16::new(*http_port) + .ok_or_else(|| invalid_tunnel(tunnel, "zero HTTP port"))?, + https_port: NonZeroU16::new(*https_port) + .ok_or_else(|| invalid_tunnel(tunnel, "zero HTTPS port"))?, + }, + }; + let availability = if tunnel.disabled_reason.is_some() { + TunnelAvailability::Disabled( + Problem::new(ProblemCode::TunnelDisabled).with_subject(subject), + ) + } else { + TunnelAvailability::Active + }; + + Ok(ConvertedTunnel { + origin: GatewayOrigin { + tunnel_id: tunnel.internal_id, + protocol, + target, + port_count: tunnel.port_count, + proxy_protocol, + }, + catalog: CatalogTunnel { + spec: TunnelSpec { + id, + protocol, + target: catalog_target, + port_count: NonZeroU16::new(tunnel.port_count).unwrap_or(NonZeroU16::MIN), + proxy_protocol, + }, + availability, + public_address: tunnel.display_address.clone(), + disabled_reason: tunnel.disabled_reason.as_ref().map(ToString::to_string), + }, + }) +} + +fn parse_target(tunnel: &AgentTunnelV1) -> Result { + let host = tunnel + .agent_config + .fields + .iter() + .find(|field| field.name == "local_ip") + .map(|field| field.value.trim()) + .filter(|value| !value.is_empty()) + .map(parse_host) + .unwrap_or_else(|| GatewayOriginHost::Ip("127.0.0.1".parse().unwrap())); + let tunnel_type = tunnel + .tunnel_type + .clone() + .and_then(|value| serde_json::from_value::(value.into()).ok()); + + if matches!(tunnel_type, Some(TunnelType::Https)) { + return Ok(GatewayOriginTarget::Https { + host, + http_port: field_port(tunnel, "http_port").unwrap_or(80), + https_port: field_port(tunnel, "https_port").unwrap_or(443), + }); + } + + let port = field_port(tunnel, "local_port") + .or_else(|| tunnel.display_address.rsplit(':').next()?.parse().ok()) + .ok_or_else(|| invalid_tunnel(tunnel, "missing local port"))?; + Ok(GatewayOriginTarget::Port { host, port }) +} + +fn parse_host(value: &str) -> GatewayOriginHost { + IpAddr::from_str(value) + .map(GatewayOriginHost::Ip) + .unwrap_or_else(|_| GatewayOriginHost::Hostname(value.to_owned())) +} + +fn field_port(tunnel: &AgentTunnelV1, name: &str) -> Option { + tunnel + .agent_config + .fields + .iter() + .find(|field| field.name == name) + .and_then(|field| field.value.parse().ok()) +} + +fn parse_proxy_protocol(tunnel: &AgentTunnelV1) -> Result { + let value = tunnel + .agent_config + .fields + .iter() + .find(|field| field.name == "proxy_protocol") + .map(|field| field.value.clone()); + let Some(value) = value else { + return Ok(ProxyProtocol::None); + }; + let parsed = serde_json::from_value::(value.into()) + .map_err(|_| invalid_tunnel(tunnel, "invalid proxy protocol"))?; + Ok(match parsed { + ApiProxyProtocol::ProxyProtocolV1 => ProxyProtocol::V1, + ApiProxyProtocol::ProxyProtocolV2 => ProxyProtocol::V2, + }) +} + +fn catalog_host( + host: &GatewayOriginHost, + tunnel: &AgentTunnelV1, +) -> Result { + match host { + GatewayOriginHost::Ip(address) => Ok(OriginHost::Ip(*address)), + GatewayOriginHost::Hostname(hostname) => Hostname::parse(hostname.clone()) + .map(OriginHost::Hostname) + .map_err(|_| invalid_tunnel(tunnel, "invalid local hostname")), + } +} + +fn invalid_tunnel(tunnel: &AgentTunnelV1, detail: &str) -> GatewayError { + GatewayError::new( + GatewayErrorCode::InvalidRunData, + "run_data", + format!("tunnel {}: {detail}", tunnel.internal_id), + ) +} + +fn map_account_status(status: ApiAccountStatus) -> AccountStatus { + match status { + ApiAccountStatus::Guest => AccountStatus::Guest, + ApiAccountStatus::EmailNotVerified => AccountStatus::EmailNotVerified, + ApiAccountStatus::Verified => AccountStatus::Verified, + } +} + +fn api_version(version: AgentVersion) -> playit_api_client::api::AgentVersion { + playit_api_client::api::AgentVersion { + variant_id: version.variant_id, + version_major: version.major, + version_minor: version.minor, + version_patch: version.patch, + } +} + +fn api_platform(platform: Platform) -> ApiPlatform { + match platform { + Platform::Linux => ApiPlatform::Linux, + Platform::Freebsd => ApiPlatform::Freebsd, + Platform::Windows => ApiPlatform::Windows, + Platform::Macos => ApiPlatform::Macos, + Platform::Android => ApiPlatform::Android, + Platform::Ios => ApiPlatform::Ios, + Platform::Docker => ApiPlatform::Docker, + Platform::MinecraftPlugin => ApiPlatform::MinecraftPlugin, + Platform::Unknown => ApiPlatform::Unknown, + } +} + +fn map_register_error(error: ApiError) -> GatewayError { + match error { + ApiError::Fail(ProtoRegisterError::AgentDisabledOverLimit) => GatewayError::new( + GatewayErrorCode::AgentDisabledOverLimit, + "register", + "agent disabled because the account is over its agent limit", + ), + ApiError::Fail(error) => { + GatewayError::new(GatewayErrorCode::Rejected, "register", format!("{error:?}")) + } + ApiError::ApiError(error) => map_response_error("register", error), + ApiError::ClientError(error) => transport_error("register", error), + } +} + +fn map_api_error( + operation: &'static str, + error: ApiError, +) -> GatewayError { + match error { + ApiError::Fail(error) => { + GatewayError::new(GatewayErrorCode::Rejected, operation, format!("{error:?}")) + } + ApiError::ApiError(error) => map_response_error(operation, error), + ApiError::ClientError(error) => transport_error(operation, error), + } +} + +fn map_no_fail_error( + operation: &'static str, + error: ApiErrorNoFail, +) -> GatewayError { + match error { + ApiErrorNoFail::ApiError(error) => map_response_error(operation, error), + ApiErrorNoFail::ClientError(error) => transport_error(operation, error), + } +} + +fn map_login_error(error: ApiError) -> GatewayError { + map_api_error("account_login_url", error) +} + +fn map_response_error(operation: &'static str, error: ApiResponseError) -> GatewayError { + let code = match error { + ApiResponseError::Auth(AuthError::InvalidAgentKey | AuthError::NoLongerValid) => { + GatewayErrorCode::InvalidSecret + } + ApiResponseError::Auth(_) => GatewayErrorCode::Authentication, + _ => GatewayErrorCode::Rejected, + }; + GatewayError::new(code, operation, format!("{error:?}")) +} + +fn transport_error(operation: &'static str, error: HttpClientError) -> GatewayError { + GatewayError::new(GatewayErrorCode::Transport, operation, format!("{error:?}")) +} + +fn now_millis() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +#[cfg(test)] +mod tests { + use super::*; + use playit_api_client::api::{AgentTunnelAttr, AgentTunnelConfig}; + + fn tunnel(local_port: &str) -> AgentTunnelV1 { + AgentTunnelV1 { + id: uuid::Uuid::nil(), + internal_id: 7, + name: "fixture".to_owned(), + display_address: "fixture.playit.gg:25565".to_owned(), + port_type: PortType::Tcp, + port_count: 0, + tunnel_type: None, + tunnel_type_display: "custom".to_owned(), + agent_config: AgentTunnelConfig { + fields: vec![ + AgentTunnelAttr { + name: "local_ip".to_owned(), + value: "origin.internal".to_owned(), + }, + AgentTunnelAttr { + name: "local_port".to_owned(), + value: local_port.to_owned(), + }, + ], + }, + disabled_reason: None, + } + } + + #[test] + fn adapter_converts_generated_tunnels_without_leaking_generated_types() { + let converted = convert_tunnel(&tunnel("25565")).unwrap(); + assert_eq!(converted.origin.tunnel_id, 7); + assert_eq!(converted.catalog.spec.port_count, NonZeroU16::MIN); + assert!(matches!( + converted.catalog.spec.target, + OriginTarget::Port { + host: OriginHost::Hostname(ref hostname), + first_port, + } if hostname.as_str() == "origin.internal" && first_port.get() == 25565 + )); + } + + #[test] + fn adapter_rejects_the_complete_catalog_when_one_tunnel_is_invalid() { + let mut tunnel = tunnel("invalid"); + tunnel.display_address = "invalid".to_owned(); + let error = convert_tunnel(&tunnel).unwrap_err(); + assert_eq!(error.code, GatewayErrorCode::InvalidRunData); + assert_eq!(error.problem_code(), ProblemCode::CatalogInvalid); + } + + #[tokio::test] + async fn adapter_login_cache_expires_at_the_contract_deadline() { + let cache = LoginUrlCache::default(); + cache.store("fixture".to_owned(), 1_000).await; + + assert_eq!( + cache.get(1_000 + LOGIN_CACHE_MILLIS - 1).await, + Some("fixture".to_owned()) + ); + assert_eq!(cache.get(1_000 + LOGIN_CACHE_MILLIS).await, None); + } +} diff --git a/packages/playit-runtime/src/installed_service.rs b/packages/playit-runtime/src/installed_service.rs new file mode 100644 index 00000000..9a5b433d --- /dev/null +++ b/packages/playit-runtime/src/installed_service.rs @@ -0,0 +1,175 @@ +use std::future::Future; +use std::time::Duration; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InstalledServiceStopPolicy { + pub graceful_wait: Duration, + pub final_wait: Duration, +} + +impl Default for InstalledServiceStopPolicy { + fn default() -> Self { + Self { + graceful_wait: Duration::from_secs(1), + final_wait: Duration::from_millis(500), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GracefulStopAttempt { + Accepted, + Rejected(String), + Failed(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InstalledServiceStopOutcome { + AlreadyStopped, + Stopped, + StillRunning, + StatusUnknown(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstalledServiceStopReport { + pub outcome: InstalledServiceStopOutcome, + pub graceful: Option, + pub fallback: Option>, + pub initial_status_error: Option, +} + +pub async fn stop_installed_service_with_fallback< + IsRunning, + IsRunningFuture, + GracefulStop, + GracefulStopFuture, + DirectStop, + DirectStopFuture, +>( + policy: InstalledServiceStopPolicy, + mut is_running: IsRunning, + graceful_stop: GracefulStop, + direct_stop: DirectStop, +) -> InstalledServiceStopReport +where + IsRunning: FnMut() -> IsRunningFuture, + IsRunningFuture: Future>, + GracefulStop: FnOnce() -> GracefulStopFuture, + GracefulStopFuture: Future, + DirectStop: FnOnce() -> DirectStopFuture, + DirectStopFuture: Future>, +{ + let initial_status_error = match is_running().await { + Ok(false) => { + return InstalledServiceStopReport { + outcome: InstalledServiceStopOutcome::AlreadyStopped, + graceful: None, + fallback: None, + initial_status_error: None, + }; + } + Ok(true) => None, + Err(error) => Some(error), + }; + + let graceful = graceful_stop().await; + let fallback = if graceful == GracefulStopAttempt::Accepted { + tokio::time::sleep(policy.graceful_wait).await; + None + } else { + Some(direct_stop().await) + }; + + tokio::time::sleep(policy.final_wait).await; + let outcome = match is_running().await { + Ok(false) => InstalledServiceStopOutcome::Stopped, + Ok(true) => InstalledServiceStopOutcome::StillRunning, + Err(error) => InstalledServiceStopOutcome::StatusUnknown(error), + }; + + InstalledServiceStopReport { + outcome, + graceful: Some(graceful), + fallback, + initial_status_error, + } +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + + use super::*; + + fn no_wait() -> InstalledServiceStopPolicy { + InstalledServiceStopPolicy { + graceful_wait: Duration::ZERO, + final_wait: Duration::ZERO, + } + } + + #[tokio::test] + async fn accepted_graceful_stop_does_not_invoke_fallback() { + let checks = Cell::new(0); + let fallback_called = Cell::new(false); + let report = stop_installed_service_with_fallback( + no_wait(), + || { + let check = checks.get(); + checks.set(check + 1); + async move { Ok(check == 0) } + }, + || async { GracefulStopAttempt::Accepted }, + || { + fallback_called.set(true); + async { Ok(()) } + }, + ) + .await; + + assert_eq!(report.outcome, InstalledServiceStopOutcome::Stopped); + assert_eq!(report.graceful, Some(GracefulStopAttempt::Accepted)); + assert_eq!(report.fallback, None); + assert!(!fallback_called.get()); + } + + #[tokio::test] + async fn rejected_graceful_stop_invokes_fallback_once() { + let checks = Cell::new(0); + let fallback_calls = Cell::new(0); + let report = stop_installed_service_with_fallback( + no_wait(), + || { + let check = checks.get(); + checks.set(check + 1); + async move { Ok(check == 0) } + }, + || async { GracefulStopAttempt::Rejected("rejected".to_owned()) }, + || { + fallback_calls.set(fallback_calls.get() + 1); + async { Ok(()) } + }, + ) + .await; + + assert_eq!(report.outcome, InstalledServiceStopOutcome::Stopped); + assert_eq!(fallback_calls.get(), 1); + assert_eq!(report.fallback, Some(Ok(()))); + } + + #[tokio::test] + async fn stopped_service_skips_both_stop_mechanisms() { + let report = stop_installed_service_with_fallback( + no_wait(), + || async { Ok(false) }, + || async { panic!("graceful stop must not run") }, + || async { panic!("fallback stop must not run") }, + ) + .await; + + assert_eq!(report.outcome, InstalledServiceStopOutcome::AlreadyStopped); + assert_eq!(report.graceful, None); + assert_eq!(report.fallback, None); + } +} diff --git a/packages/playit-runtime/src/lib.rs b/packages/playit-runtime/src/lib.rs new file mode 100644 index 00000000..426c7a42 --- /dev/null +++ b/packages/playit-runtime/src/lib.rs @@ -0,0 +1,19 @@ +#[cfg(feature = "application")] +mod claim; +#[cfg(feature = "application")] +mod engine; +#[cfg(feature = "application")] +mod generated_gateway; +mod installed_service; +#[cfg(feature = "application")] +mod supervisor; + +#[cfg(feature = "application")] +pub use claim::*; +#[cfg(feature = "application")] +pub use engine::*; +#[cfg(feature = "application")] +pub use generated_gateway::GeneratedClientGateway; +pub use installed_service::*; +#[cfg(feature = "application")] +pub use supervisor::*; diff --git a/packages/playit-runtime/src/supervisor.rs b/packages/playit-runtime/src/supervisor.rs new file mode 100644 index 00000000..73c99d33 --- /dev/null +++ b/packages/playit-runtime/src/supervisor.rs @@ -0,0 +1,1858 @@ +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use async_trait::async_trait; +use playit_agent_core::gateway::{ + GatewayErrorCode, GatewayOrigin, GatewayRunData, PlayitGateway, RunDataContext, +}; +use playit_agent_core::playit_agent::{EngineExit, ServiceExit}; +use playit_model::{ + AgentIdentity, AppSnapshot, AppState, Command, CommandDecision, Deadline, Effect, Event, + EventDecision, Phase, Problem, ProblemCode, ProblemReport, SecretInput, ServiceInfo, + TrafficSnapshot, decide_command, reduce, +}; +use tokio::sync::{mpsc, oneshot, watch}; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; + +use crate::{ + ClaimExchange, ClaimFailure, ClaimMode, ClaimProgress, ClaimService, ClaimSession, + GeneratedClientGateway, +}; + +pub type BoxSleep = Pin + Send>>; + +pub trait Clock: Send + Sync { + fn now_millis(&self) -> u64; + fn sleep_until(&self, deadline_millis: u64) -> BoxSleep; +} + +#[derive(Debug, Default)] +pub struct SystemClock; + +impl Clock for SystemClock { + fn now_millis(&self) -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 + } + + fn sleep_until(&self, deadline_millis: u64) -> BoxSleep { + let delay = Duration::from_millis(deadline_millis.saturating_sub(self.now_millis())); + Box::pin(tokio::time::sleep(delay)) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SupervisorPolicy { + pub refresh_interval: Duration, + pub refresh_retry: Duration, + pub agent_limit_retry: Duration, + pub stats_interval: Duration, + pub shutdown_deadline: Duration, + pub gateway_timeout: Duration, +} + +impl Default for SupervisorPolicy { + fn default() -> Self { + Self { + refresh_interval: Duration::from_secs(3), + refresh_retry: Duration::from_secs(3), + agent_limit_retry: Duration::from_secs(30), + stats_interval: Duration::from_millis(100), + shutdown_deadline: Duration::from_secs(5), + gateway_timeout: Duration::from_secs(10), + } + } +} + +impl SupervisorPolicy { + pub fn deadline_after(&self, now_millis: u64, delay: Duration) -> u64 { + now_millis.saturating_add(delay.as_millis() as u64) + } + + pub fn retry_deadline(&self, now_millis: u64, code: GatewayErrorCode) -> u64 { + let delay = if code == GatewayErrorCode::AgentDisabledOverLimit { + self.agent_limit_retry + } else { + self.refresh_retry + }; + self.deadline_after(now_millis, delay) + } +} + +#[derive(Debug, Clone)] +pub struct SupervisorConfig { + pub api_base: String, + pub version: String, + pub start_time: u64, + pub service: ServiceInfo, + pub policy: SupervisorPolicy, +} + +#[derive(Clone, PartialEq, Eq)] +pub enum SecretState { + Ready(String), + Missing, + Invalid(String), +} + +impl std::fmt::Debug for SecretState { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Ready(_) => formatter.write_str("Ready([REDACTED])"), + Self::Missing => formatter.write_str("Missing"), + Self::Invalid(detail) => formatter.debug_tuple("Invalid").field(detail).finish(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SecretStoreError { + Pinned, + Io(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SecretReset { + Deleted(PathBuf), + AlreadyAbsent(PathBuf), +} + +#[async_trait] +pub trait SecretStore: Send + Sync { + async fn load(&self) -> SecretState; + async fn persist(&self, secret: &SecretInput) -> Result<(), SecretStoreError>; + async fn reset(&self) -> Result; + fn path(&self) -> Option<&Path>; + fn can_provision(&self) -> bool; +} + +pub struct InlineSecretStore { + secret: String, +} + +impl InlineSecretStore { + pub fn new(secret: String) -> Self { + Self { secret } + } +} + +#[async_trait] +impl SecretStore for InlineSecretStore { + async fn load(&self) -> SecretState { + match validate_secret(self.secret.trim()) { + Ok(secret) => SecretState::Ready(secret), + Err(error) => { + SecretState::Invalid(format!("Invalid secret passed via --secret: {error}")) + } + } + } + + async fn persist(&self, _secret: &SecretInput) -> Result<(), SecretStoreError> { + Err(SecretStoreError::Pinned) + } + + async fn reset(&self) -> Result { + Err(SecretStoreError::Pinned) + } + + fn path(&self) -> Option<&Path> { + None + } + + fn can_provision(&self) -> bool { + false + } +} + +pub struct FileSecretStore { + path: PathBuf, +} + +impl FileSecretStore { + pub fn new(path: PathBuf) -> Self { + Self { path } + } +} + +#[async_trait] +impl SecretStore for FileSecretStore { + async fn load(&self) -> SecretState { + let content = match tokio::fs::read_to_string(&self.path).await { + Ok(content) => content, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return SecretState::Missing; + } + Err(error) => { + return SecretState::Invalid(format!( + "Failed to read secret file {}: {error}", + self.path.display() + )); + } + }; + match parse_secret_file(&content) { + Ok(secret) => SecretState::Ready(secret), + Err(()) => { + SecretState::Invalid(format!("invalid secret file at {}", self.path.display())) + } + } + } + + async fn persist(&self, secret: &SecretInput) -> Result<(), SecretStoreError> { + persist_secret_file(&self.path, secret.expose()) + .await + .map_err(SecretStoreError::Io) + } + + async fn reset(&self) -> Result { + match tokio::fs::remove_file(&self.path).await { + Ok(()) => Ok(SecretReset::Deleted(self.path.clone())), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(SecretReset::AlreadyAbsent(self.path.clone())) + } + Err(error) => Err(SecretStoreError::Io(format!( + "Failed to delete secret file {}: {error}", + self.path.display() + ))), + } + } + + fn path(&self) -> Option<&Path> { + Some(&self.path) + } + + fn can_provision(&self) -> bool { + true + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EngineStartError { + InvalidSecret(String), + AgentDisabledOverLimit(String), + Failed(String), +} + +#[async_trait] +pub trait OriginPublisher: Send + Sync { + async fn replace(&self, origins: &[GatewayOrigin]); +} + +pub trait TrafficSource: Send + Sync { + fn snapshot(&self) -> TrafficSnapshot; +} + +pub struct EngineChild { + cancel: CancellationToken, + exit: JoinHandle, + origins: Arc, + traffic: Arc, +} + +impl EngineChild { + pub fn new( + cancel: CancellationToken, + exit: JoinHandle, + origins: Arc, + traffic: Arc, + ) -> Self { + Self { + cancel, + exit, + origins, + traffic, + } + } +} + +#[async_trait] +pub trait EnginePort: Send + Sync { + async fn start( + &self, + gateway: Arc, + origins: &[GatewayOrigin], + ) -> Result; +} + +pub struct ServiceChild { + cancel: CancellationToken, + exit: JoinHandle, +} + +impl ServiceChild { + pub fn new(cancel: CancellationToken, exit: JoinHandle) -> Self { + Self { cancel, exit } + } +} + +pub trait IpcPort: Send { + fn start(self: Box) -> ServiceChild; +} + +pub trait GatewayFactory: Send + Sync { + fn create(&self, api_base: &str, secret: &str) -> Arc; +} + +#[derive(Debug, Default)] +pub struct GeneratedGatewayFactory; + +impl GatewayFactory for GeneratedGatewayFactory { + fn create(&self, api_base: &str, secret: &str) -> Arc { + Arc::new(GeneratedClientGateway::new( + api_base.to_owned(), + secret.to_owned(), + )) + } +} + +pub struct SnapshotStore { + sender: watch::Sender>, +} + +impl SnapshotStore { + pub fn new(snapshot: AppSnapshot) -> Self { + let (sender, _) = watch::channel(Arc::new(snapshot)); + Self { sender } + } + + pub fn snapshot(&self) -> Arc { + self.sender.borrow().clone() + } + + pub fn subscribe(&self) -> watch::Receiver> { + self.sender.subscribe() + } + + pub fn publish(&self, update: impl FnOnce(&mut AppSnapshot)) -> Option> { + let mut update = Some(update); + let mut published = None; + self.sender.send_if_modified(|current| { + let mut next = (**current).clone(); + update.take().expect("snapshot update runs once")(&mut next); + next.revision = current.revision; + if next == **current { + return false; + } + next.revision = current + .revision + .checked_add(1) + .expect("snapshot revision exhausted"); + let next = Arc::new(next); + published = Some(next.clone()); + *current = next; + true + }); + published + } + + fn apply_state(&self, state: AppState) { + self.publish(|snapshot| { + let revision = snapshot.revision; + *snapshot = state.snapshot; + snapshot.revision = revision; + }); + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CommandOutput { + Accepted, + ClaimStarted(ClaimSession), + ClaimProgress(ClaimProgress), + ClaimPending(String), + ClaimProvisioned, + LoginUrl(String), + SecretReset(SecretReset), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandFailure { + pub problem: Problem, + pub detail: Option, + pub retry_at_millis: Option, +} + +impl CommandFailure { + pub const fn new(code: ProblemCode) -> Self { + Self { + problem: Problem::new(code), + detail: None, + retry_at_millis: None, + } + } + + pub fn with_detail(mut self, detail: impl Into) -> Self { + self.detail = Some(detail.into()); + self + } + + pub const fn with_retry_at(mut self, retry_at_millis: u64) -> Self { + self.retry_at_millis = Some(retry_at_millis); + self + } +} + +pub type CommandError = CommandFailure; + +struct CommandRequest { + command: Command, + response: oneshot::Sender>, +} + +#[derive(Clone)] +pub struct SupervisorHandle { + tx: mpsc::Sender, + effect_cancel: CancellationToken, +} + +impl SupervisorHandle { + pub async fn command(&self, command: Command) -> Result { + let stops = matches!(command, Command::Stop); + let (response, result) = oneshot::channel(); + self.tx + .send(CommandRequest { command, response }) + .await + .map_err(|_| CommandFailure::new(ProblemCode::EngineUnavailable))?; + if stops { + self.effect_cancel.cancel(); + } + result + .await + .map_err(|_| CommandFailure::new(ProblemCode::EngineUnavailable))? + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SupervisorError { + MissingIpc, + IpcExited(ServiceExit), + EngineExited(EngineExit), + Startup(String), + ShutdownTimedOut, +} + +impl SupervisorError { + pub fn failure(&self) -> CommandFailure { + match self { + Self::MissingIpc => CommandFailure::new(ProblemCode::StartupFailed) + .with_detail("IPC service was not installed"), + Self::IpcExited(exit) => CommandFailure::new(ProblemCode::StartupFailed) + .with_detail(format!("IPC service exited: {exit:?}")), + Self::EngineExited(exit) => engine_failure(exit), + Self::Startup(detail) => { + CommandFailure::new(ProblemCode::StartupFailed).with_detail(detail.clone()) + } + Self::ShutdownTimedOut => CommandFailure::new(ProblemCode::ShutdownTimedOut), + } + } +} + +fn engine_failure(exit: &EngineExit) -> CommandFailure { + let code = match exit { + EngineExit::ShutdownTimedOut { .. } => ProblemCode::ShutdownTimedOut, + EngineExit::Cancelled | EngineExit::Completed => ProblemCode::EngineUnavailable, + EngineExit::Service { .. } | EngineExit::Panicked(_) => ProblemCode::StartupFailed, + }; + CommandFailure::new(code).with_detail(format!("{exit:?}")) +} + +pub struct AppSupervisor { + config: SupervisorConfig, + clock: Arc, + secrets: Arc, + engine_port: Arc, + gateway_factory: Arc, + snapshots: Arc, + commands: mpsc::Receiver, + ipc: Option>, + ipc_child: Option, + engine: Option, + gateway: Option>, + active_secret: Option, + next_refresh: Option, + next_stats: u64, + stopping: bool, + terminal_error: Option, + claims: ClaimService, + active_claim: Option, + effect_cancel: CancellationToken, + process_shutdown: CancellationToken, + start_attempt: u32, +} + +impl AppSupervisor { + pub fn new( + config: SupervisorConfig, + secrets: Arc, + engine_port: Arc, + clock: Arc, + ) -> (Self, SupervisorHandle, Arc) { + Self::new_with_gateway_factory( + config, + secrets, + engine_port, + clock, + Arc::new(GeneratedGatewayFactory), + ) + } + + pub fn new_with_gateway_factory( + config: SupervisorConfig, + secrets: Arc, + engine_port: Arc, + clock: Arc, + gateway_factory: Arc, + ) -> (Self, SupervisorHandle, Arc) { + let mut initial = AppSnapshot::booting(); + initial.service = config.service.clone(); + let snapshots = Arc::new(SnapshotStore::new(initial)); + let (tx, commands) = mpsc::channel(32); + let effect_cancel = CancellationToken::new(); + let handle = SupervisorHandle { + tx, + effect_cancel: effect_cancel.clone(), + }; + let next_stats = config + .policy + .deadline_after(clock.now_millis(), config.policy.stats_interval); + let claims = ClaimService::new(config.api_base.clone(), config.version.clone()); + ( + Self { + config, + clock, + secrets, + engine_port, + gateway_factory, + snapshots: snapshots.clone(), + commands, + ipc: None, + ipc_child: None, + engine: None, + gateway: None, + active_secret: None, + next_refresh: None, + next_stats, + stopping: false, + terminal_error: None, + claims, + active_claim: None, + effect_cancel, + process_shutdown: CancellationToken::new(), + start_attempt: 1, + }, + handle, + snapshots, + ) + } + + pub fn install_ipc(&mut self, ipc: Box) { + self.ipc = Some(ipc); + } + + pub async fn run(mut self, shutdown: CancellationToken) -> Result<(), SupervisorError> { + self.process_shutdown = shutdown.clone(); + let ipc = self.ipc.take().ok_or(SupervisorError::MissingIpc)?; + self.ipc_child = Some(ipc.start()); + self.apply_event(Event::Boot); + if let Err(error) = self.initialize_secret().await { + self.terminal_error = Some(error); + self.begin_shutdown(); + } + + loop { + if self.stopping { + self.shutdown_children().await?; + return match self.terminal_error.take() { + Some(error) => Err(error), + None => Ok(()), + }; + } + + let wake = self.next_wake(); + let clock = self.clock.clone(); + let sleep = clock.sleep_until(wake); + let engine_exit = self.engine.as_mut().map(|engine| &mut engine.exit); + let ipc_exit = self.ipc_child.as_mut().map(|ipc| &mut ipc.exit); + + tokio::select! { + request = self.commands.recv() => { + let Some(request) = request else { + self.begin_shutdown(); + continue; + }; + self.process_command(request).await; + } + _ = shutdown.cancelled() => self.begin_shutdown(), + exit = wait_optional_child(engine_exit) => { + let exit = join_engine_exit(exit); + self.engine = None; + self.begin_shutdown(); + self.shutdown_children().await?; + return Err(SupervisorError::EngineExited(exit)); + } + exit = wait_optional_child(ipc_exit) => { + let exit = join_service_exit(exit); + self.ipc_child = None; + self.begin_shutdown(); + self.shutdown_children().await?; + return Err(SupervisorError::IpcExited(exit)); + } + _ = sleep => self.on_clock().await, + } + } + } + + async fn initialize_secret(&mut self) -> Result<(), SupervisorError> { + match self.secrets.load().await { + SecretState::Ready(secret) => { + self.active_secret = Some(secret); + self.apply_event(Event::SecretLoaded); + self.start_engine().await + } + SecretState::Missing => { + self.apply_event(Event::SecretMissing); + self.publish_problem(None, false); + Ok(()) + } + SecretState::Invalid(message) => { + self.apply_event(Event::SecretInvalid); + self.publish_problem( + Some(problem_report(ProblemCode::InvalidSecret, message.clone())), + false, + ); + if self.secrets.can_provision() { + self.wait_for_replacement_secret(); + Ok(()) + } else { + Err(SupervisorError::Startup(message)) + } + } + } + } + + async fn process_command(&mut self, request: CommandRequest) { + let CommandRequest { command, response } = request; + let decision = decide_command( + AppState { + snapshot: (*self.snapshots.snapshot()).clone(), + }, + command.clone(), + ); + let (accepted_state, effects) = match decision { + CommandDecision::Accepted { state, effects } => (state, effects), + CommandDecision::Rejected { problem, .. } => { + let mut failure = CommandFailure::new(problem.code); + if let Phase::Blocked { + retry_at: Deadline::AtMillis(retry_at), + .. + } = self.snapshots.snapshot().phase + { + failure = failure.with_retry_at(retry_at); + } + let _ = response.send(Err(failure)); + return; + } + }; + let publish_after_effect = matches!(command, Command::ResetSecret); + if !publish_after_effect { + self.snapshots.apply_state(accepted_state.clone()); + } + let can_start_engine = matches!( + command, + Command::ProvisionSecret { .. } | Command::ExchangeClaim { .. } + ); + let result = self.execute_command_effects(command, effects).await; + if publish_after_effect && result.is_ok() { + self.snapshots.apply_state(accepted_state); + } + let start_after_response = can_start_engine + && matches!( + result, + Ok(CommandOutput::Accepted | CommandOutput::ClaimProvisioned) + ); + let _ = response.send(result); + if start_after_response && let Err(error) = self.start_engine().await { + self.terminal_error = Some(error); + self.begin_shutdown(); + } + } + + async fn execute_command_effects( + &mut self, + command: Command, + effects: Vec, + ) -> Result { + match command { + Command::BeginClaim if effects.contains(&Effect::StartClaim) => { + let session = ClaimService::begin(); + self.active_claim = Some(session.clone()); + Ok(CommandOutput::ClaimStarted(session)) + } + Command::PollClaim { code } if effects.contains(&Effect::PollClaim) => { + self.ensure_active_claim(&code)?; + self.bounded_gateway(self.claims.progress(&code, ClaimMode::Assignable)) + .await + .map_err(bounded_command_error)? + .map(CommandOutput::ClaimProgress) + .map_err(claim_error) + } + Command::ExchangeClaim { code } if effects.contains(&Effect::ExchangeClaim) => { + self.ensure_active_claim(&code)?; + let exchange = self + .bounded_gateway(self.claims.exchange(&code)) + .await + .map_err(bounded_command_error)? + .map_err(claim_error)?; + match exchange { + ClaimExchange::Pending(status) => Ok(CommandOutput::ClaimPending(status)), + ClaimExchange::Accepted(secret) => { + let secret = SecretInput::new(secret).map_err(|_| { + CommandFailure::new(ProblemCode::InvalidSecret) + .with_detail("claim exchange returned an empty secret") + })?; + self.apply_event(Event::SecretLoaded); + self.persist_provisioned_secret(&secret).await?; + self.active_claim = None; + Ok(CommandOutput::ClaimProvisioned) + } + } + } + Command::ProvisionSecret { secret } + if effects + .iter() + .any(|effect| matches!(effect, Effect::PersistSecret(_))) => + { + self.persist_provisioned_secret(&secret).await?; + Ok(CommandOutput::Accepted) + } + Command::ResetSecret if effects.contains(&Effect::ResetSecret) => { + let reset = self.secrets.reset().await.map_err(secret_error)?; + self.stopping = true; + Ok(CommandOutput::SecretReset(reset)) + } + Command::CreateLoginUrl if effects.contains(&Effect::CreateLoginUrl) => { + let gateway = self + .gateway + .as_ref() + .ok_or_else(|| CommandFailure::new(ProblemCode::EngineUnavailable))?; + self.bounded_gateway(gateway.account_login_url()) + .await + .map_err(bounded_command_error)? + .map(CommandOutput::LoginUrl) + .map_err(|error| { + CommandFailure::new(error.problem_code()).with_detail(error.to_string()) + }) + } + Command::RefreshCatalog if effects.contains(&Effect::FetchCatalog) => { + self.refresh_catalog().await; + Ok(CommandOutput::Accepted) + } + Command::Stop if effects.contains(&Effect::StopEngine) => { + self.stopping = true; + Ok(CommandOutput::Accepted) + } + _ => Err(CommandFailure::new(ProblemCode::CommandNotAllowed)), + } + } + + async fn start_engine(&mut self) -> Result<(), SupervisorError> { + let secret = self + .active_secret + .clone() + .ok_or_else(|| SupervisorError::Startup("agent secret is unavailable".to_owned()))?; + let gateway = self.gateway_factory.create(&self.config.api_base, &secret); + let initial_data = match self + .bounded_gateway(gateway.run_data(self.run_data_context())) + .await + { + Ok(result) => result.ok(), + Err(BoundedError::Cancelled) => return Ok(()), + Err(BoundedError::TimedOut) => None, + }; + let origins = initial_data + .as_ref() + .map(|data| data.origins.as_slice()) + .unwrap_or_default(); + + let engine_start = self + .bounded_gateway(self.engine_port.start(gateway.clone(), origins)) + .await; + let engine_start = match engine_start { + Ok(result) => result, + Err(BoundedError::Cancelled) => return Ok(()), + Err(BoundedError::TimedOut) => Err(EngineStartError::Failed( + "engine startup timed out".to_owned(), + )), + }; + + match engine_start { + Ok(engine) => { + self.engine = Some(engine); + self.gateway = Some(gateway); + if let Some(data) = initial_data { + self.publish_run_data(data).await; + } else { + self.next_refresh = Some(self.clock.now_millis()); + } + Ok(()) + } + Err(EngineStartError::InvalidSecret(message)) => { + self.gateway = Some(gateway); + self.apply_event(Event::SecretInvalid); + self.publish_problem( + Some(problem_report(ProblemCode::InvalidSecret, message)), + false, + ); + if self.secrets.can_provision() { + self.wait_for_replacement_secret(); + Ok(()) + } else { + Err(SupervisorError::Startup( + "the configured secret is invalid".to_owned(), + )) + } + } + Err(EngineStartError::AgentDisabledOverLimit(message)) => { + self.gateway = Some(gateway); + let retry_at = self.config.policy.retry_deadline( + self.clock.now_millis(), + GatewayErrorCode::AgentDisabledOverLimit, + ); + self.apply_event(Event::EngineStartFailed { + problem: Problem::new(ProblemCode::AgentDisabledOverLimit), + retry_at: Deadline::AtMillis(retry_at), + }); + self.publish_problem( + Some(problem_report(ProblemCode::AgentDisabledOverLimit, message)), + true, + ); + self.next_refresh = Some(retry_at); + Ok(()) + } + Err(EngineStartError::Failed(message)) => { + self.apply_event(Event::EngineStartFailed { + problem: Problem::new(ProblemCode::Internal), + retry_at: Deadline::Unscheduled, + }); + self.publish_problem( + Some(problem_report(ProblemCode::Internal, message.clone())), + true, + ); + Err(SupervisorError::Startup(message)) + } + } + } + + async fn on_clock(&mut self) { + let now = self.clock.now_millis(); + self.snapshots.publish(|snapshot| { + snapshot.service.uptime_secs = now.saturating_sub(self.config.start_time) / 1_000; + }); + + if now >= self.next_stats { + if let Some(engine) = &self.engine { + let traffic = engine.traffic.snapshot(); + self.snapshots + .publish(|snapshot| snapshot.traffic = traffic); + } + self.next_stats = self + .config + .policy + .deadline_after(now, self.config.policy.stats_interval); + } + + if self.next_refresh.is_some_and(|deadline| now >= deadline) { + if matches!(self.snapshots.snapshot().phase, Phase::Blocked { .. }) { + self.start_attempt = self.start_attempt.saturating_add(1); + self.apply_event(Event::RetryElapsed { + attempt: self.start_attempt, + }); + if let Err(error) = self.start_engine().await { + tracing::error!(?error, "agent retry failed"); + } + } else { + self.refresh_catalog().await; + } + } + } + + async fn refresh_catalog(&mut self) { + let Some(gateway) = self.gateway.clone() else { + return; + }; + let response = self + .bounded_gateway(gateway.run_data(self.run_data_context())) + .await; + match response { + Err(BoundedError::Cancelled) => {} + Err(BoundedError::TimedOut) => { + let retry_at = self + .config + .policy + .retry_deadline(self.clock.now_millis(), GatewayErrorCode::Transport); + self.snapshots.publish(|snapshot| { + snapshot.catalog.last_problem = + Some(Problem::new(ProblemCode::CatalogUnavailable)); + }); + self.next_refresh = Some(retry_at); + tracing::warn!("catalog refresh timed out"); + } + Ok(Ok(data)) => self.publish_run_data(data).await, + Ok(Err(error)) => { + let retry_at = self + .config + .policy + .retry_deadline(self.clock.now_millis(), error.code); + if matches!(self.snapshots.snapshot().phase, Phase::Online { .. }) { + self.apply_event(Event::CatalogLoadFailed { + retry_at: Deadline::AtMillis(retry_at), + }); + } else { + self.snapshots.publish(|snapshot| { + snapshot.catalog.last_problem = Some(error.problem()); + }); + } + self.next_refresh = Some(retry_at); + tracing::error!(?error, "failed to load agent data"); + } + } + } + + fn ensure_active_claim(&self, code: &str) -> Result<(), CommandError> { + self.active_claim + .as_ref() + .filter(|session| session.code == code) + .map(|_| ()) + .ok_or_else(|| { + CommandFailure::new(ProblemCode::CommandNotAllowed) + .with_detail("claim session is not active") + }) + } + + async fn persist_provisioned_secret( + &mut self, + secret: &SecretInput, + ) -> Result<(), CommandError> { + if validate_secret(secret.expose()).is_err() { + self.apply_event(Event::SecretPersistenceFailed); + return Err(CommandFailure::new(ProblemCode::InvalidSecret) + .with_detail("secret is not valid hexadecimal")); + } + if let Err(error) = self.secrets.persist(secret).await { + self.apply_event(Event::SecretPersistenceFailed); + return Err(secret_error(error)); + } + self.active_secret = Some(secret.expose().to_owned()); + self.start_attempt = 1; + self.apply_event(Event::SecretPersisted); + Ok(()) + } + + fn bounded_gateway( + &self, + future: impl Future + Send, + ) -> impl Future> + Send { + let effect_cancel = self.effect_cancel.clone(); + let process_shutdown = self.process_shutdown.clone(); + let timeout = self.config.policy.gateway_timeout; + async move { + tokio::select! { + biased; + _ = effect_cancel.cancelled() => Err(BoundedError::Cancelled), + _ = process_shutdown.cancelled() => Err(BoundedError::Cancelled), + result = tokio::time::timeout(timeout, future) => { + result.map_err(|_| BoundedError::TimedOut) + } + } + } + } + + fn run_data_context(&self) -> RunDataContext { + let snapshot = self.snapshots.snapshot(); + RunDataContext { + catalog_revision: snapshot.catalog.accepted.revision().saturating_add(1), + accepted_at_millis: self.clock.now_millis(), + } + } + + async fn publish_run_data(&mut self, data: GatewayRunData) { + if let Some(engine) = &self.engine { + engine.origins.replace(&data.origins).await; + } + if !matches!(self.snapshots.snapshot().phase, Phase::Online { .. }) { + self.apply_event(Event::EngineStarted { + agent: AgentIdentity::new(data.agent_id.clone()), + }); + } + self.apply_event(Event::CatalogLoaded { + catalog: data.catalog.clone(), + }); + self.snapshots.publish(move |snapshot| { + snapshot.catalog.pending = data.pending; + snapshot.catalog.account_status = data.account_status; + snapshot.catalog.login_url = data.login_url; + snapshot.catalog.last_problem = None; + snapshot.notices = data.notices; + snapshot.last_problem = None; + snapshot.service.has_secret = true; + }); + self.next_refresh = Some( + self.config + .policy + .deadline_after(self.clock.now_millis(), self.config.policy.refresh_interval), + ); + } + + fn apply_event(&self, event: Event) { + let state = AppState { + snapshot: (*self.snapshots.snapshot()).clone(), + }; + if let EventDecision::Applied { state, .. } = reduce(state, event) { + self.snapshots.apply_state(state); + } + } + + fn publish_problem(&self, problem: Option, has_secret: bool) { + self.snapshots.publish(|snapshot| { + snapshot.last_problem = problem; + snapshot.service.has_secret = has_secret; + }); + } + + fn wait_for_replacement_secret(&self) { + self.snapshots.publish(|snapshot| { + snapshot.phase = Phase::NeedsSecret { + reason: playit_model::SecretNeed::Missing, + }; + }); + } + + fn next_wake(&self) -> u64 { + self.next_refresh + .map(|refresh| refresh.min(self.next_stats)) + .unwrap_or(self.next_stats) + } + + fn begin_shutdown(&mut self) { + if self.stopping { + return; + } + let decision = decide_command( + AppState { + snapshot: (*self.snapshots.snapshot()).clone(), + }, + Command::Stop, + ); + if let CommandDecision::Accepted { state, .. } = decision { + self.snapshots.apply_state(state); + } else { + self.snapshots + .publish(|snapshot| snapshot.phase = Phase::Stopping); + } + self.stopping = true; + } + + async fn shutdown_children(&mut self) -> Result<(), SupervisorError> { + let deadline = self.config.policy.deadline_after( + self.clock.now_millis(), + self.config.policy.shutdown_deadline, + ); + if let Some(engine) = self.engine.as_ref() { + engine.cancel.cancel(); + } + let engine_exit = self.engine.as_mut().map(|engine| &mut engine.exit); + if !await_child(engine_exit, self.clock.clone(), deadline).await { + if let Some(engine) = self.engine.take() { + engine.exit.abort(); + let _ = engine.exit.await; + } + if let Some(ipc) = self.ipc_child.as_ref() { + ipc.cancel.cancel(); + } + if let Some(ipc) = self.ipc_child.take() { + ipc.exit.abort(); + let _ = ipc.exit.await; + } + self.finish_shutdown(true); + return Err(SupervisorError::ShutdownTimedOut); + } + self.engine = None; + + if let Some(ipc) = self.ipc_child.as_ref() { + ipc.cancel.cancel(); + } + let ipc_exit = self.ipc_child.as_mut().map(|ipc| &mut ipc.exit); + if !await_child(ipc_exit, self.clock.clone(), deadline).await { + if let Some(ipc) = self.ipc_child.take() { + ipc.exit.abort(); + let _ = ipc.exit.await; + } + self.finish_shutdown(true); + return Err(SupervisorError::ShutdownTimedOut); + } + self.ipc_child = None; + self.finish_shutdown(false); + Ok(()) + } + + fn finish_shutdown(&self, timed_out: bool) { + self.apply_event(if timed_out { + Event::ShutdownTimedOut + } else { + Event::ShutdownComplete + }); + } +} + +async fn wait_optional_child( + child: Option<&mut JoinHandle>, +) -> Result { + match child { + Some(child) => child.await, + None => std::future::pending().await, + } +} + +async fn await_child( + child: Option<&mut JoinHandle>, + clock: Arc, + deadline: u64, +) -> bool { + let Some(child) = child else { + return true; + }; + tokio::select! { + _ = child => true, + _ = clock.sleep_until(deadline) => false, + } +} + +fn join_service_exit(result: Result) -> ServiceExit { + match result { + Ok(exit) => exit, + Err(error) => ServiceExit::Panicked(error.to_string()), + } +} + +fn join_engine_exit(result: Result) -> EngineExit { + match result { + Ok(exit) => exit, + Err(error) => EngineExit::Panicked(error.to_string()), + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BoundedError { + Cancelled, + TimedOut, +} + +fn bounded_command_error(error: BoundedError) -> CommandError { + let detail = match error { + BoundedError::Cancelled => "request cancelled because the service is stopping", + BoundedError::TimedOut => "gateway request timed out", + }; + CommandFailure::new(ProblemCode::EngineUnavailable).with_detail(detail) +} + +fn claim_error(error: ClaimFailure) -> CommandError { + CommandFailure { + problem: error.problem, + detail: Some(error.detail), + retry_at_millis: None, + } +} + +fn secret_error(error: SecretStoreError) -> CommandError { + match error { + SecretStoreError::Pinned => CommandFailure::new(ProblemCode::SecretPinned), + SecretStoreError::Io(message) => { + CommandFailure::new(ProblemCode::SecretWriteFailed).with_detail(message) + } + } +} + +fn problem_report(code: ProblemCode, detail: String) -> ProblemReport { + ProblemReport { + problem: Problem::new(code), + detail, + } +} + +fn parse_secret_file(content: &str) -> Result { + let trimmed = content.trim(); + if let Ok(secret) = validate_secret(trimmed) { + return Ok(secret); + } + let config = toml::from_str::(content).map_err(|_| ())?; + validate_secret(config.secret_key.trim()).map_err(|_| ()) +} + +fn validate_secret(secret: &str) -> Result { + hex::decode(secret) + .map(|_| secret.to_owned()) + .map_err(|_| "secret is not valid hexadecimal".to_owned()) +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct SecretConfig { + secret_key: String, +} + +async fn persist_secret_file(path: &Path, secret: &str) -> Result<(), String> { + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + tokio::fs::create_dir_all(parent).await.map_err(|error| { + format!( + "Failed to create secret directory {}: {error}", + parent.display() + ) + })?; + } + let content = if path.extension().and_then(|ext| ext.to_str()) == Some("toml") { + toml::to_string(&SecretConfig { + secret_key: secret.to_owned(), + }) + .map_err(|error| { + format!( + "Failed to serialize secret file {}: {error}", + path.display() + ) + })? + } else { + secret.to_owned() + }; + playit_platform::secret::atomic_write_secret(path, content.as_bytes()) + .await + .map_err(|error| format!("Failed to write secret file {}: {error}", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::VecDeque; + use std::sync::Mutex; + use std::sync::atomic::{AtomicBool, Ordering}; + + use playit_model::{AccountStatus, TunnelCatalog}; + + use crate::ClaimGateway; + use playit_agent_core::gateway::{GatewayError, RegisterRequest, SignedAgentKey}; + use playit_agent_core::playit_agent::EngineService; + + struct DropTracker(Arc); + + impl Drop for DropTracker { + fn drop(&mut self) { + self.0.store(true, Ordering::Release); + } + } + + struct MissingSecrets; + + #[async_trait] + impl SecretStore for MissingSecrets { + async fn load(&self) -> SecretState { + SecretState::Missing + } + + async fn persist(&self, _secret: &SecretInput) -> Result<(), SecretStoreError> { + Ok(()) + } + + async fn reset(&self) -> Result { + Ok(SecretReset::AlreadyAbsent(PathBuf::from("fixture"))) + } + + fn path(&self) -> Option<&Path> { + Some(Path::new("fixture")) + } + + fn can_provision(&self) -> bool { + true + } + } + + struct UnusedEngine; + + #[async_trait] + impl EnginePort for UnusedEngine { + async fn start( + &self, + _gateway: Arc, + _origins: &[GatewayOrigin], + ) -> Result { + panic!("missing-secret tests must not start the engine") + } + } + + struct HoldingEngine; + + #[async_trait] + impl EnginePort for HoldingEngine { + async fn start( + &self, + _gateway: Arc, + _origins: &[GatewayOrigin], + ) -> Result { + let cancel = CancellationToken::new(); + let wait = cancel.clone(); + Ok(EngineChild::new( + cancel, + tokio::spawn(async move { + wait.cancelled().await; + EngineExit::Cancelled + }), + Arc::new(NoopOrigins), + Arc::new(NoopTraffic), + )) + } + } + + struct ReadySecrets; + + #[async_trait] + impl SecretStore for ReadySecrets { + async fn load(&self) -> SecretState { + SecretState::Ready("00".to_owned()) + } + + async fn persist(&self, _secret: &SecretInput) -> Result<(), SecretStoreError> { + Ok(()) + } + + async fn reset(&self) -> Result { + Ok(SecretReset::AlreadyAbsent(PathBuf::from("fixture"))) + } + + fn path(&self) -> Option<&Path> { + Some(Path::new("fixture")) + } + + fn can_provision(&self) -> bool { + true + } + } + + struct FakeGateway { + run_data: Mutex>>, + } + + #[async_trait] + impl PlayitGateway for FakeGateway { + async fn register( + &self, + _request: RegisterRequest, + ) -> Result { + unreachable!() + } + + async fn control_addresses(&self) -> Result, GatewayError> { + unreachable!() + } + + async fn run_data(&self, _context: RunDataContext) -> Result { + self.run_data + .lock() + .unwrap() + .pop_front() + .expect("fixture run-data response") + } + + async fn account_login_url(&self) -> Result { + Ok("https://example.invalid/login".to_owned()) + } + } + + struct FakeGatewayFactory(Arc); + + impl GatewayFactory for FakeGatewayFactory { + fn create(&self, _api_base: &str, _secret: &str) -> Arc { + self.0.clone() + } + } + + struct HangingGateway; + + #[async_trait] + impl PlayitGateway for HangingGateway { + async fn register( + &self, + _request: RegisterRequest, + ) -> Result { + std::future::pending().await + } + + async fn control_addresses(&self) -> Result, GatewayError> { + std::future::pending().await + } + + async fn run_data(&self, _context: RunDataContext) -> Result { + std::future::pending().await + } + + async fn account_login_url(&self) -> Result { + std::future::pending().await + } + } + + struct HangingGatewayFactory; + + impl GatewayFactory for HangingGatewayFactory { + fn create(&self, _api_base: &str, _secret: &str) -> Arc { + Arc::new(HangingGateway) + } + } + + struct AcceptedClaimGateway; + + #[async_trait] + impl ClaimGateway for AcceptedClaimGateway { + async fn progress( + &self, + _code: &str, + _mode: ClaimMode, + _version: &str, + ) -> Result { + Ok(ClaimProgress::Approved) + } + + async fn exchange(&self, _code: &str) -> Result { + Ok(ClaimExchange::Accepted("00".to_owned())) + } + } + + struct ImmediateIpcExit; + + impl IpcPort for ImmediateIpcExit { + fn start(self: Box) -> ServiceChild { + ServiceChild::new( + CancellationToken::new(), + tokio::spawn(async { ServiceExit::Failed("fixture IPC exit".to_owned()) }), + ) + } + } + + struct CancelledIpc; + + impl IpcPort for CancelledIpc { + fn start(self: Box) -> ServiceChild { + let cancel = CancellationToken::new(); + let child_cancel = cancel.clone(); + ServiceChild::new( + cancel, + tokio::spawn(async move { + child_cancel.cancelled().await; + ServiceExit::Cancelled + }), + ) + } + } + + fn test_supervisor(policy: SupervisorPolicy) -> (AppSupervisor, SupervisorHandle) { + let config = SupervisorConfig { + api_base: "https://example.invalid".to_owned(), + version: "1.0.10".to_owned(), + start_time: 0, + service: ServiceInfo::default(), + policy, + }; + let (supervisor, handle, _) = AppSupervisor::new( + config, + Arc::new(MissingSecrets), + Arc::new(UnusedEngine), + Arc::new(SystemClock), + ); + (supervisor, handle) + } + + #[test] + fn retry_schedule_uses_policy_and_saturates() { + let policy = SupervisorPolicy::default(); + assert_eq!( + policy.retry_deadline(1_000, GatewayErrorCode::AgentDisabledOverLimit), + 31_000 + ); + assert_eq!( + policy.retry_deadline(1_000, GatewayErrorCode::Transport), + 4_000 + ); + assert_eq!( + policy.deadline_after(u64::MAX - 1, Duration::from_secs(1)), + u64::MAX + ); + } + + #[test] + fn clock_changes_create_deadlines_from_the_latest_reading() { + let policy = SupervisorPolicy::default(); + assert_eq!( + policy.retry_deadline(50_000, GatewayErrorCode::Transport), + 53_000 + ); + assert_eq!( + policy.retry_deadline(5_000, GatewayErrorCode::Transport), + 8_000 + ); + } + + #[test] + fn snapshot_publication_is_atomic_and_monotonic() { + let store = SnapshotStore::new(AppSnapshot::booting()); + store.publish(|snapshot| { + snapshot.phase = Phase::Starting { attempt: 1 }; + snapshot.traffic.bytes_in = 5; + }); + let snapshot = store.snapshot(); + assert_eq!(snapshot.revision, 1); + assert_eq!(snapshot.traffic.bytes_in, 5); + } + + #[test] + fn supervisor_future_can_be_owned_by_a_process_task() { + fn assert_send(_value: T) {} + + let (mut supervisor, _handle) = test_supervisor(SupervisorPolicy::default()); + supervisor.install_ipc(Box::new(CancelledIpc)); + assert_send(supervisor.run(CancellationToken::new())); + } + + #[tokio::test] + async fn typed_stop_command_drives_supervisor_shutdown() { + let (mut supervisor, handle) = test_supervisor(SupervisorPolicy::default()); + supervisor.install_ipc(Box::new(CancelledIpc)); + let command = async move { + tokio::task::yield_now().await; + handle.command(Command::Stop).await + }; + let (run_result, command_result) = + tokio::join!(supervisor.run(CancellationToken::new()), command); + assert_eq!(command_result, Ok(CommandOutput::Accepted)); + assert_eq!(run_result, Ok(())); + } + + #[tokio::test] + async fn stop_interrupts_an_in_flight_gateway_request() { + let policy = SupervisorPolicy { + gateway_timeout: Duration::from_secs(60), + ..SupervisorPolicy::default() + }; + let config = SupervisorConfig { + api_base: "https://example.invalid".to_owned(), + version: "1.0.10".to_owned(), + start_time: 0, + service: ServiceInfo::default(), + policy, + }; + let (mut supervisor, handle, _) = AppSupervisor::new_with_gateway_factory( + config, + Arc::new(ReadySecrets), + Arc::new(HoldingEngine), + Arc::new(SystemClock), + Arc::new(HangingGatewayFactory), + ); + supervisor.install_ipc(Box::new(CancelledIpc)); + + let command = async move { + tokio::task::yield_now().await; + handle.command(Command::Stop).await + }; + let result = tokio::time::timeout(Duration::from_secs(1), async { + tokio::join!(supervisor.run(CancellationToken::new()), command) + }) + .await + .expect("stop must cancel the gateway request without waiting for its timeout"); + + assert_eq!(result.0, Ok(())); + assert_eq!(result.1, Ok(CommandOutput::Accepted)); + } + + #[tokio::test] + async fn failed_secret_reset_preserves_the_running_snapshot() { + let (mut supervisor, _handle) = test_supervisor(SupervisorPolicy::default()); + supervisor.secrets = Arc::new(InlineSecretStore::new("00".to_owned())); + supervisor.snapshots.publish(|snapshot| { + snapshot.phase = Phase::Online { + agent: AgentIdentity::new("agent-1"), + }; + }); + let before = supervisor.snapshots.snapshot(); + let (response, result) = oneshot::channel(); + + supervisor + .process_command(CommandRequest { + command: Command::ResetSecret, + response, + }) + .await; + + assert_eq!( + result.await, + Ok(Err(CommandFailure::new(ProblemCode::SecretPinned))) + ); + assert_eq!(supervisor.snapshots.snapshot(), before); + assert!(!supervisor.stopping); + } + + #[tokio::test] + async fn typed_begin_claim_command_owns_setup_session_creation() { + let (mut supervisor, handle) = test_supervisor(SupervisorPolicy::default()); + supervisor.install_ipc(Box::new(CancelledIpc)); + let command = async move { + tokio::task::yield_now().await; + let output = handle.command(Command::BeginClaim).await.unwrap(); + let CommandOutput::ClaimStarted(session) = output else { + panic!("begin claim returned the wrong command output"); + }; + assert_eq!(session.code.len(), 10); + assert!(session.url.ends_with(&session.code)); + assert_eq!( + handle + .command(Command::PollClaim { + code: "not-the-active-code".to_owned(), + }) + .await, + Err(CommandFailure::new(ProblemCode::CommandNotAllowed) + .with_detail("claim session is not active")) + ); + handle.command(Command::Stop).await + }; + let (run_result, stop_result) = + tokio::join!(supervisor.run(CancellationToken::new()), command); + assert_eq!(stop_result, Ok(CommandOutput::Accepted)); + assert_eq!(run_result, Ok(())); + } + + #[tokio::test] + async fn accepted_daemon_claim_persists_without_returning_the_secret() { + let (mut supervisor, _handle) = test_supervisor(SupervisorPolicy::default()); + supervisor.snapshots.publish(|snapshot| { + snapshot.phase = Phase::NeedsSecret { + reason: playit_model::SecretNeed::Missing, + }; + }); + supervisor.claims = ClaimService::with_gateway( + Arc::new(AcceptedClaimGateway), + "fixture-version".to_owned(), + ); + supervisor.active_claim = Some(ClaimSession { + code: "active-code".to_owned(), + url: "https://playit.gg/claim/active-code".to_owned(), + }); + + let output = supervisor + .execute_command_effects( + Command::ExchangeClaim { + code: "active-code".to_owned(), + }, + vec![Effect::ExchangeClaim], + ) + .await; + + assert_eq!(output, Ok(CommandOutput::ClaimProvisioned)); + assert_eq!(supervisor.active_secret.as_deref(), Some("00")); + assert!(supervisor.active_claim.is_none()); + assert!(matches!( + supervisor.snapshots.snapshot().phase, + Phase::Starting { attempt: 1 } + )); + } + + #[tokio::test] + async fn unexpected_ipc_exit_reaches_the_supervisor() { + let (mut supervisor, _handle) = test_supervisor(SupervisorPolicy::default()); + supervisor.install_ipc(Box::new(ImmediateIpcExit)); + + let result = supervisor.run(CancellationToken::new()).await; + assert_eq!( + result, + Err(SupervisorError::IpcExited(ServiceExit::Failed( + "fixture IPC exit".to_owned() + ))) + ); + } + + #[tokio::test] + async fn unexpected_engine_child_exit_reaches_the_supervisor() { + let (mut supervisor, _handle) = test_supervisor(SupervisorPolicy::default()); + supervisor.install_ipc(Box::new(CancelledIpc)); + let cancel = CancellationToken::new(); + let expected = EngineExit::Service { + service: EngineService::Tcp, + exit: ServiceExit::Failed("fixture TCP child failure".to_owned()), + }; + let child_exit = expected.clone(); + supervisor.engine = Some(EngineChild::new( + cancel, + tokio::spawn(async move { child_exit }), + Arc::new(NoopOrigins), + Arc::new(NoopTraffic), + )); + + assert_eq!( + supervisor.run(CancellationToken::new()).await, + Err(SupervisorError::EngineExited(expected)) + ); + } + + #[tokio::test] + async fn shutdown_waits_for_engine_before_stopping_ipc() { + let events = Arc::new(Mutex::new(Vec::new())); + let (mut supervisor, _handle) = test_supervisor(SupervisorPolicy::default()); + supervisor + .snapshots + .publish(|snapshot| snapshot.phase = Phase::Stopping); + + let engine_cancel = CancellationToken::new(); + let engine_wait = engine_cancel.clone(); + let engine_events = events.clone(); + let engine_exit = tokio::spawn(async move { + engine_wait.cancelled().await; + engine_events.lock().unwrap().push("engine"); + EngineExit::Cancelled + }); + supervisor.engine = Some(EngineChild::new( + engine_cancel, + engine_exit, + Arc::new(NoopOrigins), + Arc::new(NoopTraffic), + )); + + let ipc_cancel = CancellationToken::new(); + let ipc_wait = ipc_cancel.clone(); + let ipc_events = events.clone(); + let ipc_exit = tokio::spawn(async move { + ipc_wait.cancelled().await; + ipc_events.lock().unwrap().push("ipc"); + ServiceExit::Cancelled + }); + supervisor.ipc_child = Some(ServiceChild::new(ipc_cancel, ipc_exit)); + + assert_eq!(supervisor.shutdown_children().await, Ok(())); + assert_eq!(events.lock().unwrap().as_slice(), ["engine", "ipc"]); + } + + #[tokio::test] + async fn shutdown_deadline_reports_a_typed_timeout() { + let policy = SupervisorPolicy { + shutdown_deadline: Duration::from_millis(5), + ..SupervisorPolicy::default() + }; + let (mut supervisor, _handle) = test_supervisor(policy); + supervisor + .snapshots + .publish(|snapshot| snapshot.phase = Phase::Stopping); + let cancel = CancellationToken::new(); + let engine_dropped = Arc::new(AtomicBool::new(false)); + let tracker = DropTracker(engine_dropped.clone()); + supervisor.engine = Some(EngineChild::new( + cancel, + tokio::spawn(async move { + let _tracker = tracker; + std::future::pending().await + }), + Arc::new(NoopOrigins), + Arc::new(NoopTraffic), + )); + + assert_eq!( + supervisor.shutdown_children().await, + Err(SupervisorError::ShutdownTimedOut) + ); + assert!(engine_dropped.load(Ordering::Acquire)); + assert!(supervisor.engine.is_none()); + assert_eq!( + supervisor + .snapshots + .snapshot() + .notices + .last() + .unwrap() + .problem + .code, + ProblemCode::ShutdownTimedOut + ); + } + + #[tokio::test] + async fn refresh_failure_retains_the_accepted_catalog() { + let catalog = TunnelCatalog::try_new(7, 100, Vec::new()).unwrap(); + let gateway = Arc::new(FakeGateway { + run_data: Mutex::new(VecDeque::from([ + Ok(GatewayRunData { + agent_id: "agent-1".to_owned(), + origins: Vec::new(), + catalog: catalog.clone(), + account_status: AccountStatus::Verified, + pending: Vec::new(), + notices: Vec::new(), + login_url: None, + }), + Err(GatewayError::new( + GatewayErrorCode::Transport, + "run_data", + "fixture failure", + )), + ])), + }); + let config = SupervisorConfig { + api_base: "https://example.invalid".to_owned(), + version: "1.0.10".to_owned(), + start_time: 0, + service: ServiceInfo::default(), + policy: SupervisorPolicy::default(), + }; + let (mut supervisor, handle, snapshots) = AppSupervisor::new_with_gateway_factory( + config, + Arc::new(ReadySecrets), + Arc::new(HoldingEngine), + Arc::new(SystemClock), + Arc::new(FakeGatewayFactory(gateway)), + ); + supervisor.install_ipc(Box::new(CancelledIpc)); + let commands = async move { + tokio::task::yield_now().await; + assert_eq!( + handle.command(Command::RefreshCatalog).await, + Ok(CommandOutput::Accepted) + ); + assert_eq!(snapshots.snapshot().catalog.accepted.revision(), 7); + assert_eq!( + snapshots + .snapshot() + .catalog + .last_problem + .as_ref() + .unwrap() + .code, + ProblemCode::CatalogUnavailable + ); + handle.command(Command::Stop).await + }; + let (run_result, stop_result) = + tokio::join!(supervisor.run(CancellationToken::new()), commands); + assert_eq!(stop_result, Ok(CommandOutput::Accepted)); + assert_eq!(run_result, Ok(())); + } + + struct NoopOrigins; + + #[async_trait] + impl OriginPublisher for NoopOrigins { + async fn replace(&self, _origins: &[GatewayOrigin]) {} + } + + struct NoopTraffic; + + impl TrafficSource for NoopTraffic { + fn snapshot(&self) -> TrafficSnapshot { + TrafficSnapshot::default() + } + } +} diff --git a/packages/playitd-tray/Cargo.toml b/packages/playitd-tray/Cargo.toml index f7d92e98..cb4eb440 100644 --- a/packages/playitd-tray/Cargo.toml +++ b/packages/playitd-tray/Cargo.toml @@ -9,17 +9,13 @@ repository = "https://github.com/playit-cloud/playit-agent" [dependencies] tokio = { workspace = true } kanal = "0.1.1" -playitd = { path = "../playitd", version = "1.0.10" } playit-ipc = { path = "../playit-ipc", version = "1.0.10" } +playit-platform = { path = "../playit-platform", version = "1.0.10" } +playit-runtime = { path = "../playit-runtime", version = "1.0.10", default-features = false } [target."cfg(windows)".dependencies] image = { version = "0.25", default-features = false, features = ["ico"] } tray-icon = "0.22" -windows = { version = "0.62.2", features = [ - "Win32_Foundation", - "Win32_System_Com", - "Win32_UI_Shell", -] } windows-sys = { version = "0.59.0", features = [ "Win32_Foundation", "Win32_Graphics_Gdi", diff --git a/packages/playitd-tray/src/windows/backend_actions.rs b/packages/playitd-tray/src/windows/backend_actions.rs index 997e2b3e..8ab5845c 100644 --- a/packages/playitd-tray/src/windows/backend_actions.rs +++ b/packages/playitd-tray/src/windows/backend_actions.rs @@ -2,16 +2,20 @@ use std::os::windows::process::CommandExt; use std::path::PathBuf; use std::process::Command; -use playit_ipc::ipc::{IpcClient, get_default_socket_path}; -use playit_ipc::model::AgentLifecycle; -use playitd::manager::{ - ensure_installed_service_running, installed_service_is_running, stop_installed_service, +use playit_ipc::ipc::{IpcClient, IpcError, get_default_socket_path}; +use playit_ipc::model::ServiceError; +use playit_platform::service::{ + ReachabilityPolicy, ServiceManagerKind, ServiceState, installed_service_state, + start_and_wait_until_reachable, stop_installed_service, +}; +use playit_runtime::{ + GracefulStopAttempt, InstalledServiceStopOutcome, InstalledServiceStopPolicy, + stop_installed_service_with_fallback, }; use tokio::task; use windows_sys::Win32::System::Threading::CREATE_NEW_CONSOLE; use super::protocol::{BackendRequest, BackendRequestKind, BackendResponse, ServiceStateSnapshot}; -use super::startup_shortcut; use super::util::debug_log; pub(super) async fn handle_request(request: BackendRequest) -> Option { @@ -91,9 +95,13 @@ async fn start_service_async() -> Result<(), String> { debug_log("start_service: ensuring installed service is running"); - let result = ensure_installed_service_running() - .await - .map_err(|error| format!("The background service did not start: {error}")); + let result = start_and_wait_until_reachable( + ServiceManagerKind::WindowsScm, + ReachabilityPolicy::default(), + || IpcClient::is_running(get_default_socket_path()), + ) + .await + .map_err(|error| format!("The background service did not start: {error}")); if result.is_ok() { debug_log("start_service: service started"); @@ -104,80 +112,70 @@ async fn start_service_async() -> Result<(), String> { async fn stop_service_async() -> Result<(), String> { debug_log("stop_service: begin"); - if !query_service_running_async().await { - debug_log("stop_service: service is already stopped"); - return Ok(()); - } - - debug_log("stop_service: attempting graceful IPC stop"); - - match IpcClient::connect().await { - Ok(mut client) => match client.stop().await { - Ok(response) if response.accepted => { - debug_log("stop_service: IPC stop request accepted"); - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - } - Ok(response) => { - debug_log(&format!( - "stop_service: IPC stop request rejected: {}", - response - .message - .unwrap_or_else(|| "service rejected stop request".to_string()) - )); - } - Err(error) => { - debug_log(&format!("stop_service: IPC stop request failed: {error}")); + let report = stop_installed_service_with_fallback( + InstalledServiceStopPolicy::default(), + query_service_running_result_async, + || async { + match IpcClient::connect().await { + Ok(mut client) => match client.stop().await { + Ok(response) if response.accepted => GracefulStopAttempt::Accepted, + Ok(response) => GracefulStopAttempt::Rejected( + response + .message + .unwrap_or_else(|| "service rejected stop request".to_owned()), + ), + Err(error) => GracefulStopAttempt::Failed(render_ipc_error(&error)), + }, + Err(error) => GracefulStopAttempt::Failed(error.to_string()), } }, - Err(error) => { - debug_log(&format!( - "stop_service: failed to connect to playitd over IPC: {error}" - )); - } - } + stop_installed_service_async, + ) + .await; - match stop_installed_service_async().await { - Ok(()) => debug_log("stop_service: direct SCM stop request completed"), - Err(error) => { - debug_log(&format!( - "stop_service: direct SCM stop request failed: {error}" - )); + if let Some(error) = report.initial_status_error.as_deref() { + debug_log(&format!( + "stop_service: initial service status failed: {error}" + )); + } + match report.graceful.as_ref() { + Some(GracefulStopAttempt::Accepted) => debug_log("stop_service: IPC stop request accepted"), + Some(GracefulStopAttempt::Rejected(error)) => { + debug_log(&format!("stop_service: IPC stop request rejected: {error}")) + } + Some(GracefulStopAttempt::Failed(error)) => { + debug_log(&format!("stop_service: IPC stop request failed: {error}")) } + None => {} } - - debug_log("stop_service: waiting before final running check"); - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - if IpcClient::is_running(get_default_socket_path()).await { - debug_log("stop_service: final running check says playitd is still reachable"); - Err("The playit service may still be running. Try again in a few seconds or stop it from Windows Services.".to_string()) - } else { - debug_log("stop_service: final running check says service stopped"); - Ok(()) + match report.fallback.as_ref() { + Some(Ok(())) => debug_log("stop_service: direct SCM stop request completed"), + Some(Err(error)) => debug_log(&format!( + "stop_service: direct SCM stop request failed: {error}" + )), + None => {} + } + match report.outcome { + InstalledServiceStopOutcome::AlreadyStopped => { + debug_log("stop_service: service is already stopped"); + Ok(()) + } + InstalledServiceStopOutcome::Stopped => { + debug_log("stop_service: final service status says stopped"); + Ok(()) + } + InstalledServiceStopOutcome::StillRunning => Err( + "The playit service may still be running. Try again in a few seconds or stop it from Windows Services." + .to_owned(), + ), + InstalledServiceStopOutcome::StatusUnknown(error) => Err(format!( + "The playit service stop was requested, but its status could not be checked: {error}" + )), } } async fn reset_agent_async() -> Result<(), String> { debug_log("reset_agent: begin"); - if !query_service_running_async().await { - debug_log("reset_agent: service is not running"); - return Err( - "Reset Agent Setup is unavailable because the playit service is not running." - .to_string(), - ); - } - - let lifecycle = query_service_lifecycle_async().await; - debug_log(&format!( - "reset_agent: lifecycle query result = {lifecycle:?}" - )); - if matches!(lifecycle, Ok(AgentLifecycle::WaitingForSecret)) { - debug_log("reset_agent: lifecycle already WaitingForSecret"); - return Err( - "The playit service is already waiting for setup. Open playit to finish setup." - .to_string(), - ); - } - debug_log("reset_agent: connecting to playitd over IPC"); let mut client = IpcClient::connect().await.map_err(|error| { debug_log(&format!("reset_agent: failed to connect over IPC: {error}")); @@ -189,7 +187,10 @@ async fn reset_agent_async() -> Result<(), String> { debug_log(&format!( "reset_agent: reset_secret IPC call failed: {error}" )); - format!("Could not reset the agent setup: {error}") + format!( + "Could not reset the agent setup: {}", + render_ipc_error(&error) + ) })?; debug_log(&format!( @@ -204,8 +205,13 @@ async fn reset_agent_async() -> Result<(), String> { .unwrap_or_else(|| "The playit service rejected the reset request.".to_string())); } - debug_log("reset_agent: secret reset accepted, stopping service"); - stop_service_async().await?; + debug_log("reset_agent: secret reset accepted; supervisor is stopping"); + for _ in 0..20 { + if !IpcClient::is_running(get_default_socket_path()).await { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } debug_log("reset_agent: launching playit after stop"); launch_playit() } @@ -219,79 +225,63 @@ async fn query_service_state_snapshot_async() -> ServiceStateSnapshot { ServiceStateSnapshot { service_running, - reset_agent_enabled: query_reset_agent_enabled_async(service_running).await, + reset_agent_enabled: service_running, } } -async fn query_reset_agent_enabled_async(service_running: bool) -> bool { - if !service_running { - debug_log("snapshot: reset agent disabled because service is not running"); - return false; +fn render_ipc_error(error: &IpcError) -> String { + match error { + IpcError::Service(problem) => render_service_problem(problem), + _ => error.to_string(), } +} - match query_service_lifecycle_async().await { - Ok(AgentLifecycle::WaitingForSecret) => { - debug_log("snapshot: reset agent disabled because lifecycle is WaitingForSecret"); - false - } - Ok(AgentLifecycle::Stopping) => { - debug_log("snapshot: reset agent disabled because lifecycle is Stopping"); - false - } - Ok(other) => { - debug_log(&format!( - "snapshot: reset agent enabled because lifecycle is {other:?}" - )); - true - } - Err(error) => { - debug_log(&format!( - "snapshot: lifecycle query failed while checking reset availability: {error}" - )); - true +fn render_service_problem(problem: &ServiceError) -> String { + match problem.meaning().code.as_str() { + "invalid_secret" => "The configured secret is invalid.".to_owned(), + "secret_pinned" => "Reset is unavailable while playitd uses an inline --secret.".to_owned(), + "secret_write_failed" => { + format!( + "The playit service could not update its secret: {}", + problem.message + ) } + "command_not_allowed" => "Reset is not available in the current service state.".to_owned(), + _ => problem.message.clone(), } } -async fn query_service_lifecycle_async() -> Result { - debug_log("lifecycle: connecting to playitd over IPC"); - let mut client = IpcClient::connect().await.map_err(|error| { - debug_log(&format!("lifecycle: failed to connect over IPC: {error}")); - format!("Could not connect to the playit service: {error}") - })?; - - let lifecycle = client.lifecycle().await.map_err(|error| { - debug_log(&format!( - "lifecycle: failed to read lifecycle over IPC: {error}" - )); - format!("Could not read the playit service status: {error}") - })?; - - debug_log(&format!("lifecycle: received {lifecycle:?}")); - Ok(lifecycle) -} - pub(super) fn remove_startup_shortcut() -> Result<(), String> { - startup_shortcut::remove_startup_shortcut() + playit_platform::windows::remove_tray_startup_shortcut() } pub(super) fn ensure_startup_shortcut() -> Result<(), String> { - startup_shortcut::ensure_startup_shortcut() + let tray_path = std::env::current_exe() + .map_err(|error| format!("Failed to resolve playitd-tray.exe path: {error}"))?; + playit_platform::windows::ensure_tray_startup_shortcut(&tray_path).map(|_| ()) } pub(super) fn startup_shortcut_exists() -> Result { - startup_shortcut::startup_shortcut_exists() + playit_platform::windows::tray_startup_shortcut_exists() } pub(super) fn query_service_running_sync() -> bool { - match installed_service_is_running() { - Ok(running) => { + match query_service_running_result_sync() { + Ok(running) => running, + Err(_) => false, + } +} + +fn query_service_running_result_sync() -> Result { + match installed_service_state(ServiceManagerKind::WindowsScm) { + Ok(state) => { + let running = state == ServiceState::Running; debug_log(&format!("service_query: playitd running={running}")); - running + Ok(running) } Err(error) => { debug_log(&format!("service_query: failed to query playitd: {error}")); - false + Err(error.to_string()) } } } @@ -313,9 +303,15 @@ async fn query_service_running_async() -> bool { } } +async fn query_service_running_result_async() -> Result { + task::spawn_blocking(query_service_running_result_sync) + .await + .map_err(|error| format!("Failed to join service status task: {error}"))? +} + async fn stop_installed_service_async() -> Result<(), String> { debug_log("stop_service: invoking direct SCM stop fallback"); - task::spawn_blocking(stop_installed_service) + task::spawn_blocking(|| stop_installed_service(ServiceManagerKind::WindowsScm)) .await .map_err(|error| format!("Failed to join installed service stop task: {error}"))? .map_err(|error| error.to_string()) diff --git a/packages/playitd-tray/src/windows/mod.rs b/packages/playitd-tray/src/windows/mod.rs index 9732afd8..b80d3f0f 100644 --- a/packages/playitd-tray/src/windows/mod.rs +++ b/packages/playitd-tray/src/windows/mod.rs @@ -2,7 +2,6 @@ mod backend; mod backend_actions; mod frontend; mod protocol; -mod startup_shortcut; mod state; mod util; diff --git a/packages/playitd-tray/src/windows/startup_shortcut.rs b/packages/playitd-tray/src/windows/startup_shortcut.rs deleted file mode 100644 index addf4580..00000000 --- a/packages/playitd-tray/src/windows/startup_shortcut.rs +++ /dev/null @@ -1,194 +0,0 @@ -use std::fs; -use std::os::windows::ffi::OsStrExt; -use std::path::{Path, PathBuf}; - -use windows::Win32::Foundation::{RPC_E_CHANGED_MODE, S_FALSE, S_OK}; -use windows::Win32::System::Com::{ - CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, CoCreateInstance, CoInitializeEx, - CoTaskMemFree, CoUninitialize, IPersistFile, -}; -use windows::Win32::UI::Shell::{ - FOLDERID_Startup, IShellLinkW, KF_FLAG_DEFAULT, SHGetKnownFolderPath, ShellLink, -}; -use windows::core::{GUID, Interface, PCWSTR}; - -use super::util::debug_log; - -const TRAY_SHORTCUT_NAME: &str = "Playit Tray.lnk"; -const TRAY_SHORTCUT_DESCRIPTION: &str = - "Shows the Playit tray icon when the background service is running."; - -struct ComInitialization { - should_uninitialize: bool, -} - -impl Drop for ComInitialization { - fn drop(&mut self) { - if self.should_uninitialize { - unsafe { - CoUninitialize(); - } - } - } -} - -pub(super) fn remove_startup_shortcut() -> Result<(), String> { - let shortcut_path = startup_shortcut_path()?; - - if !shortcut_path.exists() { - debug_log("remove_startup_shortcut: shortcut does not exist"); - return Ok(()); - } - - debug_log(&format!( - "remove_startup_shortcut: deleting {}", - shortcut_path.display() - )); - fs::remove_file(&shortcut_path).map_err(|error| { - format!( - "Failed to delete startup shortcut at {}: {error}", - shortcut_path.display() - ) - }) -} - -pub(super) fn ensure_startup_shortcut() -> Result<(), String> { - let shortcut_path = startup_shortcut_path()?; - let tray_path = std::env::current_exe() - .map_err(|error| format!("Failed to resolve playitd-tray.exe path: {error}"))?; - let working_directory = tray_path.parent().ok_or_else(|| { - format!( - "Failed to resolve the working directory for {}", - tray_path.display() - ) - })?; - - if let Some(parent) = shortcut_path.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!( - "Failed to create the current user's Startup folder at {}: {error}", - parent.display() - ) - })?; - } - - debug_log(&format!( - "ensure_startup_shortcut: writing {} -> {}", - shortcut_path.display(), - tray_path.display() - )); - - let _com = initialize_com()?; - create_startup_shortcut(&tray_path, working_directory, &shortcut_path) -} - -pub(super) fn startup_shortcut_exists() -> Result { - Ok(startup_shortcut_path()?.exists()) -} - -fn startup_shortcut_path() -> Result { - known_folder_shortcut_path(&FOLDERID_Startup, "the current user's Startup folder") -} - -fn known_folder_shortcut_path(folder_id: &GUID, folder_name: &str) -> Result { - unsafe { - let wide_path = SHGetKnownFolderPath(folder_id, KF_FLAG_DEFAULT, None) - .map_err(|error| format!("Failed to resolve {folder_name}: {error}"))?; - - if wide_path.is_null() { - return Err(format!("{folder_name} path was empty")); - } - - let path = wide_path - .to_string() - .map_err(|error| format!("Failed to read {folder_name} path: {error}"))?; - CoTaskMemFree(Some(wide_path.0.cast())); - - Ok(PathBuf::from(path).join(TRAY_SHORTCUT_NAME)) - } -} - -fn create_startup_shortcut( - tray_path: &Path, - working_directory: &Path, - shortcut_path: &Path, -) -> Result<(), String> { - unsafe { - let shell_link: IShellLinkW = CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER) - .map_err(|error| format!("Failed to create the ShellLink COM object: {error}"))?; - - shell_link - .SetPath(path_pcwstr(tray_path)?.as_pcwstr()) - .map_err(|error| { - format!( - "Failed to set the tray shortcut target to {}: {error}", - tray_path.display() - ) - })?; - shell_link - .SetWorkingDirectory(path_pcwstr(working_directory)?.as_pcwstr()) - .map_err(|error| { - format!( - "Failed to set the tray shortcut working directory to {}: {error}", - working_directory.display() - ) - })?; - shell_link - .SetDescription(wide(TRAY_SHORTCUT_DESCRIPTION).as_pcwstr()) - .map_err(|error| format!("Failed to set the tray shortcut description: {error}"))?; - - let persist_file: IPersistFile = shell_link.cast().map_err(|error| { - format!("Failed to query the tray shortcut persistence interface: {error}") - })?; - persist_file - .Save(path_pcwstr(shortcut_path)?.as_pcwstr(), true) - .map_err(|error| { - format!( - "Failed to save the startup shortcut at {}: {error}", - shortcut_path.display() - ) - }) - } -} - -fn initialize_com() -> Result { - unsafe { - let result = CoInitializeEx(None, COINIT_APARTMENTTHREADED); - if result == S_OK || result == S_FALSE { - return Ok(ComInitialization { - should_uninitialize: true, - }); - } - if result == RPC_E_CHANGED_MODE { - return Ok(ComInitialization { - should_uninitialize: false, - }); - } - - Err(format!( - "Failed to initialize COM for the tray shortcut helper (HRESULT {:#x})", - result.0 - )) - } -} - -struct WideString(Vec); - -impl WideString { - fn as_pcwstr(&self) -> PCWSTR { - PCWSTR(self.0.as_ptr()) - } -} - -fn path_pcwstr(path: &Path) -> Result { - Ok(WideString( - path.as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect(), - )) -} - -fn wide(value: &str) -> WideString { - WideString(value.encode_utf16().chain(std::iter::once(0)).collect()) -} diff --git a/packages/playitd-windows-setup/Cargo.toml b/packages/playitd-windows-setup/Cargo.toml index 3bc39d96..7b55b982 100644 --- a/packages/playitd-windows-setup/Cargo.toml +++ b/packages/playitd-windows-setup/Cargo.toml @@ -7,13 +7,4 @@ description = "Windows installer provisioning helper for playit" repository = "https://github.com/playit-cloud/playit-agent" [dependencies] -playitd = { path = "../playitd", version = "1.0.10" } - -[target."cfg(windows)".dependencies] -windows = { version = "0.62.2", features = [ - "Win32_Foundation", - "Win32_System_Com", - "Win32_System_Environment", - "Win32_System_Registry", - "Win32_UI_Shell", -] } +playit-platform = { path = "../playit-platform", version = "1.0.10" } diff --git a/packages/playitd-windows-setup/src/main.rs b/packages/playitd-windows-setup/src/main.rs index e22e6911..1eab5842 100644 --- a/packages/playitd-windows-setup/src/main.rs +++ b/packages/playitd-windows-setup/src/main.rs @@ -1,14 +1,5 @@ #![cfg_attr(target_os = "windows", windows_subsystem = "windows")] -#[cfg(target_os = "windows")] -mod permissions; -#[cfg(any(target_os = "windows", test))] -mod secret_migration; -#[cfg(any(target_os = "windows", test))] -mod sid; -#[cfg(target_os = "windows")] -mod startup_shortcut; - #[cfg(any(target_os = "windows", test))] mod setup_log; @@ -47,24 +38,28 @@ fn run_and_log() -> Result<(), String> { Err(unexpected_extra_arguments(&extra_args)) } else { let installed_user_sid = extra_args.first().map(|arg| arg.to_string_lossy()); - permissions::apply_installer_permissions(installed_user_sid.as_deref()) + playit_platform::windows::apply_installer_permissions(installed_user_sid.as_deref()) } } - "ensure-startup-shortcut" => require_no_extra_arguments(&extra_args) - .and_then(|()| startup_shortcut::ensure_startup_shortcut()), + "ensure-startup-shortcut" => require_no_extra_arguments(&extra_args).and_then(|()| { + let setup_path = std::env::current_exe() + .map_err(|error| format!("Failed to resolve setup helper path: {error}"))?; + let tray_path = setup_path.with_file_name("playitd-tray.exe"); + playit_platform::windows::ensure_tray_startup_shortcut(&tray_path).map(|_| ()) + }), "migrate-v17-secret" => { if extra_args.len() > 1 { Err(unexpected_extra_arguments(&extra_args)) } else { let installed_user_sid = extra_args.first().map(|arg| arg.to_string_lossy()); - secret_migration::migrate_v17_secret(installed_user_sid.as_deref()) + playit_platform::windows::migrate_v17_secret(installed_user_sid.as_deref()) } } "remove-startup-shortcut" => require_no_extra_arguments(&extra_args) - .and_then(|()| startup_shortcut::remove_startup_shortcut()), + .and_then(|()| playit_platform::windows::remove_tray_startup_shortcut()), "write-installed-user-sid" => { require_no_extra_arguments(&extra_args)?; - playitd::windows::write_current_user_sid() + playit_platform::windows::write_current_user_sid() .map_err(|error| format!("Failed to write installed user SID: {error}"))?; Ok(()) } diff --git a/packages/playitd-windows-setup/src/permissions.rs b/packages/playitd-windows-setup/src/permissions.rs deleted file mode 100644 index 1b225876..00000000 --- a/packages/playitd-windows-setup/src/permissions.rs +++ /dev/null @@ -1,218 +0,0 @@ -use std::ffi::OsString; -use std::fs; -use std::os::windows::process::CommandExt; -use std::process::{Command, Output}; - -use crate::sid::normalize_sid; -use playitd::manager::INSTALLED_SERVICE_LABEL; - -const CREATE_NO_WINDOW: u32 = 0x08000000; -#[cfg(test)] -const AUTHENTICATED_USERS_SDDL_ALIAS: &str = "AU"; -const AUTHENTICATED_USERS_ICACLS_SID: &str = "*S-1-5-11"; -const SERVICE_ACCESS_ACE: &str = "(A;;LCRPWPLO;;;AU)"; - -pub(crate) fn apply_installer_permissions(installed_user_sid: Option<&str>) -> Result<(), String> { - let mut errors = Vec::new(); - - if let Err(error) = grant_log_folder_permissions() { - errors.push(error); - } - - if let Err(error) = grant_service_permissions() { - errors.push(error); - } - - if let Err(error) = write_installed_user_sid(installed_user_sid) { - errors.push(error); - } - - if errors.is_empty() { - Ok(()) - } else { - Err(errors.join("; ")) - } -} - -fn write_installed_user_sid(installed_user_sid: Option<&str>) -> Result<(), String> { - let installed_user_sid = installed_user_sid - .map(str::trim) - .filter(|sid| !sid.is_empty()) - .ok_or_else(|| "MSI did not provide the installing user's SID".to_string())?; - let installed_user_sid = normalize_sid(installed_user_sid).ok_or_else(|| { - format!("MSI provided an invalid installing user SID: {installed_user_sid}") - })?; - - let path = playitd::windows::installed_user_sid_path(); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!( - "Failed to create installed user SID directory at {}: {error}", - parent.display() - ) - })?; - } - - fs::write(&path, format!("{installed_user_sid}\n")).map_err(|error| { - format!( - "Failed to write installed user SID to {}: {error}", - path.display() - ) - }) -} - -fn grant_log_folder_permissions() -> Result<(), String> { - let log_dir = playitd::windows_service_log_path() - .parent() - .ok_or_else(|| "Failed to resolve playit service log directory".to_string())? - .to_path_buf(); - - fs::create_dir_all(&log_dir).map_err(|error| { - format!( - "Failed to create playit service log directory at {}: {error}", - log_dir.display() - ) - })?; - - run_command( - "icacls.exe", - vec![ - log_dir.as_os_str().to_os_string(), - OsString::from("/grant"), - OsString::from(format!("{AUTHENTICATED_USERS_ICACLS_SID}:(OI)(CI)M")), - ], - ) - .map_err(|error| { - format!( - "Failed to grant Authenticated Users modify access to {}: {error}", - log_dir.display() - ) - }) -} - -fn grant_service_permissions() -> Result<(), String> { - let current_sddl = service_security_descriptor()?; - let updated_sddl = add_service_access_ace(¤t_sddl)?; - - if updated_sddl == current_sddl { - return Ok(()); - } - - run_command( - "sc.exe", - vec![ - OsString::from("sdset"), - OsString::from(INSTALLED_SERVICE_LABEL), - OsString::from(updated_sddl), - ], - ) - .map_err(|error| { - format!( - "Failed to grant Authenticated Users access to the {INSTALLED_SERVICE_LABEL} service: {error}" - ) - }) -} - -fn service_security_descriptor() -> Result { - let output = run_command_with_output( - "sc.exe", - vec![ - OsString::from("sdshow"), - OsString::from(INSTALLED_SERVICE_LABEL), - ], - ) - .map_err(|error| { - format!("Failed to read the {INSTALLED_SERVICE_LABEL} service security descriptor: {error}") - })?; - - String::from_utf8_lossy(&output.stdout) - .lines() - .map(str::trim) - .find(|line| line.starts_with("D:")) - .map(str::to_string) - .ok_or_else(|| { - format!( - "Failed to find a DACL in sc.exe sdshow output: {}", - command_output_text(&output) - ) - }) -} - -fn add_service_access_ace(sddl: &str) -> Result { - if sddl.contains(SERVICE_ACCESS_ACE) { - return Ok(sddl.to_string()); - } - - if !sddl.starts_with("D:") { - return Err(format!("Service security descriptor has no DACL: {sddl}")); - } - - let insert_at = sddl.find("S:").unwrap_or(sddl.len()); - let mut updated = String::with_capacity(sddl.len() + SERVICE_ACCESS_ACE.len()); - updated.push_str(&sddl[..insert_at]); - updated.push_str(SERVICE_ACCESS_ACE); - updated.push_str(&sddl[insert_at..]); - Ok(updated) -} - -fn run_command(program: &str, args: Vec) -> Result<(), String> { - let output = run_command_with_output(program, args)?; - if output.status.success() { - Ok(()) - } else { - Err(command_output_text(&output)) - } -} - -fn run_command_with_output(program: &str, args: Vec) -> Result { - Command::new(program) - .args(args) - .creation_flags(CREATE_NO_WINDOW) - .output() - .map_err(|error| format!("Failed to run {program}: {error}")) -} - -fn command_output_text(output: &Output) -> String { - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - format!( - "exit_status={} stdout=\"{}\" stderr=\"{}\"", - output.status, - stdout.trim(), - stderr.trim() - ) -} - -#[cfg(test)] -mod tests { - use super::{AUTHENTICATED_USERS_SDDL_ALIAS, SERVICE_ACCESS_ACE, add_service_access_ace}; - - #[test] - fn service_ace_uses_authenticated_users_sddl_alias() { - assert!(SERVICE_ACCESS_ACE.ends_with(&format!(";;;{})", AUTHENTICATED_USERS_SDDL_ALIAS))); - } - - #[test] - fn add_service_access_ace_appends_to_dacl_before_sacl() { - let sddl = "D:(A;;LCRP;;;SY)S:(AU;FA;LCRP;;;WD)"; - - let updated = add_service_access_ace(sddl).unwrap(); - - assert_eq!( - updated, - "D:(A;;LCRP;;;SY)(A;;LCRPWPLO;;;AU)S:(AU;FA;LCRP;;;WD)" - ); - } - - #[test] - fn add_service_access_ace_is_idempotent() { - let sddl = "D:(A;;LCRP;;;SY)(A;;LCRPWPLO;;;AU)"; - - assert_eq!(add_service_access_ace(sddl).unwrap(), sddl); - } - - #[test] - fn add_service_access_ace_rejects_missing_dacl() { - assert!(add_service_access_ace("S:(AU;FA;LCRP;;;WD)").is_err()); - } -} diff --git a/packages/playitd-windows-setup/src/secret_migration.rs b/packages/playitd-windows-setup/src/secret_migration.rs deleted file mode 100644 index 568cb67a..00000000 --- a/packages/playitd-windows-setup/src/secret_migration.rs +++ /dev/null @@ -1,382 +0,0 @@ -use std::fs; -use std::io::Write; -use std::path::{Path, PathBuf}; - -#[cfg(target_os = "windows")] -use crate::sid::normalize_sid; - -const PROFILE_LIST_REGISTRY_KEY: &str = r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList"; -#[cfg(target_os = "windows")] -const PROFILE_IMAGE_PATH_VALUE: &str = "ProfileImagePath"; - -#[cfg(target_os = "windows")] -pub(crate) fn migrate_v17_secret(installed_user_sid: Option<&str>) -> Result<(), String> { - let installed_user_sid = installed_user_sid - .map(str::trim) - .filter(|sid| !sid.is_empty()) - .ok_or_else(|| "MSI did not provide the installing user's SID".to_string())?; - let installed_user_sid = normalize_sid(installed_user_sid).ok_or_else(|| { - format!("MSI provided an invalid installing user SID: {installed_user_sid}") - })?; - - let new_path = playitd::windows_service_secret_path(); - if new_path.exists() { - return Ok(()); - } - - let Some(profile_dir) = profile_dir_for_sid(installed_user_sid)? else { - return Ok(()); - }; - - migrate_v17_secret_from_profile(&new_path, &profile_dir).map(|_| ()) -} - -#[derive(Debug, PartialEq, Eq)] -enum MigrationStatus { - AlreadyConfigured, - MissingLegacy, - Copied, -} - -fn migrate_v17_secret_from_profile( - new_path: &Path, - profile_dir: &Path, -) -> Result { - if new_path.exists() { - return Ok(MigrationStatus::AlreadyConfigured); - } - - let old_path = v17_secret_path_from_profile(profile_dir); - if !old_path.exists() { - return Ok(MigrationStatus::MissingLegacy); - } - - if let Some(parent) = new_path.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!( - "Failed to create playit service config directory at {}: {error}", - parent.display() - ) - })?; - } - - if new_path.exists() { - return Ok(MigrationStatus::AlreadyConfigured); - } - - let content = fs::read(&old_path).map_err(|error| { - format!( - "Failed to read legacy playit config at {}: {error}", - old_path.display() - ) - })?; - - let mut new_file = match fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(new_path) - { - Ok(file) => file, - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { - return Ok(MigrationStatus::AlreadyConfigured); - } - Err(error) => { - return Err(format!( - "Failed to create migrated playit config at {}: {error}", - new_path.display() - )); - } - }; - - new_file.write_all(&content).map_err(|error| { - format!( - "Failed to write migrated playit config to {}: {error}", - new_path.display() - ) - })?; - - Ok(MigrationStatus::Copied) -} - -fn v17_secret_path_from_profile(profile_dir: &Path) -> PathBuf { - profile_dir - .join("AppData") - .join("Local") - .join("playit_gg") - .join("playit.toml") -} - -fn profile_registry_subkey(sid: &str) -> String { - format!("{PROFILE_LIST_REGISTRY_KEY}\\{sid}") -} - -#[cfg(test)] -fn expand_percent_variables_with(value: &str, lookup: F) -> String -where - F: Fn(&str) -> Option, -{ - let mut expanded = String::with_capacity(value.len()); - let mut remaining = value; - - while let Some(start) = remaining.find('%') { - expanded.push_str(&remaining[..start]); - let after_start = &remaining[start + 1..]; - - let Some(end) = after_start.find('%') else { - expanded.push('%'); - expanded.push_str(after_start); - return expanded; - }; - - let key = &after_start[..end]; - if key.is_empty() { - expanded.push_str("%%"); - } else if let Some(replacement) = lookup(key) { - expanded.push_str(&replacement); - } else { - expanded.push('%'); - expanded.push_str(key); - expanded.push('%'); - } - - remaining = &after_start[end + 1..]; - } - - expanded.push_str(remaining); - expanded -} - -#[cfg(target_os = "windows")] -fn profile_dir_for_sid(sid: &str) -> Result, String> { - let Some(profile_image_path) = read_profile_image_path(sid)? else { - return Ok(None); - }; - - let expanded = expand_environment_strings(&profile_image_path)?; - Ok(Some(PathBuf::from(expanded))) -} - -#[cfg(target_os = "windows")] -fn read_profile_image_path(sid: &str) -> Result, String> { - use windows::Win32::Foundation::{ERROR_FILE_NOT_FOUND, ERROR_MORE_DATA, ERROR_SUCCESS}; - use windows::Win32::System::Registry::{ - HKEY_LOCAL_MACHINE, REG_VALUE_TYPE, RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegGetValueW, - }; - use windows::core::PCWSTR; - - let subkey = wide_null(&profile_registry_subkey(sid)); - let value = wide_null(PROFILE_IMAGE_PATH_VALUE); - let flags = RRF_RT_REG_EXPAND_SZ | RRF_RT_REG_SZ; - let mut value_type = REG_VALUE_TYPE::default(); - let mut byte_len = 0u32; - - let status = unsafe { - RegGetValueW( - HKEY_LOCAL_MACHINE, - PCWSTR(subkey.as_ptr()), - PCWSTR(value.as_ptr()), - flags, - Some(&mut value_type), - None, - Some(&mut byte_len), - ) - }; - - if status == ERROR_FILE_NOT_FOUND { - return Ok(None); - } - if status != ERROR_SUCCESS && status != ERROR_MORE_DATA { - return Err(format!( - "Failed to read {PROFILE_IMAGE_PATH_VALUE} size from HKLM\\{}: {}", - profile_registry_subkey(sid), - status.0 - )); - } - - let mut buffer = vec![0u16; byte_len.div_ceil(2) as usize]; - let status = unsafe { - RegGetValueW( - HKEY_LOCAL_MACHINE, - PCWSTR(subkey.as_ptr()), - PCWSTR(value.as_ptr()), - flags, - Some(&mut value_type), - Some(buffer.as_mut_ptr().cast()), - Some(&mut byte_len), - ) - }; - - if status == ERROR_FILE_NOT_FOUND { - return Ok(None); - } - if status != ERROR_SUCCESS { - return Err(format!( - "Failed to read {PROFILE_IMAGE_PATH_VALUE} from HKLM\\{}: {}", - profile_registry_subkey(sid), - status.0 - )); - } - - buffer.truncate(byte_len.div_ceil(2) as usize); - while buffer.last() == Some(&0) { - buffer.pop(); - } - - String::from_utf16(&buffer).map(Some).map_err(|error| { - format!( - "Failed to decode {PROFILE_IMAGE_PATH_VALUE} from HKLM\\{}: {error}", - profile_registry_subkey(sid) - ) - }) -} - -#[cfg(target_os = "windows")] -fn expand_environment_strings(value: &str) -> Result { - use windows::Win32::System::Environment::ExpandEnvironmentStringsW; - use windows::core::PCWSTR; - - let source = wide_null(value); - let needed = unsafe { ExpandEnvironmentStringsW(PCWSTR(source.as_ptr()), None) }; - if needed == 0 { - return Err(format!( - "Failed to expand environment variables in profile path {value}: {}", - std::io::Error::last_os_error() - )); - } - - let mut buffer = vec![0u16; needed as usize]; - let written = unsafe { ExpandEnvironmentStringsW(PCWSTR(source.as_ptr()), Some(&mut buffer)) }; - if written == 0 { - return Err(format!( - "Failed to expand environment variables in profile path {value}: {}", - std::io::Error::last_os_error() - )); - } - if written as usize > buffer.len() { - return Err(format!( - "Expanded profile path did not fit in allocated buffer for {value}" - )); - } - - buffer.truncate(written.saturating_sub(1) as usize); - String::from_utf16(&buffer) - .map_err(|error| format!("Failed to decode expanded profile path for {value}: {error}")) -} - -#[cfg(target_os = "windows")] -fn wide_null(value: &str) -> Vec { - value.encode_utf16().chain(std::iter::once(0)).collect() -} - -#[cfg(test)] -mod tests { - use super::{ - MigrationStatus, expand_percent_variables_with, migrate_v17_secret_from_profile, - profile_registry_subkey, v17_secret_path_from_profile, - }; - use std::fs; - use std::path::{Path, PathBuf}; - use std::time::{SystemTime, UNIX_EPOCH}; - - #[test] - fn profile_registry_subkey_uses_sid() { - assert_eq!( - profile_registry_subkey("S-1-5-21-1-2-3-1001"), - r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\S-1-5-21-1-2-3-1001" - ); - } - - #[test] - fn profile_path_expansion_handles_system_drive() { - let expanded = expand_percent_variables_with(r"%SystemDrive%\Users\Alice", |key| { - (key == "SystemDrive").then(|| "C:".to_string()) - }); - - assert_eq!(expanded, r"C:\Users\Alice"); - } - - #[test] - fn old_secret_path_uses_local_app_data() { - assert_eq!( - v17_secret_path_from_profile(Path::new(r"C:\Users\Alice")), - PathBuf::from(r"C:\Users\Alice") - .join("AppData") - .join("Local") - .join("playit_gg") - .join("playit.toml") - ); - } - - #[test] - fn migration_noops_when_new_secret_exists() { - let temp = TempDir::new("new-exists"); - let new_path = temp.path().join("program-data").join("playit.toml"); - let profile_dir = temp.path().join("profile"); - fs::create_dir_all(new_path.parent().unwrap()).unwrap(); - fs::write(&new_path, "new").unwrap(); - - let status = migrate_v17_secret_from_profile(&new_path, &profile_dir).unwrap(); - - assert_eq!(status, MigrationStatus::AlreadyConfigured); - assert_eq!(fs::read_to_string(&new_path).unwrap(), "new"); - } - - #[test] - fn migration_noops_when_old_secret_is_missing() { - let temp = TempDir::new("old-missing"); - let new_path = temp.path().join("program-data").join("playit.toml"); - let profile_dir = temp.path().join("profile"); - - let status = migrate_v17_secret_from_profile(&new_path, &profile_dir).unwrap(); - - assert_eq!(status, MigrationStatus::MissingLegacy); - assert!(!new_path.exists()); - } - - #[test] - fn migration_copies_old_secret_contents() { - let temp = TempDir::new("copies-old"); - let new_path = temp.path().join("program-data").join("playit.toml"); - let profile_dir = temp.path().join("profile"); - let old_path = v17_secret_path_from_profile(&profile_dir); - fs::create_dir_all(old_path.parent().unwrap()).unwrap(); - fs::write(&old_path, "secret_key = \"abc\"\n").unwrap(); - - let status = migrate_v17_secret_from_profile(&new_path, &profile_dir).unwrap(); - - assert_eq!(status, MigrationStatus::Copied); - assert_eq!( - fs::read_to_string(&new_path).unwrap(), - "secret_key = \"abc\"\n" - ); - } - - struct TempDir { - path: PathBuf, - } - - impl TempDir { - fn new(name: &str) -> Self { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let path = std::env::temp_dir().join(format!( - "playitd-windows-setup-{name}-{}-{nanos}", - std::process::id() - )); - fs::create_dir_all(&path).unwrap(); - Self { path } - } - - fn path(&self) -> &Path { - &self.path - } - } - - impl Drop for TempDir { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.path); - } - } -} diff --git a/packages/playitd-windows-setup/src/setup_log.rs b/packages/playitd-windows-setup/src/setup_log.rs index b8cc7791..d13a305d 100644 --- a/packages/playitd-windows-setup/src/setup_log.rs +++ b/packages/playitd-windows-setup/src/setup_log.rs @@ -1,6 +1,4 @@ #[cfg(target_os = "windows")] -use std::env; -#[cfg(target_os = "windows")] use std::fs::{self, OpenOptions}; #[cfg(target_os = "windows")] use std::io::{self, Write}; @@ -10,10 +8,12 @@ use std::path::PathBuf; #[cfg(target_os = "windows")] use std::time::{SystemTime, UNIX_EPOCH}; -const LOG_RELATIVE_PATH: &[&str] = &["playit_gg", "logs", "playit-installer.log"]; -#[cfg(target_os = "windows")] -const PROGRAMDATA_FALLBACK: &str = r"C:\ProgramData"; - +#[cfg(test)] +const LOG_RELATIVE_PATH: &[&str] = &[ + playit_platform::paths::WINDOWS_DATA_DIR_NAME, + "logs", + "playit-installer.log", +]; #[cfg(target_os = "windows")] pub(crate) fn log_command_result(command: &str, result: Result<(), String>) -> Result<(), String> { let status = if result.is_ok() { "success" } else { "failure" }; @@ -37,10 +37,7 @@ pub(crate) fn log_command_result(command: &str, result: Result<(), String>) -> R #[cfg(target_os = "windows")] fn default_log_path() -> PathBuf { - let base = env::var_os("PROGRAMDATA") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(PROGRAMDATA_FALLBACK)); - log_path_from_base(base) + playit_platform::paths::windows_installer_log_path() } #[cfg(target_os = "windows")] @@ -48,6 +45,7 @@ fn temp_log_path() -> PathBuf { std::env::temp_dir().join("playit-installer.log") } +#[cfg(test)] fn log_path_from_base(base: impl Into) -> PathBuf { let mut path = base.into(); for segment in LOG_RELATIVE_PATH { diff --git a/packages/playitd-windows-setup/src/sid.rs b/packages/playitd-windows-setup/src/sid.rs deleted file mode 100644 index 518aeba9..00000000 --- a/packages/playitd-windows-setup/src/sid.rs +++ /dev/null @@ -1,58 +0,0 @@ -pub(crate) fn normalize_sid(sid: &str) -> Option<&str> { - if !sid.starts_with("S-1-") { - return None; - } - - if sid - .chars() - .any(|c| c.is_whitespace() || matches!(c, '(' | ')' | ';')) - { - return None; - } - - if !sid - .chars() - .all(|c| c.is_ascii_digit() || matches!(c, 'S' | '-')) - { - return None; - } - - let mut parts = sid.split('-'); - if parts.next() != Some("S") || parts.next() != Some("1") { - return None; - } - if !parts.all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit())) { - return None; - } - - Some(sid) -} - -#[cfg(test)] -mod tests { - use super::normalize_sid; - - #[test] - fn sid_validation_accepts_normal_sid() { - assert_eq!( - normalize_sid("S-1-5-21-1-2-3-1001"), - Some("S-1-5-21-1-2-3-1001") - ); - } - - #[test] - fn sid_validation_rejects_sddl_breakout_characters() { - assert_eq!(normalize_sid("S-1-5-21-1-2-3-1001)"), None); - assert_eq!(normalize_sid("S-1-5-21-1-2-3-1001;"), None); - assert_eq!(normalize_sid("S-1-5-21-1-2-3-1001("), None); - assert_eq!(normalize_sid("S-1-5-21-1-2-3-1001 "), None); - } - - #[test] - fn sid_validation_rejects_malformed_sid() { - assert_eq!(normalize_sid(""), None); - assert_eq!(normalize_sid("S-2-5-21-1-2-3-1001"), None); - assert_eq!(normalize_sid("S-1-5-21-1-2-3-"), None); - assert_eq!(normalize_sid("S-1-5-21-1-2-3-user"), None); - } -} diff --git a/packages/playitd/Cargo.toml b/packages/playitd/Cargo.toml index ca33b9dc..b0932534 100644 --- a/packages/playitd/Cargo.toml +++ b/packages/playitd/Cargo.toml @@ -14,18 +14,17 @@ serde_json = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } tracing-appender = { workspace = true } -dirs = { workspace = true } -hex = { workspace = true } toml = { workspace = true } governor = "0.10.0" dotenv = "0.15.0" interprocess = { version = "2.2", features = ["tokio"] } -service-manager = "0.10" playit-agent-core = { path = "../agent_core", version = "1.0.10" } playit-ipc = { path = "../playit-ipc", version = "1.0.10" } -playit-api-client = { path = "../api_client", version = "0.2.0" } +playit-model = { path = "../playit-model", version = "1.0.10" } +playit-platform = { path = "../playit-platform", version = "1.0.10" } +playit-runtime = { path = "../playit-runtime", version = "1.0.10" } clap = { version = "4.6.0", features = ["derive"] } serde_yml = "0.0.12" @@ -37,18 +36,7 @@ path = "src/bin/playitd.rs" name = "playitd-service" path = "src/bin/playitd-service.rs" -[target.'cfg(unix)'.dependencies] -libc = "0.2" -uzers = { version = "0.12.2", default-features = false } - [target."cfg(windows)".dependencies] -windows-sys = { version = "0.59.0", features = [ - "Win32_Foundation", - "Win32_Security", - "Win32_Security_Authorization", - "Win32_System_Services", - "Win32_System_Threading", -] } +windows-sys = { version = "0.59.0", features = ["Win32_System_Threading"] } windows-service = "0.8.0" -widestring = "1.2" tracing-rolling-file = "0.1.3" diff --git a/packages/playitd/src/bin/playitd-service.rs b/packages/playitd/src/bin/playitd-service.rs index 2869eb46..9ded1835 100644 --- a/packages/playitd/src/bin/playitd-service.rs +++ b/packages/playitd/src/bin/playitd-service.rs @@ -9,8 +9,8 @@ mod windows_service_host { use std::time::{Duration, Instant}; use playit_ipc::ipc::{IpcClient, get_default_socket_path}; - use playitd::manager::INSTALLED_SERVICE_LABEL; - use playitd::{windows_service_log_path, windows_service_secret_path}; + use playit_platform::paths::WINDOWS_SERVICE_NAME as INSTALLED_SERVICE_LABEL; + use playit_platform::paths::{windows_service_log_path, windows_service_secret_path}; use std::os::windows::process::CommandExt; use windows_service::define_windows_service; use windows_service::service::{ diff --git a/packages/playitd/src/bin/playitd.rs b/packages/playitd/src/bin/playitd.rs index d1e48142..9d9aea4f 100644 --- a/packages/playitd/src/bin/playitd.rs +++ b/packages/playitd/src/bin/playitd.rs @@ -3,7 +3,8 @@ use std::path::PathBuf; use clap::Parser; -use playitd::{DaemonOptions, VersionDetails, default_secret_path, load_version_overrides}; +use playit_platform::default_secret_path; +use playitd::{DaemonOptions, VersionDetails, load_version_overrides}; #[derive(Parser)] #[command(name = "playitd")] diff --git a/packages/playitd/src/daemon.rs b/packages/playitd/src/daemon.rs index f4a67825..c2a88a5d 100644 --- a/packages/playitd/src/daemon.rs +++ b/packages/playitd/src/daemon.rs @@ -1,40 +1,37 @@ use std::path::{Path, PathBuf}; use std::str::FromStr; use std::sync::Arc; -use std::time::Duration; -use crate::ipc_server::{IpcServer, SecretProvisionRequest, StateCache}; -use crate::logging::{IpcBroadcastLayer, log_rate_limit_filter}; +#[cfg(test)] use playit_agent_core::agent_control::errors::SetupError; use playit_agent_core::agent_control::platform::current_platform; use playit_agent_core::agent_control::version::{help_register_version, register_platform}; -use playit_agent_core::network::origin_lookup::{OriginLookup, OriginResource, OriginTarget}; +use playit_agent_core::gateway::Platform; use playit_agent_core::network::tcp::tcp_settings::TcpSettings; use playit_agent_core::network::udp::udp_settings::UdpSettings; -use playit_agent_core::playit_agent::{PlayitAgent, PlayitAgentSettings}; -use playit_agent_core::stats::AgentStats; -use playit_agent_core::utils::now_milli; -use playit_api_client::PlayitApi; -use playit_api_client::api::{ - AccountStatus, ApiResponseError, AuthError, Platform, ProtoRegisterError, -}; +use playit_agent_core::playit_agent::ServiceExit; +use playit_agent_core::playit_agent::{ControlSettings, EngineLimits, PlayitAgentSettings}; use playit_ipc::ipc::{IpcError, get_default_socket_path, protocol_info}; -use playit_ipc::model::{ - AccountStatus as ServiceAccountStatus, AgentLifecycle, AgentState, ConnectionStats, - NoticeState, PendingTunnelState, ServiceError, ServiceErrorCode, ServicePhase, ServiceStatus, - ServiceUpdate, TunnelState, +use playit_ipc::model::ServiceUpdate; +use playit_model::{AppConfig, RawAppConfig, ServiceInfo}; +#[cfg(test)] +use playit_runtime::setup_error_message; +use playit_runtime::{ + AppSupervisor, Clock, FileSecretStore, InlineSecretStore, IpcPort, SecretStore, ServiceChild, + SupervisedEnginePort, SupervisorConfig, SupervisorError, SupervisorPolicy, SystemClock, }; use serde::Deserialize; -use tokio::sync::{broadcast, mpsc}; -use tokio::task::JoinHandle; +use tokio::sync::broadcast; use tokio_util::sync::CancellationToken; use tracing_appender::non_blocking::WorkerGuard; use tracing_subscriber::EnvFilter; use tracing_subscriber::layer::{Layer, SubscriberExt}; use tracing_subscriber::util::SubscriberInitExt; +use crate::ipc_server::{IpcServer, IpcServerConfig}; +use crate::logging::{IpcBroadcastLayer, log_rate_limit_filter}; + pub const DEFAULT_VARIANT_ID: &str = "308943e8-faef-4835-a2ba-270351f72aa3"; -const AGENT_LIMIT_RETRY_INTERVAL: Duration = Duration::from_secs(30); #[cfg(target_os = "windows")] const WINDOWS_LOG_MAX_FILE_SIZE_BYTES: u64 = 5 * 1024 * 1024; #[cfg(target_os = "windows")] @@ -42,7 +39,7 @@ const WINDOWS_LOG_MAX_TOTAL_FILES: usize = 3; #[cfg(target_os = "windows")] const WINDOWS_LOG_MAX_ROTATED_FILES: usize = WINDOWS_LOG_MAX_TOTAL_FILES - 1; -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct DaemonOptions { pub secret: Option, pub secret_path: Option, @@ -52,11 +49,25 @@ pub struct DaemonOptions { pub version: VersionDetails, } +impl std::fmt::Debug for DaemonOptions { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("DaemonOptions") + .field("secret", &self.secret.as_ref().map(|_| "[REDACTED]")) + .field("secret_path", &self.secret_path) + .field("socket_path", &self.socket_path) + .field("log_path", &self.log_path) + .field("platform_docker", &self.platform_docker) + .field("version", &self.version) + .finish() + } +} + impl Default for DaemonOptions { fn default() -> Self { Self { secret: None, - secret_path: Some(crate::paths::default_secret_path()), + secret_path: Some(playit_platform::default_secret_path()), socket_path: None, log_path: None, platform_docker: false, @@ -66,42 +77,6 @@ impl Default for DaemonOptions { } } -#[derive(Debug, Clone)] -enum SecretSource { - Inline { secret: String }, - File { path: PathBuf }, -} - -#[derive(Debug, Clone)] -enum LoadedSecret { - Ready(String), - Missing, - Invalid(String), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct RunningSummary { - tunnel_count: usize, - pending_tunnel_count: usize, - disabled_tunnel_count: usize, - account_status: &'static str, -} - -impl RunningSummary { - fn from_state(state: &AgentState) -> Self { - Self { - tunnel_count: state.tunnels.len(), - pending_tunnel_count: state.pending_tunnels.len(), - disabled_tunnel_count: state - .tunnels - .iter() - .filter(|tunnel| tunnel.is_disabled) - .count(), - account_status: service_account_status_label(&state.account_status), - } - } -} - #[derive(Debug, Clone)] pub struct VersionDetails { pub variant_id: String, @@ -129,9 +104,8 @@ impl VersionDetails { .next() .ok_or_else(|| format!("missing patch version in {version}")) .and_then(parse_version_part)?; - Ok(Self { - variant_id: variant_id.to_string(), + variant_id: variant_id.to_owned(), major, minor, patch, @@ -139,17 +113,17 @@ impl VersionDetails { } pub fn apply_overrides(&mut self, overrides: VersionOverrideFile) { - if let Some(variant_id) = overrides.variant_id { - self.variant_id = variant_id; + if let Some(value) = overrides.variant_id { + self.variant_id = value; } - if let Some(major) = overrides.major { - self.major = major; + if let Some(value) = overrides.major { + self.major = value; } - if let Some(minor) = overrides.minor { - self.minor = minor; + if let Some(value) = overrides.minor { + self.minor = value; } - if let Some(patch) = overrides.patch { - self.patch = patch; + if let Some(value) = overrides.patch { + self.patch = value; } } @@ -166,31 +140,6 @@ pub struct VersionOverrideFile { pub patch: Option, } -#[derive(Debug)] -pub enum DaemonError { - Ipc(IpcError), - SecretError(String), - SetupError(String), -} - -impl std::fmt::Display for DaemonError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Ipc(e) => write!(f, "IPC error: {e}"), - Self::SecretError(e) => write!(f, "Secret error: {e}"), - Self::SetupError(e) => write!(f, "Setup error: {e}"), - } - } -} - -impl std::error::Error for DaemonError {} - -impl From for DaemonError { - fn from(e: IpcError) -> Self { - Self::Ipc(e) - } -} - pub async fn load_version_overrides(path: &Path) -> Result { let content = tokio::fs::read_to_string(path).await.map_err(|error| { format!( @@ -198,11 +147,10 @@ pub async fn load_version_overrides(path: &Path) -> Result serde_json::from_str(&content) @@ -216,1049 +164,209 @@ pub async fn load_version_overrides(path: &Path) -> Result Self { - match options.secret.clone() { - Some(secret) => Self::Inline { secret }, - None => Self::File { - path: options - .secret_path - .clone() - .unwrap_or_else(crate::paths::default_secret_path), - }, - } - } - - fn secret_path(&self) -> Option<&Path> { - match self { - Self::Inline { .. } => None, - Self::File { path } => Some(path.as_path()), - } - } - - fn allows_ipc_provisioning(&self) -> bool { - matches!(self, Self::File { .. }) - } +#[derive(Debug)] +pub enum DaemonError { + Ipc(IpcError), + Setup(String), +} - async fn load(&self) -> LoadedSecret { +impl std::fmt::Display for DaemonError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::Inline { secret } => match validate_secret(secret.trim()) { - Ok(secret) => LoadedSecret::Ready(secret), - Err(error) => { - LoadedSecret::Invalid(format!("Invalid secret passed via --secret: {error}")) - } - }, - Self::File { path } => load_secret_from_path(path).await, + Self::Ipc(error) => write!(formatter, "IPC error: {error}"), + Self::Setup(error) => write!(formatter, "Setup error: {error}"), } } +} - fn secret_provision_error(&self) -> ServiceError { - match self { - Self::Inline { .. } => daemon_error( - ServiceErrorCode::SecretPinned, - "Secret provisioning is unavailable because playitd was started with --secret." - .to_string(), - false, - ), - Self::File { .. } => daemon_error( - ServiceErrorCode::ProvisioningUnavailable, - "Secret provisioning is unavailable".to_string(), - true, - ), - } - } +impl std::error::Error for DaemonError {} - fn secret_reset_error(&self) -> ServiceError { - match self { - Self::Inline { .. } => daemon_error( - ServiceErrorCode::SecretPinned, - "Secret reset is unavailable because playitd was started with --secret." - .to_string(), - false, - ), - Self::File { path } => daemon_error( - ServiceErrorCode::SecretWriteFailed, - format!("Failed to access secret file {}", path.display()), - true, - ), - } +impl From for DaemonError { + fn from(error: IpcError) -> Self { + Self::Ipc(error) } } pub async fn run_daemon(options: DaemonOptions) -> Result<(), DaemonError> { - let secret_source = SecretSource::from_options(&options); - let (secret_provision_tx, mut secret_rx) = if secret_source.allows_ipc_provisioning() { - let (secret_tx, secret_rx) = mpsc::channel::(8); - (Some(secret_tx), Some(secret_rx)) - } else { - (None, None) - }; - let mut runtime = initialize_runtime(&options, &secret_source, secret_provision_tx).await?; - - let Some(secret_code) = - resolve_startup_secret(&mut runtime, &secret_source, &mut secret_rx).await? - else { - return Ok(()); - }; - - let Some(agent) = - build_agent_with_reprovisioning(&mut runtime, &secret_source, &mut secret_rx, secret_code) - .await? - else { - return Ok(()); - }; - - publish_runtime_state( - &runtime.state_cache, - &runtime.event_tx, - runtime - .status_context - .status(ServicePhase::Running, true, None), - AgentLifecycle::Starting, - ) - .await; - - run_until_shutdown(runtime, agent).await -} - -struct DaemonRuntime { - start_time: u64, - version_string: String, - status_context: StatusContext, - cancel_token: CancellationToken, - ipc_server: Arc, - ipc_handle: Option>, - state_cache: Arc, - event_tx: broadcast::Sender, - _log_guard: Option, -} - -struct AgentRuntime { - api: PlayitApi, - runner: PlayitAgent, - stats: AgentStats, - lookup: Arc, -} - -async fn initialize_runtime( - options: &DaemonOptions, - secret_source: &SecretSource, - secret_provision_tx: Option>, -) -> Result { - let start_time = now_milli(); - let (event_tx, _) = broadcast::channel::(256); - let version_string = options.version.version_string(); + let clock = Arc::new(SystemClock); + let start_time = clock.now_millis(); + let version = options.version.version_string(); let platform = if options.platform_docker { Platform::Docker } else { current_platform() }; - let status_context = StatusContext { - secret_path: secret_source - .secret_path() - .map(|path| path.display().to_string()), - socket_path: options - .socket_path - .clone() - .unwrap_or_else(|| get_default_socket_path().to_string()), - version: version_string.clone(), - start_time, + let socket_path = options + .socket_path + .clone() + .unwrap_or_else(|| get_default_socket_path().to_owned()); + let secret_path = options + .secret_path + .clone() + .unwrap_or_else(playit_platform::default_secret_path); + let app_config = validate_runtime_config( + api_base(), + if options.secret.is_some() { + "inline-secret".to_owned() + } else { + secret_path.display().to_string() + }, + socket_path.clone(), + )?; + let secrets: Arc = match options.secret.clone() { + Some(secret) => Arc::new(InlineSecretStore::new(secret)), + None => Arc::new(FileSecretStore::new(secret_path)), }; + let (log_tx, _) = broadcast::channel::(256); let log_filter = EnvFilter::try_from_env("PLAYIT_LOG").unwrap_or_else(|_| EnvFilter::new("info")); let use_ansi = matches!(platform, Platform::Linux | Platform::Docker); - let log_guard = init_tracing( + let _log_guard = init_tracing( log_filter, use_ansi, - event_tx.clone(), + log_tx.clone(), options.log_path.as_deref(), ) - .map_err(DaemonError::SetupError)?; - + .map_err(DaemonError::Setup)?; register_platform(platform); - - let _ = help_register_version(&version_string, &options.version.variant_id); - - tracing::info!( - socket_path = ?options.socket_path, - secret_path = ?status_context.secret_path, - version = %version_string, - "Starting playitd" - ); - - let cancel_token = CancellationToken::new(); - let ipc_server = Arc::new( - IpcServer::new_with_sender( - options.socket_path.clone(), - cancel_token.clone(), - event_tx, - secret_source.secret_path().map(PathBuf::from), - secret_provision_tx, - secret_source.secret_provision_error(), - secret_source.secret_reset_error(), - ) - .await - .map_err(DaemonError::Ipc)?, - ); - - let listener = ipc_server.bind_listener().await.map_err(DaemonError::Ipc)?; - let state_cache = ipc_server.state_cache(); - let event_tx = ipc_server.event_sender(); - let ipc_handle = { - let server = ipc_server.clone(); - tokio::spawn(async move { - if let Err(e) = server.run(listener).await { - tracing::error!("IPC server error: {e}"); - } - }) + let _ = help_register_version(&version, &options.version.variant_id); + tracing::info!(socket_path = %socket_path, secret_path = ?secrets.path(), version = %version, "starting playitd"); + + let policy = SupervisorPolicy { + refresh_retry: app_config.control.retry_delay, + shutdown_deadline: app_config.shutdown.drain_timeout, + gateway_timeout: app_config.control.connect_timeout, + ..SupervisorPolicy::default() }; - - Ok(DaemonRuntime { + let config = SupervisorConfig { + api_base: app_config.api_base.as_str().to_owned(), + version: version.clone(), start_time, - version_string, - status_context, - cancel_token, - ipc_server, - ipc_handle: Some(ipc_handle), - state_cache, - event_tx, - _log_guard: log_guard, - }) -} - -async fn resolve_startup_secret( - runtime: &mut DaemonRuntime, - secret_source: &SecretSource, - secret_rx: &mut Option>, -) -> Result, DaemonError> { - match secret_source.load().await { - LoadedSecret::Ready(secret) => { - publish_starting(runtime).await; - Ok(Some(secret)) - } - LoadedSecret::Missing => { - let secret = wait_for_startup_secret(runtime, secret_source, secret_rx).await?; - if secret.is_none() { - tracing::info!("playitd shutdown before provisioning completed"); - } - Ok(secret) - } - LoadedSecret::Invalid(error) => { - let service_error = daemon_error( - ServiceErrorCode::InvalidSecret, - error.clone(), - secret_source.allows_ipc_provisioning(), - ); - publish_runtime_state( - &runtime.state_cache, - &runtime.event_tx, - runtime.status_context.status( - ServicePhase::HasInvalidSecret, - false, - Some(service_error.clone()), - ), - AgentLifecycle::HasInvalidSecret(service_error), - ) - .await; - - if !secret_source.allows_ipc_provisioning() { - return Err(DaemonError::SecretError(error)); - } - - let secret = wait_for_startup_secret(runtime, secret_source, secret_rx).await?; - if secret.is_none() { - tracing::info!("playitd shutdown before invalid secret was corrected"); - } - Ok(secret) - } - } -} - -async fn wait_for_startup_secret( - runtime: &mut DaemonRuntime, - secret_source: &SecretSource, - secret_rx: &mut Option>, -) -> Result, DaemonError> { - let Some(secret_path) = secret_source.secret_path() else { - return Err(DaemonError::SecretError( - "No secret source available for startup".to_string(), - )); - }; - - publish_runtime_state( - &runtime.state_cache, - &runtime.event_tx, - runtime - .status_context - .status(ServicePhase::WaitingForSecret, false, None), - AgentLifecycle::WaitingForSecret, - ) - .await; - - wait_for_provisioned_secret(runtime, secret_path, secret_rx).await -} - -async fn wait_for_provisioned_secret( - runtime: &mut DaemonRuntime, - secret_path: &Path, - secret_rx: &mut Option>, -) -> Result, DaemonError> { - let secret_rx = secret_rx - .as_mut() - .expect("file-backed secret mode must enable provisioning"); - - match wait_for_secret_provisioning(secret_path, secret_rx, &runtime.cancel_token) - .await - .map_err(DaemonError::SecretError)? - { - Some(secret) => { - publish_starting(runtime).await; - Ok(Some(secret)) - } - None => { - publish_stopping(runtime, false).await; - await_ipc_shutdown(runtime).await; - Ok(None) - } - } -} - -async fn build_agent_with_reprovisioning( - runtime: &mut DaemonRuntime, - secret_source: &SecretSource, - secret_rx: &mut Option>, - secret_code: String, -) -> Result, DaemonError> { - let lookup = Arc::new(OriginLookup::default()); - let mut secret_code = secret_code; - - loop { - let api = PlayitApi::create(api_base(), Some(secret_code.clone())); - runtime.ipc_server.set_api(api.clone()).await; - - if let Ok(data) = api.v1_agents_rundata().await { - lookup.update_from_run_data(&data).await; - } - - let settings = PlayitAgentSettings { - udp_settings: UdpSettings::default(), - tcp_settings: TcpSettings::default(), - api_url: api_base(), - secret_key: secret_code.clone(), - }; - - match PlayitAgent::new(settings, lookup.clone()).await { - Ok(runner) => { - let stats = runner.stats(); - return Ok(Some(AgentRuntime { - api, - runner, - stats, - lookup, - })); - } - Err(error) - if secret_source.allows_ipc_provisioning() - && is_invalid_agent_secret_error(&error) => - { - tracing::warn!(?error, "configured agent secret is no longer valid"); - - let service_error = daemon_error( - ServiceErrorCode::InvalidSecret, - "The configured playit secret is no longer valid. Run `playit setup` to provision a new secret.".to_string(), - true, - ); - publish_runtime_state( - &runtime.state_cache, - &runtime.event_tx, - runtime.status_context.status( - ServicePhase::WaitingForSecret, - false, - Some(service_error), - ), - AgentLifecycle::WaitingForSecret, - ) - .await; - - let secret_path = secret_source - .secret_path() - .expect("file-backed secret mode must provide a secret path"); - let Some(secret) = - wait_for_provisioned_secret(runtime, secret_path, secret_rx).await? - else { - tracing::info!("playitd shutdown before reprovisioning completed"); - return Ok(None); - }; - secret_code = secret; - } - Err(error) if is_agent_disabled_over_limit_error(&error) => { - tracing::warn!( - ?error, - "agent disabled because the account is over the agent limit" - ); - - let service_error = daemon_error( - ServiceErrorCode::AgentDisabledOverLimit, - agent_disabled_over_limit_message(), - true, - ); - publish_runtime_state( - &runtime.state_cache, - &runtime.event_tx, - runtime.status_context.status( - ServicePhase::DisabledOverLimit, - true, - Some(service_error.clone()), - ), - AgentLifecycle::DisabledOverLimit(service_error), - ) - .await; - - tokio::select! { - _ = runtime.cancel_token.cancelled() => { - publish_stopping(runtime, true).await; - await_ipc_shutdown(runtime).await; - tracing::info!("playitd shutdown while waiting for the account agent limit to be resolved"); - return Ok(None); - } - _ = tokio::time::sleep(AGENT_LIMIT_RETRY_INTERVAL) => {} - } - } - Err(error) => { - let message = setup_error_user_message(&error); - tracing::error!(?error, %message, "failed to start playit agent"); - let service_error = daemon_error(ServiceErrorCode::Internal, message.clone(), true); - publish_runtime_state( - &runtime.state_cache, - &runtime.event_tx, - runtime.status_context.status( - ServicePhase::Error, - true, - Some(service_error.clone()), - ), - AgentLifecycle::Error(service_error), - ) - .await; - return Err(DaemonError::SetupError(message)); - } - } - } -} - -async fn run_until_shutdown( - mut runtime: DaemonRuntime, - agent: AgentRuntime, -) -> Result<(), DaemonError> { - let agent_cancel = agent.runner.cancellation_token(); - let mut agent_handle = tokio::spawn(agent.runner.run()); - let stats_handle = { - let event_tx = runtime.event_tx.clone(); - let token = runtime.cancel_token.clone(); - let cache = runtime.state_cache.clone(); - tokio::spawn(broadcast_stats(agent.stats, event_tx, cache, token)) + service: ServiceInfo { + process_id: Some(std::process::id()), + uptime_secs: 0, + started_at_millis: start_time, + version: Some(version), + ipc_protocol: protocol_info().ipc_version, + ipc_endpoint: Some(socket_path), + secret_location: secrets.path().map(|path| path.display().to_string()), + has_secret: false, + }, + policy, }; - let state_handle = { - let event_tx = runtime.event_tx.clone(); - let token = runtime.cancel_token.clone(); - let cache = runtime.state_cache.clone(); - tokio::spawn(broadcast_agent_state( - agent.api, - agent.lookup, - event_tx, - cache, - token, - runtime.start_time, - runtime.version_string.clone(), - )) + let limits = EngineLimits { + shutdown_deadline: app_config.shutdown.drain_timeout, + ..EngineLimits::default() }; - - let shutdown_reason = tokio::select! { - _ = tokio::signal::ctrl_c() => { - tracing::info!("Received Ctrl+C, shutting down"); - ShutdownReason::Requested - } - _ = runtime.cancel_token.cancelled() => { - tracing::info!("Shutdown requested via IPC"); - ShutdownReason::Requested - } - result = &mut agent_handle => { - if runtime.cancel_token.is_cancelled() || agent_cancel.is_cancelled() { - tracing::info!("Agent task completed during shutdown"); - ShutdownReason::Requested - } else { - match &result { - Ok(()) => tracing::error!("Agent task stopped unexpectedly"), - Err(error) => tracing::error!(?error, "Agent task failed"), - } - ShutdownReason::AgentStopped(result) - } - } - }; - - runtime.cancel_token.cancel(); - agent_cancel.cancel(); - publish_stopping(&runtime, true).await; - - let _ = tokio::time::timeout(Duration::from_secs(5), async { - if !matches!(shutdown_reason, ShutdownReason::AgentStopped(_)) { - let _ = (&mut agent_handle).await; - } - await_ipc_shutdown(&mut runtime).await; - let _ = stats_handle.await; - let _ = state_handle.await; - }) - .await; - - tracing::info!("playitd shutdown complete"); - - match shutdown_reason { - ShutdownReason::Requested => Ok(()), - ShutdownReason::AgentStopped(Ok(())) => Err(DaemonError::SetupError( - "playit agent task stopped unexpectedly".to_string(), - )), - ShutdownReason::AgentStopped(Err(error)) => Err(DaemonError::SetupError(format!( - "playit agent task failed: {error}" - ))), - } -} - -enum ShutdownReason { - Requested, - AgentStopped(Result<(), tokio::task::JoinError>), -} - -async fn publish_starting(runtime: &DaemonRuntime) { - publish_runtime_state( - &runtime.state_cache, - &runtime.event_tx, - runtime - .status_context - .status(ServicePhase::Starting, true, None), - AgentLifecycle::Starting, - ) - .await; -} - -async fn publish_stopping(runtime: &DaemonRuntime, has_secret: bool) { - publish_runtime_state( - &runtime.state_cache, - &runtime.event_tx, - runtime - .status_context - .status(ServicePhase::Stopping, has_secret, None), - AgentLifecycle::Stopping, - ) - .await; -} - -async fn await_ipc_shutdown(runtime: &mut DaemonRuntime) { - if let Some(handle) = runtime.ipc_handle.take() { - let _ = handle.await; - } -} - -async fn broadcast_stats( - stats: AgentStats, - event_tx: broadcast::Sender, - state_cache: Arc, - cancel_token: CancellationToken, -) { - let mut interval = tokio::time::interval(Duration::from_millis(100)); - - loop { - tokio::select! { - _ = interval.tick() => { - let snapshot = stats.snapshot(); - let stats = ConnectionStats { - bytes_in: snapshot.bytes_in, - bytes_out: snapshot.bytes_out, - active_tcp: snapshot.active_tcp, - active_udp: snapshot.active_udp, - }; - state_cache.set_stats(stats.clone()).await; - let _ = event_tx.send(ServiceUpdate::Stats(stats)); - } - _ = cancel_token.cancelled() => break, - } - } -} - -async fn broadcast_agent_state( - api: PlayitApi, - lookup: Arc, - event_tx: broadcast::Sender, - state_cache: Arc, - cancel_token: CancellationToken, - start_time: u64, - version_string: String, -) { - let mut interval = tokio::time::interval(Duration::from_secs(3)); - let mut guest_login_link: Option<(String, u64)> = None; - let mut last_running_summary: Option = None; - - loop { - tokio::select! { - _ = interval.tick() => { - match api.v1_agents_rundata().await { - Ok(mut api_data) => { - lookup.update_from_run_data(&api_data).await; - - let login_link = match api_data.permissions.account_status { - AccountStatus::Guest => { - get_cached_guest_login_link(&api, &mut guest_login_link).await - } - _ => None, - }; - - api_data.notices.sort_by_key(|n| n.priority); - - let state = AgentState { - version: version_string.clone(), - tunnels: api_data - .tunnels - .iter() - .filter_map(|tunnel| { - let origin = OriginResource::from_agent_tunnel(tunnel)?; - let destination = match origin.target { - OriginTarget::Https { - ip, - http_port, - https_port, - } => format!("{ip} (http: {http_port}, https: {https_port})"), - OriginTarget::Port { ip, port } => format!("{ip}:{port}"), - }; - - Some(TunnelState { - display_address: tunnel.display_address.clone(), - destination, - is_disabled: tunnel.disabled_reason.is_some(), - disabled_reason: tunnel.disabled_reason.as_ref().map(|s| s.to_string()), - }) - }) - .collect(), - pending_tunnels: api_data - .pending - .iter() - .map(|p| PendingTunnelState { - id: p.id.to_string(), - status_msg: p.status_msg.clone(), - }) - .collect(), - notices: api_data - .notices - .iter() - .map(|n| NoticeState { - priority: format!("{:?}", n.priority), - message: n.message.to_string(), - resolve_link: n.resolve_link.as_ref().map(|s| s.to_string()), - }) - .collect(), - account_status: match api_data.permissions.account_status { - AccountStatus::Guest => ServiceAccountStatus::Guest, - AccountStatus::EmailNotVerified => ServiceAccountStatus::EmailNotVerified, - AccountStatus::Verified => ServiceAccountStatus::Verified, - }, - agent_id: api_data.agent_id.to_string(), - login_link, - start_time, - }; - - let summary = RunningSummary::from_state(&state); - if last_running_summary.as_ref() != Some(&summary) { - if last_running_summary.is_none() { - tracing::info!( - agent_id = %state.agent_id, - tunnel_count = summary.tunnel_count, - pending_tunnel_count = summary.pending_tunnel_count, - disabled_tunnel_count = summary.disabled_tunnel_count, - account_status = summary.account_status, - "playit connected; tunnels loaded" - ); - } else { - tracing::info!( - agent_id = %state.agent_id, - tunnel_count = summary.tunnel_count, - pending_tunnel_count = summary.pending_tunnel_count, - disabled_tunnel_count = summary.disabled_tunnel_count, - account_status = summary.account_status, - "playit tunnel state updated" - ); - } - last_running_summary = Some(summary); - } - - let lifecycle = AgentLifecycle::Running(state); - state_cache.set_lifecycle(lifecycle.clone()).await; - let _ = event_tx.send(ServiceUpdate::Lifecycle(lifecycle)); - } - Err(error) => tracing::error!(?error, "Failed to load agent data"), - } - } - _ = cancel_token.cancelled() => break, - } - } -} - -fn api_base() -> String { - dotenv::var("API_BASE").unwrap_or_else(|_| "https://api.playit.gg".to_string()) -} - -async fn get_cached_guest_login_link( - api: &PlayitApi, - guest_login_link: &mut Option<(String, u64)>, -) -> Option { - let now = now_milli(); - match guest_login_link { - Some((link, ts)) if now.saturating_sub(*ts) < 15_000 => Some(link.clone()), - _ => match api.login_guest().await { - Ok(session) => { - let link = format!( - "https://playit.gg/login/guest-account/{}", - session.session_key - ); - *guest_login_link = Some((link.clone(), now)); - Some(link) - } - Err(_) => None, + let engine_settings = PlayitAgentSettings { + control_settings: ControlSettings { + connect_timeout: app_config.control.connect_timeout, + retry_delay: app_config.control.retry_delay, + event_queue_capacity: app_config.control.event_queue_capacity.get(), }, - } -} - -async fn load_secret_from_path(path: &Path) -> LoadedSecret { - let content = match tokio::fs::read_to_string(path).await { - Ok(content) => content, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return LoadedSecret::Missing, - Err(error) => { - return LoadedSecret::Invalid(format!( - "Failed to read secret file {}: {error}", - path.display() - )); - } + tcp_settings: TcpSettings { + new_client_ratelimit: app_config.tcp.per_second.get(), + new_client_ratelimit_burst: app_config.tcp.burst.get(), + queue_capacity: app_config.tcp.queue_capacity.get(), + ..TcpSettings::default() + }, + udp_settings: UdpSettings { + new_client_ratelimit: app_config.udp.per_second.get(), + new_client_ratelimit_burst: app_config.udp.burst.get(), + queue_capacity: app_config.udp.queue_capacity.get(), + }, + limits, }; - - match parse_secret_file(&content) { - Ok(secret) => LoadedSecret::Ready(secret), - Err(()) => LoadedSecret::Invalid(format!( - "Invalid secret file at {}. Remove or replace it with a valid secret.", - path.display() - )), - } -} - -async fn wait_for_secret_provisioning( - secret_path: &Path, - provision_rx: &mut mpsc::Receiver, - cancel_token: &CancellationToken, -) -> Result, String> { - tracing::info!( - secret_path = %secret_path.display(), - "Waiting for frontend secret provisioning over IPC" + let (mut supervisor, commands, snapshots) = AppSupervisor::new( + config, + secrets.clone(), + Arc::new(SupervisedEnginePort::new(engine_settings)), + clock, ); - loop { - tokio::select! { - maybe_request = provision_rx.recv() => { - let Some(request) = maybe_request else { - return Err("Secret provisioning channel closed".to_string()); - }; - - let result = persist_secret_file(secret_path, &request.secret).await; - let ack = result.as_ref().map(|_| ()).map_err(Clone::clone); - let _ = request.response_tx.send(ack); - - match result { - Ok(()) => { - tracing::info!(secret_path = %secret_path.display(), "Secret provisioned successfully"); - return Ok(Some(request.secret)); - } - Err(error) => { - tracing::error!(secret_path = %secret_path.display(), "{error}"); - } - } - } - _ = cancel_token.cancelled() => { - return Ok(None); - } - } - } -} - -async fn persist_secret_file(path: &Path, secret: &str) -> Result<(), String> { - let secret = validate_secret(secret.trim())?; - - if let Some(parent) = path - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - { - tokio::fs::create_dir_all(parent).await.map_err(|error| { - format!( - "Failed to create secret directory {}: {error}", - parent.display() - ) - })?; - } - - let content = if path.extension().and_then(|ext| ext.to_str()) == Some("toml") { - toml::to_string(&SecretConfig { - secret_key: secret.clone(), - }) - .map_err(|error| { - format!( - "Failed to serialize secret file {}: {error}", - path.display() - ) - })? - } else { - secret - }; - - secure_write_secret_file(path, content.as_bytes()).await -} - -#[cfg(unix)] -async fn secure_write_secret_file(path: &Path, content: &[u8]) -> Result<(), String> { - let path = path.to_path_buf(); - let content = content.to_vec(); - - tokio::task::spawn_blocking(move || secure_write_secret_file_blocking(&path, &content)) - .await - .map_err(|error| format!("Failed to join secret file writer task: {error}"))? -} - -#[cfg(unix)] -fn secure_write_secret_file_blocking(path: &Path, content: &[u8]) -> Result<(), String> { - use std::io::Write; - use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; - - let parent = path - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - .unwrap_or_else(|| Path::new(".")); - let file_name = path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("playit.toml"); - let tmp_path = parent.join(format!( - ".{file_name}.tmp-{}-{}", - std::process::id(), - now_milli() - )); - - let result = (|| { - let mut file = std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .open(&tmp_path) - .map_err(|error| { - format!( - "Failed to create temporary secret file {}: {error}", - tmp_path.display() - ) - })?; - - file.write_all(content).map_err(|error| { - format!( - "Failed to write temporary secret file {}: {error}", - tmp_path.display() - ) - })?; - file.sync_all().map_err(|error| { - format!( - "Failed to sync temporary secret file {}: {error}", - tmp_path.display() - ) - })?; - drop(file); - - std::fs::rename(&tmp_path, path).map_err(|error| { - format!( - "Failed to replace secret file {} with {}: {error}", - path.display(), - tmp_path.display() - ) - })?; - std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err( - |error| { - format!( - "Failed to set secret file permissions on {}: {error}", - path.display() - ) + let ipc_cancel = CancellationToken::new(); + let ipc = Arc::new( + IpcServer::new_with_sender( + IpcServerConfig { + socket_path: options.socket_path, + snapshots, + secret_path: secrets.path().map(PathBuf::from), + commands: Some(commands), }, - )?; - - Ok(()) - })(); - - if result.is_err() { - let _ = std::fs::remove_file(&tmp_path); - } - - result + ipc_cancel.clone(), + log_tx, + ) + .await?, + ); + let listener = ipc.bind_listener().await?; + supervisor.install_ipc(Box::new(IpcService { + server: ipc, + listener: Some(listener), + cancel: ipc_cancel, + })); + + let process_shutdown = CancellationToken::new(); + let signal_shutdown = process_shutdown.clone(); + let signal = tokio::spawn(async move { + if tokio::signal::ctrl_c().await.is_ok() { + signal_shutdown.cancel(); + } + }); + let result = supervisor.run(process_shutdown).await; + signal.abort(); + let _ = signal.await; + result.map_err(map_supervisor_error) } -#[cfg(not(unix))] -async fn secure_write_secret_file(path: &Path, content: &[u8]) -> Result<(), String> { - tokio::fs::write(path, content) - .await - .map_err(|error| format!("Failed to write secret file {}: {error}", path.display())) +fn api_base() -> String { + dotenv::var("API_BASE").unwrap_or_else(|_| "https://api.playit.gg".to_owned()) +} + +fn validate_runtime_config( + api_base: String, + secret_location: String, + ipc_location: String, +) -> Result { + AppConfig::validate(RawAppConfig { + api_base, + secret_location, + ipc_location, + ..RawAppConfig::default() + }) + .map_err(|error| DaemonError::Setup(format!("invalid runtime configuration: {error:?}"))) } -fn parse_secret_file(content: &str) -> Result { - let trimmed = content.trim(); - if let Ok(secret) = validate_secret(trimmed) { - return Ok(secret); - } - - let config = toml::from_str::(content).map_err(|_| ())?; - validate_secret(config.secret_key.trim()).map_err(|_| ()) +struct IpcService { + server: Arc, + listener: Option, + cancel: CancellationToken, } -fn validate_secret(secret: &str) -> Result { - hex::decode(secret) - .map(|_| secret.to_string()) - .map_err(|_| { - "The secret is not valid. It should be the key generated by playit setup.".to_string() - }) +impl IpcPort for IpcService { + fn start(mut self: Box) -> ServiceChild { + let listener = self.listener.take().expect("IPC listener starts once"); + let server = self.server.clone(); + let exit = tokio::spawn(async move { + match server.run(listener).await { + Ok(()) => ServiceExit::Completed, + Err(error) => ServiceExit::Failed(error.to_string()), + } + }); + ServiceChild::new(self.cancel.clone(), exit) + } } -#[derive(serde::Serialize, serde::Deserialize)] -struct SecretConfig { - secret_key: String, +fn map_supervisor_error(error: SupervisorError) -> DaemonError { + let failure = error.failure(); + let detail = failure.detail.unwrap_or_else(|| format!("{error:?}")); + DaemonError::Setup(format!("[{}] {detail}", failure.problem.code.as_str())) } fn parse_version_part(part: &str) -> Result { u32::from_str(part).map_err(|error| format!("Invalid version component {part}: {error}")) } -struct StatusContext { - secret_path: Option, - socket_path: String, - version: String, - start_time: u64, -} - -impl StatusContext { - fn status( - &self, - phase: ServicePhase, - has_secret: bool, - last_error: Option, - ) -> ServiceStatus { - ServiceStatus { - phase, - pid: std::process::id(), - uptime_secs: now_milli().saturating_sub(self.start_time) / 1000, - version: self.version.clone(), - socket_path: self.socket_path.clone(), - secret_path: self.secret_path.clone(), - has_secret, - protocol: protocol_info(), - last_error, - } - } -} - -async fn publish_runtime_state( - state_cache: &Arc, - event_tx: &broadcast::Sender, - status: ServiceStatus, - lifecycle: AgentLifecycle, -) { - state_cache.set_status(status.clone()).await; - state_cache.set_lifecycle(lifecycle.clone()).await; - let _ = event_tx.send(ServiceUpdate::Status(status)); - let _ = event_tx.send(ServiceUpdate::Lifecycle(lifecycle)); -} - -fn daemon_error(code: ServiceErrorCode, message: String, retryable: bool) -> ServiceError { - ServiceError { - code, - message, - retryable, - details: None, - } -} - -fn service_account_status_label(status: &ServiceAccountStatus) -> &'static str { - match status { - ServiceAccountStatus::Unknown => "unknown", - ServiceAccountStatus::Guest => "guest", - ServiceAccountStatus::EmailNotVerified => "email_not_verified", - ServiceAccountStatus::Verified => "verified", - } -} - -fn agent_disabled_over_limit_message() -> String { - "This account is over the agent limit. Delete an unused agent or upgrade the account, then the service will retry." - .to_string() -} - -fn setup_error_user_message(error: &SetupError) -> String { - match error { - SetupError::FailedToConnect => { - "Could not connect to playit tunnel servers. Check your internet connection, firewall, VPN, or DNS settings, then restart playit.".to_string() - } - SetupError::RequestError(_) => { - "Could not reach the playit API. Check your internet connection or try again later.".to_string() - } - SetupError::ApiError(ApiResponseError::Auth(AuthError::InvalidAgentKey | AuthError::NoLongerValid)) => { - "The configured playit secret is no longer valid. Run `playit setup` to provision a new secret.".to_string() - } - SetupError::ApiError(error) => { - format!("The playit API rejected the agent startup request: {error}") - } - SetupError::ApiFail(payload) - if matches!( - serde_json::from_str::(payload), - Ok(ProtoRegisterError::AgentDisabledOverLimit) - ) => - { - agent_disabled_over_limit_message() - } - SetupError::ApiFail(_) => { - "The playit API rejected the agent registration request. Check your account and tunnel configuration, then try again.".to_string() - } - SetupError::Timeout(_) => { - "Timed out while connecting to playit. Check your network/firewall and try again.".to_string() - } - SetupError::IoError(error) => { - format!("Could not open a required network socket: {error}") - } - SetupError::AttemptingToAuthWithOldFlow - | SetupError::FailedToDecodeSignedAgentRegisterHex - | SetupError::NoResponseFromAuthenticate - | SetupError::RegisterInvalidSignature - | SetupError::RegisterUnauthorized => { - format!("Failed to start the playit agent: {error}") - } - } -} - -fn is_invalid_agent_secret_error(error: &SetupError) -> bool { - matches!( - error, - SetupError::ApiError(ApiResponseError::Auth( - AuthError::InvalidAgentKey | AuthError::NoLongerValid - )) - ) -} - -fn parse_proto_register_error(error: &SetupError) -> Option { - match error { - SetupError::ApiFail(payload) => serde_json::from_str(payload).ok(), - _ => None, - } -} - -fn is_agent_disabled_over_limit_error(error: &SetupError) -> bool { - matches!( - parse_proto_register_error(error), - Some(ProtoRegisterError::AgentDisabledOverLimit) - ) -} - fn init_tracing( log_filter: EnvFilter, use_ansi: bool, @@ -1269,7 +377,6 @@ fn init_tracing( Some(path) => { let writer = log_file_writer(path)?; let (non_blocking, guard) = tracing_appender::non_blocking(writer); - tracing_subscriber::registry() .with(log_filter) .with( @@ -1282,7 +389,6 @@ fn init_tracing( .with_filter(log_rate_limit_filter()), ) .init(); - Ok(Some(guard)) } None => { @@ -1298,7 +404,6 @@ fn init_tracing( .with_filter(log_rate_limit_filter()), ) .init(); - Ok(None) } } @@ -1306,25 +411,11 @@ fn init_tracing( #[cfg(target_os = "windows")] fn log_file_writer(path: &Path) -> Result { - windows_log_file_writer_with_limits( - path, - WINDOWS_LOG_MAX_FILE_SIZE_BYTES, - WINDOWS_LOG_MAX_ROTATED_FILES, - ) -} - -#[cfg(target_os = "windows")] -fn windows_log_file_writer_with_limits( - path: &Path, - max_file_size_bytes: u64, - max_rotated_files: usize, -) -> Result { create_log_parent_dir(path)?; - Ok(tracing_rolling_file::RollingFileAppenderBase::builder() .filename(path.display().to_string()) - .max_filecount(max_rotated_files) - .condition_max_file_size(max_file_size_bytes) + .max_filecount(WINDOWS_LOG_MAX_ROTATED_FILES) + .condition_max_file_size(WINDOWS_LOG_MAX_FILE_SIZE_BYTES) .build() .map_err(|error| { format!( @@ -1337,13 +428,11 @@ fn windows_log_file_writer_with_limits( #[cfg(not(target_os = "windows"))] fn log_file_writer(path: &Path) -> Result { create_log_parent_dir(path)?; - let parent = path.parent().unwrap_or_else(|| Path::new(".")); let file_name = path .file_name() .and_then(|file| file.to_str()) .ok_or_else(|| format!("Invalid --log-path {}", path.display()))?; - Ok(tracing_appender::rolling::never(parent, file_name)) } @@ -1359,206 +448,29 @@ fn create_log_parent_dir(path: &Path) -> Result<(), String> { #[cfg(test)] mod tests { - use std::time::{Duration, SystemTime, UNIX_EPOCH}; - - use super::{DaemonOptions, RunningSummary, run_daemon, setup_error_user_message}; - use playit_agent_core::agent_control::errors::{SetupError, TimeoutSource}; - use playit_api_client::api::{ApiResponseError, AuthError, ProtoRegisterError}; - use playit_ipc::ipc::IpcClient; - use playit_ipc::model::{ - AccountStatus as ServiceAccountStatus, AgentLifecycle, AgentState, ServicePhase, - TunnelState, - }; - - fn unique_test_path(name: &str, extension: &str) -> std::path::PathBuf { - std::env::temp_dir().join(format!( - "playitd-{name}-{}-{}.{}", - std::process::id(), - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(), - extension, - )) - } + use super::*; #[test] - fn setup_error_message_handles_connection_failure() { - let message = setup_error_user_message(&SetupError::FailedToConnect); - - assert!(message.contains("Could not connect to playit tunnel servers")); - assert!(message.contains("firewall")); + fn version_parser_ignores_prerelease_suffix() { + let version = VersionDetails::from_version_string("1.2.3-beta.1", "fixture").unwrap(); + assert_eq!(version.version_string(), "1.2.3"); + assert_eq!(version.variant_id, "fixture"); } #[test] - fn setup_error_message_handles_timeout() { - let message = setup_error_user_message(&SetupError::Timeout(TimeoutSource { - file_name: "test.rs", - line_no: 1, - })); - - assert!(message.contains("Timed out while connecting to playit")); - } - - #[test] - fn setup_error_message_handles_io_error() { - let message = setup_error_user_message(&SetupError::IoError(std::io::Error::new( - std::io::ErrorKind::AddrInUse, - "address already in use", - ))); - - assert!(message.contains("Could not open a required network socket")); - assert!(message.contains("address already in use")); - } - - #[test] - fn setup_error_message_handles_invalid_secret() { - let message = setup_error_user_message(&SetupError::ApiError(ApiResponseError::Auth( - AuthError::NoLongerValid, - ))); - - assert!(message.contains("secret is no longer valid")); - assert!(message.contains("playit setup")); - } - - #[test] - fn setup_error_message_handles_agent_limit() { - let payload = serde_json::to_string(&ProtoRegisterError::AgentDisabledOverLimit).unwrap(); - let message = setup_error_user_message(&SetupError::ApiFail(payload)); - - assert!(message.contains("over the agent limit")); - } - - #[test] - fn running_summary_counts_tunnel_state() { - let state = AgentState { - account_status: ServiceAccountStatus::Verified, - tunnels: vec![ - TunnelState { - is_disabled: false, - ..TunnelState::default() - }, - TunnelState { - is_disabled: true, - ..TunnelState::default() - }, - ], - pending_tunnels: vec![Default::default()], - ..AgentState::default() - }; - - let summary = RunningSummary::from_state(&state); - - assert_eq!(summary.tunnel_count, 2); - assert_eq!(summary.pending_tunnel_count, 1); - assert_eq!(summary.disabled_tunnel_count, 1); - assert_eq!(summary.account_status, "verified"); + fn setup_error_has_network_guidance() { + let message = setup_error_message(&SetupError::FailedToConnect); + assert!(message.contains("firewall")); } - #[cfg(target_os = "windows")] #[test] - fn windows_log_file_writer_rotates_by_size_and_file_count() { - use std::io::Write; - - let log_path = unique_test_path("rotating-log", "log"); - for suffix in ["", ".1", ".2", ".3"] { - let _ = std::fs::remove_file(format!("{}{suffix}", log_path.display())); - } - - { - let mut writer = super::windows_log_file_writer_with_limits(&log_path, 64, 2).unwrap(); - for _ in 0..8 { - writer - .write_all(b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\n") - .unwrap(); - } - writer.flush().unwrap(); - } - - let existing_log_files = ["", ".1", ".2", ".3"] - .iter() - .filter(|suffix| { - std::path::PathBuf::from(format!("{}{suffix}", log_path.display())).exists() - }) - .count(); - - assert!(log_path.exists()); - assert!(std::path::PathBuf::from(format!("{}.1", log_path.display())).exists()); - assert!(existing_log_files <= 3); - assert!(!std::path::PathBuf::from(format!("{}.3", log_path.display())).exists()); - - for suffix in ["", ".1", ".2", ".3"] { - let _ = std::fs::remove_file(format!("{}{suffix}", log_path.display())); - } - } - - async fn wait_for_waiting_for_secret(socket_path: &str) -> IpcClient { - let mut last_lifecycle = None; - - for _ in 0..50 { - match IpcClient::connect_with_path(socket_path).await { - Ok(mut client) => match client.lifecycle().await { - Ok(AgentLifecycle::WaitingForSecret) => return client, - Ok(lifecycle) => { - last_lifecycle = Some(format!("{lifecycle:?}")); - } - Err(error) => { - last_lifecycle = Some(format!("lifecycle error: {error}")); - } - }, - Err(error) => { - last_lifecycle = Some(format!("connect error: {error}")); - } - } - - tokio::time::sleep(Duration::from_millis(100)).await; - } - - panic!( - "daemon did not report WaitingForSecret over IPC; last observed state: {}", - last_lifecycle.unwrap_or_else(|| "none".to_string()) - ); - } - - #[tokio::test] - async fn missing_file_secret_reports_waiting_for_secret() { - let secret_path = unique_test_path("missing-secret", "toml"); - let socket_path = unique_test_path("missing-secret", "sock") - .display() - .to_string(); - let _ = std::fs::remove_file(&secret_path); - let _ = std::fs::remove_file(&socket_path); - - let daemon_handle = tokio::spawn(run_daemon(DaemonOptions { - secret: None, - secret_path: Some(secret_path.clone()), - socket_path: Some(socket_path.clone()), - log_path: None, - platform_docker: false, - ..DaemonOptions::default() - })); - - let mut client = wait_for_waiting_for_secret(&socket_path).await; - let status = client.status().await.unwrap(); - let expected_secret_path = secret_path.display().to_string(); - - assert!(matches!(status.phase, ServicePhase::WaitingForSecret)); - assert!(!status.has_secret); - assert_eq!( - status.secret_path.as_deref(), - Some(expected_secret_path.as_str()) - ); - - let stop_response = client.stop().await.unwrap(); - assert!(stop_response.accepted); - - let daemon_result = tokio::time::timeout(Duration::from_secs(5), daemon_handle) - .await - .expect("daemon did not stop after IPC stop request") - .expect("daemon task panicked"); - assert!(daemon_result.is_ok()); - - let _ = std::fs::remove_file(&secret_path); - let _ = std::fs::remove_file(&socket_path); + fn production_config_is_validated_before_runtime_construction() { + let error = validate_runtime_config( + "api.playit.gg".to_owned(), + "playit.toml".to_owned(), + "playit.sock".to_owned(), + ) + .unwrap_err(); + assert!(error.to_string().contains("invalid runtime configuration")); } } diff --git a/packages/playitd/src/ipc_server.rs b/packages/playitd/src/ipc_server.rs index 700d9e74..4c6e8030 100644 --- a/packages/playitd/src/ipc_server.rs +++ b/packages/playitd/src/ipc_server.rs @@ -8,8 +8,6 @@ use interprocess::local_socket::{ }; #[cfg(target_os = "windows")] use interprocess::os::windows::local_socket::ListenerOptionsExt; -use playit_agent_core::utils::now_milli; -use playit_api_client::PlayitApi; use playit_ipc::endpoint::IpcEndpoint; use playit_ipc::ipc::{ EventEnvelope, HelloEnvelope, IPC_VERSION, IncomingRequestEnvelope, IpcError, IpcFrameWriter, @@ -17,88 +15,239 @@ use playit_ipc::ipc::{ framed_parts, get_default_socket_path, is_known_request_type, protocol_info, try_connect, }; use playit_ipc::model::{ - AccountLoginUrlResponse, AgentLifecycle, CommandResponse, ConnectionStats, SecretPathResponse, - ServiceError, ServiceErrorCode, ServiceStatus, ServiceUpdate, SubscribeResponse, - SubscriptionSnapshot, + AccountLoginUrlResponse, AccountStatus, AgentLifecycle, AgentState, ClaimExchangeResponse, + ClaimProgressResponse, ClaimSessionResponse, CommandResponse, ConnectionStats, NoticeState, + PendingTunnelState, SecretPathResponse, ServiceError, ServiceErrorCode, ServicePhase, + ServiceStatus, ServiceUpdate, SubscribeResponse, SubscriptionSnapshot, TunnelState, +}; +use playit_model::{ + AppSnapshot, Command, Deadline, NoticePriority, OriginHost, OriginTarget, Phase, Problem, + ProblemCode, ProblemReport, RetryPolicy, SecretInput, SecretNeed, +}; +use playit_runtime::{ + ClaimProgress, CommandFailure, CommandOutput, SecretReset, SnapshotStore, SupervisorHandle, }; use serde_json::json; -use tokio::sync::{RwLock, broadcast, mpsc, oneshot}; +use tokio::sync::broadcast; +use tokio::task::JoinSet; use tokio_util::sync::CancellationToken; -const ACCOUNT_AGENTS_URL: &str = "https://playit.gg/account/agents"; -const ACCOUNT_UPGRADE_URL: &str = "https://playit.gg/account/upgrade"; - -#[derive(Default)] -pub struct StateCache { - lifecycle: RwLock, - status: RwLock, - stats: RwLock, +fn project_snapshot(snapshot: &AppSnapshot) -> SubscriptionSnapshot { + SubscriptionSnapshot { + status: project_status(snapshot), + lifecycle: project_lifecycle(snapshot), + stats: ConnectionStats { + bytes_in: snapshot.traffic.bytes_in, + bytes_out: snapshot.traffic.bytes_out, + active_tcp: snapshot.traffic.active_tcp, + active_udp: snapshot.traffic.active_udp, + }, + } } -impl StateCache { - pub async fn set_lifecycle(&self, lifecycle: AgentLifecycle) { - *self.lifecycle.write().await = lifecycle; +fn project_status(snapshot: &AppSnapshot) -> ServiceStatus { + let phase = match &snapshot.phase { + Phase::Booting | Phase::Starting { .. } => ServicePhase::Starting, + Phase::NeedsSecret { + reason: SecretNeed::Invalid, + } => ServicePhase::HasInvalidSecret, + Phase::NeedsSecret { .. } => ServicePhase::WaitingForSecret, + Phase::Blocked { problem, .. } if problem.code == ProblemCode::AgentDisabledOverLimit => { + ServicePhase::DisabledOverLimit + } + Phase::Blocked { .. } => ServicePhase::Error, + Phase::Online { .. } => ServicePhase::Running, + Phase::Stopping | Phase::Stopped => ServicePhase::Stopping, + }; + ServiceStatus { + phase, + pid: snapshot.service.process_id.unwrap_or_default(), + uptime_secs: snapshot.service.uptime_secs, + version: snapshot.service.version.clone().unwrap_or_default(), + socket_path: snapshot.service.ipc_endpoint.clone().unwrap_or_default(), + secret_path: snapshot.service.secret_location.clone(), + has_secret: snapshot.service.has_secret, + protocol: protocol_info(), + last_error: project_snapshot_problem(snapshot), } +} - pub async fn lifecycle(&self) -> AgentLifecycle { - self.lifecycle.read().await.clone() +fn project_lifecycle(snapshot: &AppSnapshot) -> AgentLifecycle { + match &snapshot.phase { + Phase::Booting | Phase::Starting { .. } => AgentLifecycle::Starting, + Phase::NeedsSecret { + reason: SecretNeed::Invalid, + } => AgentLifecycle::HasInvalidSecret(project_last_problem(snapshot)), + Phase::NeedsSecret { .. } => AgentLifecycle::WaitingForSecret, + Phase::Blocked { problem, .. } if problem.code == ProblemCode::AgentDisabledOverLimit => { + AgentLifecycle::DisabledOverLimit(project_last_problem(snapshot)) + } + Phase::Blocked { .. } => AgentLifecycle::Error(project_last_problem(snapshot)), + Phase::Online { agent } => AgentLifecycle::Running(AgentState { + version: snapshot.service.version.clone().unwrap_or_default(), + tunnels: snapshot + .catalog + .accepted + .tunnels() + .iter() + .map(|tunnel| TunnelState { + display_address: tunnel.public_address.clone(), + destination: project_origin_target(&tunnel.spec.target), + is_disabled: tunnel.disabled_reason.is_some(), + disabled_reason: tunnel.disabled_reason.clone(), + }) + .collect(), + pending_tunnels: snapshot + .catalog + .pending + .iter() + .map(|tunnel| PendingTunnelState { + id: tunnel.id.clone(), + status_msg: tunnel.status.clone(), + }) + .collect(), + notices: snapshot + .notices + .iter() + .filter_map(|notice| notice.content.as_ref()) + .map(|notice| NoticeState { + priority: match notice.priority { + NoticePriority::Info => "info", + NoticePriority::Critical => "Critical", + NoticePriority::High => "High", + NoticePriority::Low => "Low", + } + .to_owned(), + message: notice.message.clone(), + resolve_link: notice.resolve_url.clone(), + }) + .collect(), + account_status: match snapshot.catalog.account_status { + playit_model::AccountStatus::Unknown => AccountStatus::Unknown, + playit_model::AccountStatus::Guest => AccountStatus::Guest, + playit_model::AccountStatus::EmailNotVerified => AccountStatus::EmailNotVerified, + playit_model::AccountStatus::Verified => AccountStatus::Verified, + }, + agent_id: agent.as_str().to_owned(), + login_link: snapshot.catalog.login_url.clone(), + start_time: snapshot.service.started_at_millis, + }), + Phase::Stopping | Phase::Stopped => AgentLifecycle::Stopping, } +} - pub async fn set_status(&self, status: ServiceStatus) { - *self.status.write().await = status; +fn project_origin_target(target: &OriginTarget) -> String { + let host = |host: &OriginHost| match host { + OriginHost::Ip(address) => address.to_string(), + OriginHost::Hostname(hostname) => hostname.as_str().to_owned(), + }; + match target { + OriginTarget::Port { + host: origin, + first_port, + } => { + format!("{}:{}", host(origin), first_port.get()) + } + OriginTarget::Https { + host: origin, + http_port, + https_port, + } => format!( + "{} (http: {}, https: {})", + host(origin), + http_port.get(), + https_port.get() + ), } +} - pub async fn status(&self) -> ServiceStatus { - self.status.read().await.clone() - } +fn project_last_problem(snapshot: &AppSnapshot) -> ServiceError { + project_snapshot_problem(snapshot) + .unwrap_or_else(|| protocol_error(ServiceErrorCode::Internal, String::new(), false)) +} - pub async fn set_stats(&self, stats: ConnectionStats) { - *self.stats.write().await = stats; +fn project_snapshot_problem(snapshot: &AppSnapshot) -> Option { + match &snapshot.phase { + Phase::Blocked { problem, retry_at } => { + let retry_at_millis = match retry_at { + Deadline::AtMillis(value) => Some(*value), + Deadline::Unscheduled => None, + }; + Some(ServiceError { + code: service_error_code(problem.code), + message: snapshot + .last_problem + .as_ref() + .map(|report| report.detail.clone()) + .unwrap_or_else(|| problem.code.as_str().to_owned()), + retryable: problem.metadata.retry != RetryPolicy::Never, + details: Some(problem_details(problem, retry_at_millis)), + }) + } + _ => snapshot.last_problem.as_ref().map(project_problem), } +} - pub async fn stats(&self) -> ConnectionStats { - self.stats.read().await.clone() +fn project_problem(report: &ProblemReport) -> ServiceError { + let code = service_error_code(report.problem.code); + ServiceError { + code, + message: report.detail.clone(), + retryable: report.problem.metadata.retry != RetryPolicy::Never, + details: Some(problem_details(&report.problem, None)), } +} - pub async fn subscription_snapshot(&self) -> SubscriptionSnapshot { - SubscriptionSnapshot { - status: self.status().await, - lifecycle: self.lifecycle().await, - stats: self.stats().await, +fn service_error_code(code: ProblemCode) -> ServiceErrorCode { + match code { + ProblemCode::UnsupportedProtocol => ServiceErrorCode::UnsupportedProtocol, + ProblemCode::InvalidRequest | ProblemCode::CommandNotAllowed => { + ServiceErrorCode::InvalidRequest } + ProblemCode::AgentDisabledOverLimit => ServiceErrorCode::AgentDisabledOverLimit, + ProblemCode::InvalidSecret => ServiceErrorCode::InvalidSecret, + ProblemCode::SecretPinned => ServiceErrorCode::SecretPinned, + ProblemCode::ProvisioningUnavailable => ServiceErrorCode::ProvisioningUnavailable, + ProblemCode::SecretWriteFailed => ServiceErrorCode::SecretWriteFailed, + _ => ServiceErrorCode::Internal, } } -pub struct SecretProvisionRequest { - pub secret: String, - pub response_tx: oneshot::Sender>, +fn problem_details(problem: &Problem, retry_at_millis: Option) -> serde_json::Value { + json!({ + "problem_code": problem.code.as_str(), + "severity": problem.metadata.severity.as_str(), + "retry": problem.metadata.retry.as_str(), + "action": problem.metadata.action.as_str(), + "retry_at_millis": retry_at_millis, + }) +} + +pub struct IpcServerConfig { + pub socket_path: Option, + pub snapshots: Arc, + pub secret_path: Option, + pub commands: Option, } pub struct IpcServer { - event_tx: broadcast::Sender, + log_tx: broadcast::Sender, socket_path: String, - start_time: u64, cancel_token: CancellationToken, - state_cache: Arc, + snapshots: Arc, secret_path: Option, - secret_provision_tx: Option>, - secret_provision_error: ServiceError, - secret_reset_error: ServiceError, - api: RwLock>, - guest_login_cache: RwLock>, + commands: Option, } impl IpcServer { pub async fn new_with_sender( - socket_path: Option, + config: IpcServerConfig, cancel_token: CancellationToken, - event_tx: broadcast::Sender, - secret_path: Option, - secret_provision_tx: Option>, - secret_provision_error: ServiceError, - secret_reset_error: ServiceError, + log_tx: broadcast::Sender, ) -> Result { - let socket_path = socket_path.unwrap_or_else(|| get_default_socket_path().to_string()); + let socket_path = config + .socket_path + .unwrap_or_else(|| get_default_socket_path().to_string()); let endpoint = IpcEndpoint::parse(socket_path.clone()); if try_connect(&endpoint).await.is_ok() { @@ -119,57 +268,35 @@ impl IpcServer { } Ok(Self { - event_tx, + log_tx, socket_path, - start_time: now_milli(), cancel_token, - state_cache: Arc::new(StateCache::default()), - secret_path, - secret_provision_tx, - secret_provision_error, - secret_reset_error, - api: RwLock::new(None), - guest_login_cache: RwLock::new(None), + snapshots: config.snapshots, + secret_path: config.secret_path, + commands: config.commands, }) } - pub fn event_sender(&self) -> broadcast::Sender { - self.event_tx.clone() - } - - pub fn state_cache(&self) -> Arc { - self.state_cache.clone() - } - - pub async fn set_api(&self, api: PlayitApi) { - *self.api.write().await = Some(api); - } - pub async fn bind_listener(&self) -> Result { let listener = self.create_listener()?; #[cfg(target_os = "linux")] - crate::linux::configure_socket_permissions(&self.socket_path)?; + playit_platform::linux::configure_socket_permissions(&self.socket_path) + .map_err(IpcError::BindFailed)?; Ok(listener) } pub async fn run(self: Arc, listener: Listener) -> Result<(), IpcError> { + let mut clients = JoinSet::new(); + loop { tokio::select! { accept_result = listener.accept() => { match accept_result { Ok(stream) => { let server = self.clone(); - tokio::spawn(async move { - if let Err(e) = server.handle_client(stream).await { - if e.is_connection_closed() { - tracing::debug!("Client disconnected: {e}"); - } else { - tracing::warn!("Client connection error: {e}"); - } - } - }); + clients.spawn(async move { server.handle_client(stream).await }); } Err(e) => { tracing::error!("Accept error: {e}"); @@ -178,6 +305,20 @@ impl IpcServer { } } } + joined = clients.join_next(), if !clients.is_empty() => { + match joined { + Some(Ok(Ok(()))) | None => {} + Some(Ok(Err(error))) if error.is_connection_closed() => { + tracing::debug!("Client disconnected: {error}"); + } + Some(Ok(Err(error))) => { + tracing::warn!("Client connection error: {error}"); + } + Some(Err(error)) => { + tracing::warn!("IPC client task failed: {error}"); + } + } + } _ = self.cancel_token.cancelled() => { tracing::info!("IPC server shutting down"); break; @@ -185,6 +326,9 @@ impl IpcServer { } } + clients.abort_all(); + while clients.join_next().await.is_some() {} + Ok(()) } @@ -203,8 +347,10 @@ impl IpcServer { })?; let listener = ListenerOptions::new().name(name); #[cfg(target_os = "windows")] - let listener = listener - .security_descriptor(crate::windows::restricted_pipe_security_descriptor()?); + let listener = listener.security_descriptor( + playit_platform::windows::restricted_pipe_security_descriptor() + .map_err(IpcError::BindFailed)?, + ); listener.create_tokio().map_err(IpcError::BindFailed) } IpcEndpoint::Filesystem(path) => { @@ -213,8 +359,10 @@ impl IpcServer { })?; let listener = ListenerOptions::new().name(name); #[cfg(target_os = "windows")] - let listener = listener - .security_descriptor(crate::windows::restricted_pipe_security_descriptor()?); + let listener = listener.security_descriptor( + playit_platform::windows::restricted_pipe_security_descriptor() + .map_err(IpcError::BindFailed)?, + ); listener.create_tokio().map_err(IpcError::BindFailed) } } @@ -223,8 +371,10 @@ impl IpcServer { async fn handle_client(&self, stream: Stream) -> Result<(), IpcError> { let (reader, writer) = stream.split(); let (mut reader, mut writer) = framed_parts(reader, writer); - let mut event_rx = self.event_tx.subscribe(); + let mut log_rx = self.log_tx.subscribe(); + let mut snapshot_rx = self.snapshots.subscribe(); let mut subscribed = false; + let mut last_projection: Option = None; self.send_hello(&mut writer).await?; @@ -237,15 +387,30 @@ impl IpcServer { if outcome.subscribed { subscribed = true; + last_projection = outcome.subscription.clone(); } self.send_response(&mut writer, request_id, outcome.response).await?; } - event_result = event_rx.recv(), if subscribed => { - match event_result { - Ok(event) => self.send_event(&mut writer, event).await?, + snapshot_result = snapshot_rx.changed(), if subscribed => { + match snapshot_result { + Ok(()) => { + let snapshot = snapshot_rx.borrow_and_update().clone(); + let projection = project_snapshot(&snapshot); + self.send_snapshot_changes(&mut writer, last_projection.as_ref(), &projection).await?; + last_projection = Some(projection); + } + Err(_) => break, + } + } + log_result = log_rx.recv(), if subscribed => { + match log_result { + Ok(ServiceUpdate::Log(log)) => { + self.send_event(&mut writer, ServiceUpdate::Log(log)).await? + } + Ok(_) => {} Err(broadcast::error::RecvError::Lagged(_)) => { - tracing::debug!("Client lagged behind, some events dropped"); + tracing::debug!("Client lagged behind, some log events were dropped"); } Err(broadcast::error::RecvError::Closed) => break, } @@ -259,23 +424,23 @@ impl IpcServer { async fn handle_request_envelope(&self, envelope: IncomingRequestEnvelope) -> RequestOutcome { match self.validate_request_envelope(envelope) { Ok(request) => self.handle_service_request(request).await, - Err(response) => RequestOutcome::respond(response), + Err(response) => RequestOutcome::respond(*response), } } fn validate_request_envelope( &self, envelope: IncomingRequestEnvelope, - ) -> Result { + ) -> Result> { if envelope.ipc_version != IPC_VERSION { - return Err(ServiceResponse::Error(protocol_error( + return Err(Box::new(ServiceResponse::Error(protocol_error( ServiceErrorCode::UnsupportedProtocol, format!( "unsupported IPC version {} (expected {})", envelope.ipc_version, IPC_VERSION ), false, - ))); + )))); } match envelope.request { @@ -283,35 +448,104 @@ impl IpcServer { ServiceRequestOrUnknown::Unknown(unknown) if is_known_request_type(&unknown.type_name) => { - Err(ServiceResponse::Error(protocol_error( + Err(Box::new(ServiceResponse::Error(protocol_error( ServiceErrorCode::InvalidRequest, format!("invalid IPC request payload for {}", unknown.type_name), false, - ))) + )))) } - ServiceRequestOrUnknown::Unknown(unknown) => Err(ServiceResponse::Error( + ServiceRequestOrUnknown::Unknown(unknown) => Err(Box::new(ServiceResponse::Error( invalid_request_type_error(&unknown.type_name), - )), + ))), } } async fn handle_service_request(&self, request: ServiceRequest) -> RequestOutcome { match request { - ServiceRequest::Subscribe => RequestOutcome { - response: self.subscribe_response().await, - subscribed: true, - }, - ServiceRequest::GetStatus => RequestOutcome::respond(self.status_response().await), - ServiceRequest::GetState => { - RequestOutcome::respond(ServiceResponse::State(self.state_cache.lifecycle().await)) + ServiceRequest::Subscribe => { + let snapshot = project_snapshot(&self.snapshots.snapshot()); + RequestOutcome { + response: self.subscribe_response(snapshot.clone()), + subscribed: true, + subscription: Some(snapshot), + } } + ServiceRequest::GetStatus => RequestOutcome::respond(self.status_response().await), + ServiceRequest::GetState => RequestOutcome::respond(ServiceResponse::State( + project_lifecycle(&self.snapshots.snapshot()), + )), ServiceRequest::Stop => { tracing::info!("Stop request received, initiating shutdown"); - self.cancel_token.cancel(); - RequestOutcome::respond(ServiceResponse::Stop(CommandResponse { - accepted: true, - message: Some("shutdown requested".to_string()), - })) + let response = match self.command(Command::Stop).await { + Ok(CommandOutput::Accepted) => ServiceResponse::Stop(CommandResponse { + accepted: true, + message: Some("shutdown requested".to_string()), + }), + Ok(_) => ServiceResponse::Error(provisioning_unavailable_error()), + Err(error) => ServiceResponse::Error(command_error(error)), + }; + RequestOutcome::respond(response) + } + ServiceRequest::BeginClaim => { + let response = match self.command(Command::BeginClaim).await { + Ok(CommandOutput::ClaimStarted(session)) => { + ServiceResponse::ClaimSession(ClaimSessionResponse { + claim_code: session.code, + claim_url: session.url, + }) + } + Ok(_) => ServiceResponse::Error(provisioning_unavailable_error()), + Err(error) => ServiceResponse::Error(command_error(error)), + }; + RequestOutcome::respond(response) + } + ServiceRequest::PollClaim { claim_code } => { + let response = match self.command(Command::PollClaim { code: claim_code }).await { + Ok(CommandOutput::ClaimProgress(progress)) => { + ServiceResponse::ClaimProgress(match progress { + ClaimProgress::WaitingForVisit => { + ClaimProgressResponse::WaitingForVisit + } + ClaimProgress::WaitingForApproval => { + ClaimProgressResponse::WaitingForApproval + } + ClaimProgress::Approved => ClaimProgressResponse::Approved, + ClaimProgress::Rejected => ClaimProgressResponse::Rejected, + }) + } + Ok(_) => ServiceResponse::Error(provisioning_unavailable_error()), + Err(error) => ServiceResponse::Error(command_error(error)), + }; + RequestOutcome::respond(response) + } + ServiceRequest::ExchangeClaim { claim_code } => { + let response = match self + .command(Command::ExchangeClaim { code: claim_code }) + .await + { + Ok(CommandOutput::ClaimPending(status)) => { + ServiceResponse::ClaimExchange(ClaimExchangeResponse::Pending(status)) + } + Ok(CommandOutput::ClaimProvisioned) => { + ServiceResponse::ClaimExchange(ClaimExchangeResponse::Accepted) + } + Ok(_) => ServiceResponse::Error(provisioning_unavailable_error()), + Err(error) => ServiceResponse::Error(command_error(error)), + }; + RequestOutcome::respond(response) + } + ServiceRequest::RefreshCatalog => { + let response = match self.command(Command::RefreshCatalog).await { + Ok(CommandOutput::Accepted) => { + ServiceResponse::RefreshCatalog(CommandResponse { + accepted: true, + message: Some("catalog refresh requested".to_owned()), + }) + } + Ok(_) => ServiceResponse::Error(provisioning_unavailable_error()), + Err(error) => ServiceResponse::Error(command_error(error)), + }; + RequestOutcome::respond(response) } ServiceRequest::SetSecret { secret } => { RequestOutcome::respond(self.set_secret_response(secret).await) @@ -333,8 +567,7 @@ impl IpcServer { } } - async fn subscribe_response(&self) -> ServiceResponse { - let snapshot = self.state_cache.subscription_snapshot().await; + fn subscribe_response(&self, snapshot: SubscriptionSnapshot) -> ServiceResponse { ServiceResponse::Subscribe(SubscribeResponse { protocol: protocol_info(), snapshot, @@ -342,71 +575,59 @@ impl IpcServer { } async fn status_response(&self) -> ServiceResponse { - let mut status = self.state_cache.status().await; - let uptime_ms = now_milli().saturating_sub(self.start_time); - status.uptime_secs = uptime_ms / 1000; - ServiceResponse::Status(status) + ServiceResponse::Status(project_status(&self.snapshots.snapshot())) } async fn set_secret_response(&self, secret: String) -> ServiceResponse { - let lifecycle = self.state_cache.lifecycle().await; - if !matches!(lifecycle, AgentLifecycle::WaitingForSecret) { - return ServiceResponse::Error(secret_provisioning_state_error(&lifecycle)); - } - - let Some(secret_provision_tx) = &self.secret_provision_tx else { - return ServiceResponse::Error(self.secret_provision_error.clone()); + let secret = match SecretInput::new(secret) { + Ok(secret) => secret, + Err(_) => { + return ServiceResponse::Error(command_error( + CommandFailure::new(ProblemCode::InvalidSecret).with_detail("secret is empty"), + )); + } }; - - let (response_tx, response_rx) = oneshot::channel(); - if secret_provision_tx - .send(SecretProvisionRequest { - secret, - response_tx, - }) - .await - .is_err() - { - return ServiceResponse::Error(provisioning_unavailable_error()); - } - - match response_rx.await { - Ok(Ok(())) => ServiceResponse::SetSecret(CommandResponse { + match self.command(Command::ProvisionSecret { secret }).await { + Ok(CommandOutput::Accepted) => ServiceResponse::SetSecret(CommandResponse { accepted: true, message: Some("secret provisioned".to_string()), }), - Ok(Err(message)) => ServiceResponse::Error(protocol_error( - ServiceErrorCode::SecretWriteFailed, - message, - true, - )), - Err(_) => ServiceResponse::Error(provisioning_unavailable_error()), + Ok(_) => ServiceResponse::Error(provisioning_unavailable_error()), + Err(error) => ServiceResponse::Error(command_error(error)), } } async fn reset_secret_response(&self) -> ServiceResponse { - match self.reset_secret().await { - Ok(message) => { + match self.command(Command::ResetSecret).await { + Ok(CommandOutput::SecretReset(reset)) => { tracing::info!("Secret reset, initiating shutdown"); - self.cancel_token.cancel(); ServiceResponse::ResetSecret(CommandResponse { accepted: true, - message: Some(message), + message: Some(reset_message(reset)), }) } - Err(error) => ServiceResponse::Error(error), + Ok(_) => ServiceResponse::Error(provisioning_unavailable_error()), + Err(error) => ServiceResponse::Error(command_error(error)), } } async fn account_login_url_response(&self) -> ServiceResponse { - match self.get_account_login_url().await { - Ok(login_url) => { + match self.command(Command::CreateLoginUrl).await { + Ok(CommandOutput::LoginUrl(login_url)) => { ServiceResponse::AccountLoginUrl(AccountLoginUrlResponse { login_url }) } - Err(error) => ServiceResponse::Error(error), + Ok(_) => ServiceResponse::Error(provisioning_unavailable_error()), + Err(error) => ServiceResponse::Error(command_error(error)), } } + async fn command(&self, command: Command) -> Result { + let Some(commands) = &self.commands else { + return Err(CommandFailure::new(ProblemCode::EngineUnavailable)); + }; + commands.command(command).await + } + async fn send_response( &self, writer: &mut IpcFrameWriter, @@ -446,69 +667,55 @@ impl IpcServer { .await } - async fn reset_secret(&self) -> Result { - let Some(secret_path) = &self.secret_path else { - return Err(self.secret_reset_error.clone()); - }; - - match tokio::fs::remove_file(secret_path).await { - Ok(()) => Ok(format!( - "Deleted secret file at {}. Restart playitd to reprovision a new secret.", - secret_path.display() - )), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(format!( - "Secret file was already absent at {}.", - secret_path.display() - )), - Err(error) => Err(protocol_error( - ServiceErrorCode::SecretWriteFailed, - format!( - "Failed to delete secret file {}: {error}", - secret_path.display() - ), - true, - )), + async fn send_snapshot_changes( + &self, + writer: &mut IpcFrameWriter, + previous: Option<&SubscriptionSnapshot>, + current: &SubscriptionSnapshot, + ) -> Result<(), IpcError> { + if previous.is_none_or(|value| !same_wire_value(&value.status, ¤t.status)) { + self.send_event(writer, ServiceUpdate::Status(current.status.clone())) + .await?; } + if previous.is_none_or(|value| !same_wire_value(&value.lifecycle, ¤t.lifecycle)) { + self.send_event(writer, ServiceUpdate::Lifecycle(current.lifecycle.clone())) + .await?; + } + if previous.is_none_or(|value| !same_wire_value(&value.stats, ¤t.stats)) { + self.send_event(writer, ServiceUpdate::Stats(current.stats.clone())) + .await?; + } + Ok(()) } +} - async fn get_account_login_url(&self) -> Result { - { - let cache = self.guest_login_cache.read().await; - if let Some((link, ts)) = &*cache { - if now_milli().saturating_sub(*ts) < 15_000 { - return Ok(link.clone()); - } - } +fn reset_message(reset: SecretReset) -> String { + match reset { + SecretReset::Deleted(path) => format!( + "Deleted secret file at {}. Restart playitd to reprovision a new secret.", + path.display() + ), + SecretReset::AlreadyAbsent(path) => { + format!("Secret file was already absent at {}.", path.display()) } + } +} - let api = self.api.read().await.clone().ok_or_else(|| { - protocol_error( - ServiceErrorCode::InvalidRequest, - "playitd is not ready to generate a login URL yet".to_string(), - true, - ) - })?; - - let session = api.login_guest().await.map_err(|error| { - protocol_error( - ServiceErrorCode::Internal, - format!("Failed to create login URL: {error:?}"), - true, - ) - })?; - - let link = format!( - "https://playit.gg/login/guest-account/{}", - session.session_key - ); - *self.guest_login_cache.write().await = Some((link.clone(), now_milli())); - Ok(link) +fn command_error(error: CommandFailure) -> ServiceError { + ServiceError { + code: service_error_code(error.problem.code), + message: error + .detail + .unwrap_or_else(|| error.problem.code.as_str().to_owned()), + retryable: error.problem.metadata.retry != RetryPolicy::Never, + details: Some(problem_details(&error.problem, error.retry_at_millis)), } } struct RequestOutcome { response: ServiceResponse, subscribed: bool, + subscription: Option, } impl RequestOutcome { @@ -516,10 +723,16 @@ impl RequestOutcome { Self { response, subscribed: false, + subscription: None, } } } +fn same_wire_value(left: &T, right: &T) -> bool { + serde_json::to_vec(left).expect("IPC model serializes") + == serde_json::to_vec(right).expect("IPC model serializes") +} + fn protocol_error(code: ServiceErrorCode, message: String, retryable: bool) -> ServiceError { ServiceError { code, @@ -530,11 +743,7 @@ fn protocol_error(code: ServiceErrorCode, message: String, retryable: bool) -> S } fn provisioning_unavailable_error() -> ServiceError { - protocol_error( - ServiceErrorCode::ProvisioningUnavailable, - "playitd is no longer waiting for secret provisioning".to_string(), - true, - ) + command_error(CommandFailure::new(ProblemCode::ProvisioningUnavailable)) } fn invalid_request_type_error(request_type: &str) -> ServiceError { @@ -546,77 +755,26 @@ fn invalid_request_type_error(request_type: &str) -> ServiceError { } } -fn over_limit_guidance() -> String { - format!( - "Delete unused agents: {ACCOUNT_AGENTS_URL}\nIncrease your agent limit: {ACCOUNT_UPGRADE_URL}" - ) -} - -fn secret_provisioning_state_error(lifecycle: &AgentLifecycle) -> ServiceError { - match lifecycle { - AgentLifecycle::WaitingForSecret => protocol_error( - ServiceErrorCode::ProvisioningUnavailable, - "The playit service is not ready to save a secret yet. Try setup again in a few seconds." - .to_string(), - true, - ), - AgentLifecycle::HasInvalidSecret(error) => protocol_error( - ServiceErrorCode::ProvisioningUnavailable, - format!( - "The playit service is not waiting for a new secret because its current secret is invalid: {}", - error.message - ), - false, - ), - AgentLifecycle::DisabledOverLimit(error) => protocol_error( - ServiceErrorCode::ProvisioningUnavailable, - format!( - "Setup is unavailable because this account is over the agent limit.\n{}\nReason: {}", - over_limit_guidance(), - error.message - ), - false, - ), - AgentLifecycle::Starting => protocol_error( - ServiceErrorCode::ProvisioningUnavailable, - "The playit service is still starting. Try setup again in a few seconds.".to_string(), - true, - ), - AgentLifecycle::Running(_) => protocol_error( - ServiceErrorCode::ProvisioningUnavailable, - "The playit service already has a configured secret. Run `playit reset` before provisioning a new one." - .to_string(), - false, - ), - AgentLifecycle::Stopping => protocol_error( - ServiceErrorCode::ProvisioningUnavailable, - "The playit service is stopping and cannot accept setup right now.".to_string(), - true, - ), - AgentLifecycle::Error(error) => protocol_error( - ServiceErrorCode::ProvisioningUnavailable, - format!( - "The playit service reported an error and cannot accept setup right now: {}", - error.message - ), - true, - ), - } -} - #[cfg(test)] mod tests { + use std::num::{NonZeroU16, NonZeroU64}; use std::sync::Arc; - use std::time::{SystemTime, UNIX_EPOCH}; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; - use super::{IpcServer, protocol_error, try_connect}; + use super::{IpcServer, IpcServerConfig, SnapshotStore, project_snapshot, try_connect}; use interprocess::local_socket::tokio::prelude::*; use playit_ipc::endpoint::IpcEndpoint; use playit_ipc::ipc::{ - IPC_VERSION, IpcClient, IpcError, RequestEnvelope, ServerEnvelope, ServiceRequest, - ServiceResponse, + IPC_VERSION, IpcClient, IpcError, RequestEnvelope, ResponseEnvelope, ServerEnvelope, + ServiceRequest, ServiceResponse, protocol_info, + }; + use playit_ipc::model::{AgentLifecycle, ServiceErrorCode, SubscribeResponse}; + use playit_model::{ + AgentIdentity, AppSnapshot, CatalogTunnel, Notice, NoticeContent, NoticePriority, + OriginHost, OriginTarget, PendingTunnel, Phase, Problem, ProblemCode, ProxyProtocol, + ServiceInfo, TrafficSnapshot, TunnelAvailability, TunnelCatalog, TunnelId, TunnelProtocol, + TunnelSpec, }; - use playit_ipc::model::{AgentLifecycle, ServiceErrorCode}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter}; use tokio::sync::broadcast; use tokio_util::sync::CancellationToken; @@ -635,6 +793,150 @@ mod tests { .to_string() } + #[tokio::test] + async fn snapshot_revision_increments_once_per_visible_publication() { + let store = SnapshotStore::new(AppSnapshot::booting()); + let published = store.publish(|snapshot| { + snapshot.phase = Phase::Starting { attempt: 1 }; + snapshot.traffic.bytes_in = 10; + snapshot.service.has_secret = true; + }); + assert_eq!(published.unwrap().revision, 1); + + let unchanged = store.publish(|snapshot| { + snapshot.phase = Phase::Starting { attempt: 1 }; + snapshot.traffic.bytes_in = 10; + snapshot.service.has_secret = true; + }); + assert!(unchanged.is_none()); + assert_eq!(store.snapshot().revision, 1); + } + + #[tokio::test] + async fn slow_snapshot_subscriber_receives_latest_complete_revision() { + let store = SnapshotStore::new(AppSnapshot::booting()); + let mut receiver = store.subscribe(); + + for value in 1..=100 { + store.publish(|snapshot| { + snapshot.traffic.bytes_in = value; + snapshot.traffic.bytes_out = value * 2; + }); + } + + receiver.changed().await.unwrap(); + let snapshot = receiver.borrow_and_update().clone(); + assert_eq!(snapshot.revision, 100); + assert_eq!(snapshot.traffic.bytes_in, 100); + assert_eq!(snapshot.traffic.bytes_out, 200); + } + + #[tokio::test] + async fn concurrent_snapshot_publishers_do_not_lose_changes() { + let store = Arc::new(SnapshotStore::new(AppSnapshot::booting())); + let mut tasks = Vec::new(); + for _ in 0..4 { + let store = store.clone(); + tasks.push(tokio::spawn(async move { + for _ in 0..250 { + store.publish(|snapshot| snapshot.traffic.bytes_in += 1); + tokio::task::yield_now().await; + } + })); + } + for task in tasks { + task.await.unwrap(); + } + + let snapshot = store.snapshot(); + assert_eq!(snapshot.revision, 1_000); + assert_eq!(snapshot.traffic.bytes_in, 1_000); + } + + #[test] + fn ipc_v2_subscription_projection_matches_golden_json() { + let mut snapshot = AppSnapshot::booting(); + snapshot.phase = Phase::Online { + agent: AgentIdentity::new("agent-1"), + }; + snapshot.service = ServiceInfo { + process_id: Some(4242), + uptime_secs: 17, + started_at_millis: 1_700_000_000_000, + version: Some("1.0.10".to_owned()), + ipc_protocol: IPC_VERSION, + ipc_endpoint: Some("/run/playit/playitd.sock".to_owned()), + secret_location: Some("/etc/playit/playit.toml".to_owned()), + has_secret: true, + }; + snapshot.catalog.accepted = TunnelCatalog::try_new( + 1, + 1_700_000_000_000, + vec![CatalogTunnel { + spec: TunnelSpec { + id: TunnelId::new(NonZeroU64::MIN), + protocol: TunnelProtocol::Tcp, + target: OriginTarget::Port { + host: OriginHost::Ip("127.0.0.1".parse().unwrap()), + first_port: NonZeroU16::new(25565).unwrap(), + }, + port_count: NonZeroU16::MIN, + proxy_protocol: ProxyProtocol::None, + }, + availability: TunnelAvailability::Active, + public_address: "demo.playit.gg:25565".to_owned(), + disabled_reason: None, + }], + ) + .unwrap(); + snapshot.catalog.pending = vec![PendingTunnel { + id: "pending-1".to_owned(), + status: "allocating".to_owned(), + }]; + snapshot.catalog.account_status = playit_model::AccountStatus::Verified; + snapshot.catalog.login_url = Some("https://playit.gg/login/fixture".to_owned()); + snapshot.traffic = TrafficSnapshot { + bytes_in: 10, + bytes_out: 20, + active_tcp: 1, + active_udp: 2, + }; + snapshot.notices = vec![Notice { + problem: Problem::new(ProblemCode::RemoteNotice), + content: Some(NoticeContent { + priority: NoticePriority::Info, + message: "fixture notice".to_owned(), + resolve_url: Some("https://playit.gg/account".to_owned()), + }), + }]; + + let envelope = ServerEnvelope::Response(ResponseEnvelope { + ipc_version: IPC_VERSION, + request_id: 1, + response: ServiceResponse::Subscribe(SubscribeResponse { + protocol: protocol_info(), + snapshot: project_snapshot(&snapshot), + }), + }); + let expected = include_str!("../../playit-ipc/fixtures/ipc_v2_server_transcript.jsonl") + .lines() + .nth(1) + .unwrap(); + assert_eq!(serde_json::to_string(&envelope).unwrap(), expected); + } + + #[test] + fn every_application_problem_projects_stable_action_and_retry_metadata() { + for code in ProblemCode::ALL { + let problem = Problem::new(code); + let wire = super::command_error(playit_runtime::CommandFailure::new(code)); + let meaning = wire.meaning(); + assert_eq!(meaning.code, code.as_str()); + assert_eq!(meaning.action, problem.metadata.action.as_str()); + assert_eq!(meaning.retry, problem.metadata.retry.as_str()); + } + } + async fn spawn_test_server( name: &str, ) -> ( @@ -648,21 +950,14 @@ mod tests { let (event_tx, _) = broadcast::channel(8); let server = Arc::new( IpcServer::new_with_sender( - Some(socket_path.clone()), + IpcServerConfig { + socket_path: Some(socket_path.clone()), + snapshots: Arc::new(SnapshotStore::new(AppSnapshot::booting())), + secret_path: None, + commands: None, + }, cancel_token.clone(), event_tx, - None, - None, - protocol_error( - ServiceErrorCode::ProvisioningUnavailable, - "provisioning unavailable".to_string(), - false, - ), - protocol_error( - ServiceErrorCode::SecretWriteFailed, - "secret reset unavailable".to_string(), - false, - ), ) .await .unwrap(), @@ -712,6 +1007,26 @@ mod tests { shutdown_server(cancel_token, handle).await; } + #[tokio::test] + async fn shutdown_closes_active_client_tasks() { + let (_server, cancel_token, handle, socket_path) = + spawn_test_server("shutdown-client").await; + let (mut reader, _writer) = connect_raw(&socket_path).await; + assert!(matches!( + read_server_envelope(&mut reader).await, + ServerEnvelope::Hello(_) + )); + + shutdown_server(cancel_token, handle).await; + + let mut line = String::new(); + let bytes_read = tokio::time::timeout(Duration::from_secs(1), reader.read_line(&mut line)) + .await + .expect("server shutdown closes the client promptly") + .unwrap(); + assert_eq!(bytes_read, 0); + } + #[tokio::test] async fn unknown_request_type_returns_error_and_connection_stays_open() { let (_server, cancel_token, handle, socket_path) = spawn_test_server("unknown-type").await; diff --git a/packages/playitd/src/lib.rs b/packages/playitd/src/lib.rs index 4926150f..06bb6c74 100644 --- a/packages/playitd/src/lib.rs +++ b/packages/playitd/src/lib.rs @@ -1,26 +1,8 @@ pub mod daemon; mod ipc_server; -#[cfg(target_os = "linux")] -pub(crate) mod linux; pub mod logging; -pub mod manager; -pub mod paths; -#[cfg(unix)] -pub mod unix_account; -#[cfg(target_os = "windows")] -pub mod windows; pub use daemon::{ DEFAULT_VARIANT_ID, DaemonOptions, VersionDetails, VersionOverrideFile, load_version_overrides, run_daemon, }; -pub use paths::default_secret_path; - -#[cfg(target_os = "macos")] -pub use paths::{ - macos_launch_agent_data_dir, macos_launch_agent_log_dir, macos_launch_agent_log_path, - macos_launch_agent_secret_path, macos_launch_agent_socket_path, -}; - -#[cfg(target_os = "windows")] -pub use paths::{windows_service_data_dir, windows_service_log_path, windows_service_secret_path}; diff --git a/packages/playitd/src/linux.rs b/packages/playitd/src/linux.rs deleted file mode 100644 index 74ec6c29..00000000 --- a/packages/playitd/src/linux.rs +++ /dev/null @@ -1,296 +0,0 @@ -use std::{ - ffi::CString, - io, - os::unix::{ffi::OsStrExt, fs::PermissionsExt}, - path::Path, - process::Command, -}; - -use playit_ipc::ipc::IpcError; - -use crate::manager::ServiceManagerError; - -const SYSTEMD_SERVICE_NAME: &str = "playit"; -const OPENRC_SERVICE_NAME: &str = "playit"; -const PLAYIT_SOCKET_GROUP_NAME: &str = "playit"; -const PLAYIT_SOCKET_MODE: u32 = 0o660; - -pub(crate) fn start_systemd_service() -> Result<(), ServiceManagerError> { - run_systemctl(&systemd_start_args(), ServiceManagerError::StartFailed) -} - -pub(crate) fn start_openrc_service() -> Result<(), ServiceManagerError> { - run_rc_service(&openrc_start_args(), ServiceManagerError::StartFailed) -} - -pub(crate) fn is_systemd_service_active() -> Result { - let args = systemd_is_active_args(); - let output = Command::new("systemctl") - .args(args) - .output() - .map_err(|e| ServiceManagerError::NotAvailable(format!("Failed to run systemctl: {e}")))?; - - match output.status.code() { - Some(0) => Ok(true), - Some(3) | Some(4) => Ok(false), - _ => { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let detail = if !stderr.is_empty() { - stderr - } else if !stdout.is_empty() { - stdout - } else { - format!("exit status {}", output.status) - }; - - Err(ServiceManagerError::NotAvailable(format!( - "systemctl {} failed: {}", - args.join(" "), - detail - ))) - } - } -} - -pub(crate) fn is_openrc_service_active() -> Result { - let args = openrc_status_args(); - let output = Command::new("rc-service") - .args(args) - .output() - .map_err(|e| ServiceManagerError::NotAvailable(format!("Failed to run rc-service: {e}")))?; - - Ok(output.status.success()) -} - -pub(crate) fn stop_systemd_service() -> Result<(), ServiceManagerError> { - run_systemctl(&systemd_stop_args(), ServiceManagerError::StopFailed) -} - -pub(crate) fn stop_openrc_service() -> Result<(), ServiceManagerError> { - run_rc_service(&openrc_stop_args(), ServiceManagerError::StopFailed) -} - -pub(crate) fn configure_socket_permissions(socket_path: &str) -> Result<(), IpcError> { - let Some(target) = socket_permission_target(socket_path, crate::unix_account::effective_uid()) - else { - return Ok(()); - }; - - if !Path::new(target.path).exists() { - return Err(IpcError::BindFailed(io::Error::new( - io::ErrorKind::NotFound, - format!("IPC socket {} was not created", target.path), - ))); - } - - let group_gid = if target.chown_group { - match crate::unix_account::group_gid_by_name(target.group_name) { - Some(group_gid) => Some(group_gid), - None => { - tracing::warn!( - group = target.group_name, - socket_path = %target.path, - "IPC socket group is missing, leaving default socket permissions in place" - ); - None - } - } - } else { - None - }; - - apply_socket_permissions(target.path, group_gid, target.mode) -} - -fn systemd_start_args() -> [&'static str; 2] { - ["start", SYSTEMD_SERVICE_NAME] -} - -fn systemd_is_active_args() -> [&'static str; 3] { - ["is-active", "--quiet", SYSTEMD_SERVICE_NAME] -} - -fn systemd_stop_args() -> [&'static str; 2] { - ["stop", SYSTEMD_SERVICE_NAME] -} - -fn openrc_start_args() -> [&'static str; 2] { - [OPENRC_SERVICE_NAME, "start"] -} - -fn openrc_status_args() -> [&'static str; 2] { - [OPENRC_SERVICE_NAME, "status"] -} - -fn openrc_stop_args() -> [&'static str; 2] { - [OPENRC_SERVICE_NAME, "stop"] -} - -fn run_systemctl( - args: &[&str], - err_builder: fn(String) -> ServiceManagerError, -) -> Result<(), ServiceManagerError> { - let output = Command::new("systemctl") - .args(args) - .output() - .map_err(|e| err_builder(format!("Failed to run systemctl: {e}")))?; - - if output.status.success() { - return Ok(()); - } - - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - Err(err_builder(format!( - "systemctl {} failed: {}", - args.join(" "), - stderr - ))) -} - -fn run_rc_service( - args: &[&str], - err_builder: fn(String) -> ServiceManagerError, -) -> Result<(), ServiceManagerError> { - let output = Command::new("rc-service") - .args(args) - .output() - .map_err(|e| err_builder(format!("Failed to run rc-service: {e}")))?; - - if output.status.success() { - return Ok(()); - } - - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let detail = if !stderr.is_empty() { - stderr - } else if !stdout.is_empty() { - stdout - } else { - format!("exit status {}", output.status) - }; - - Err(err_builder(format!( - "rc-service {} failed: {}", - args.join(" "), - detail - ))) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct LinuxSocketPermissionTarget<'a> { - path: &'a str, - group_name: &'static str, - mode: u32, - chown_group: bool, -} - -fn socket_permission_target( - socket_path: &str, - effective_uid: u32, -) -> Option> { - if socket_path.starts_with('@') || socket_path.starts_with(r"\\.\pipe\") { - return None; - } - - Some(LinuxSocketPermissionTarget { - path: socket_path, - group_name: PLAYIT_SOCKET_GROUP_NAME, - mode: PLAYIT_SOCKET_MODE, - chown_group: effective_uid == 0, - }) -} - -fn apply_socket_permissions( - socket_path: &str, - group_gid: Option, - mode: u32, -) -> Result<(), IpcError> { - let path = Path::new(socket_path); - let permissions = std::fs::Permissions::from_mode(mode); - std::fs::set_permissions(path, permissions).map_err(|e| { - IpcError::BindFailed(io::Error::new( - e.kind(), - format!("failed to chmod IPC socket {socket_path} to {mode:o}: {e}"), - )) - })?; - - let Some(group_gid) = group_gid else { - return Ok(()); - }; - - let path_cstr = CString::new(path.as_os_str().as_bytes()).map_err(|e| { - IpcError::BindFailed(io::Error::new( - io::ErrorKind::InvalidInput, - format!("invalid IPC socket path {socket_path:?}: {e}"), - )) - })?; - - let chown_status = unsafe { libc::chown(path_cstr.as_ptr(), u32::MAX, group_gid) }; - if chown_status != 0 { - return Err(IpcError::BindFailed(io::Error::new( - io::ErrorKind::PermissionDenied, - format!( - "failed to chown IPC socket {socket_path} to group gid {group_gid}: {}", - io::Error::last_os_error() - ), - ))); - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::{ - PLAYIT_SOCKET_GROUP_NAME, PLAYIT_SOCKET_MODE, openrc_start_args, openrc_status_args, - openrc_stop_args, socket_permission_target, systemd_start_args, systemd_stop_args, - }; - - #[test] - fn systemd_command_args_are_stable() { - assert_eq!(systemd_start_args(), ["start", "playit"]); - assert_eq!(systemd_stop_args(), ["stop", "playit"]); - } - - #[test] - fn openrc_command_args_are_stable() { - assert_eq!(openrc_start_args(), ["playit", "start"]); - assert_eq!(openrc_status_args(), ["playit", "status"]); - assert_eq!(openrc_stop_args(), ["playit", "stop"]); - } - - #[test] - fn root_socket_target_chmods_and_chowns_group() { - let target = socket_permission_target("/run/playit/playitd.sock", 0).unwrap(); - - assert_eq!(target.path, "/run/playit/playitd.sock"); - assert_eq!(target.group_name, PLAYIT_SOCKET_GROUP_NAME); - assert_eq!(target.mode, PLAYIT_SOCKET_MODE); - assert!(target.chown_group); - } - - #[test] - fn non_root_socket_target_chmods_without_chown() { - let target = socket_permission_target("/run/playit/playitd.sock", 1234).unwrap(); - - assert_eq!(target.path, "/run/playit/playitd.sock"); - assert_eq!(target.group_name, PLAYIT_SOCKET_GROUP_NAME); - assert_eq!(target.mode, PLAYIT_SOCKET_MODE); - assert!(!target.chown_group); - } - - #[test] - fn abstract_socket_target_is_ignored() { - assert_eq!(socket_permission_target("@playitd", 0), None); - } - - #[test] - fn windows_pipe_target_is_ignored() { - assert_eq!( - socket_permission_target(r"\\.\pipe\playitd-system", 0), - None - ); - } -} diff --git a/packages/playitd/src/manager.rs b/packages/playitd/src/manager.rs deleted file mode 100644 index cfd17234..00000000 --- a/packages/playitd/src/manager.rs +++ /dev/null @@ -1,381 +0,0 @@ -use service_manager::{ServiceLabel, ServiceManager, ServiceStartCtx, ServiceStopCtx}; - -#[cfg(target_os = "linux")] -use crate::linux; -use playit_ipc::ipc::{IpcClient, get_default_socket_path}; -#[cfg(target_os = "windows")] -use windows_sys::Win32::Foundation::{ - ERROR_SERVICE_ALREADY_RUNNING, ERROR_SERVICE_DOES_NOT_EXIST, ERROR_SERVICE_NOT_ACTIVE, -}; -#[cfg(target_os = "windows")] -use windows_sys::Win32::System::Services::{ - CloseServiceHandle, ControlService, OpenSCManagerW, OpenServiceW, QueryServiceStatusEx, - SC_HANDLE, SC_MANAGER_CONNECT, SC_STATUS_PROCESS_INFO, SERVICE_CONTROL_STOP, - SERVICE_QUERY_STATUS, SERVICE_RUNNING, SERVICE_START, SERVICE_STATUS, SERVICE_STATUS_PROCESS, - SERVICE_STOP, StartServiceW, -}; - -#[cfg(target_os = "windows")] -pub const INSTALLED_SERVICE_LABEL: &str = "playitd"; - -#[cfg(not(target_os = "windows"))] -pub const INSTALLED_SERVICE_LABEL: &str = "gg.playit.playitd"; - -#[derive(Debug)] -pub enum ServiceManagerError { - NotAvailable(String), - StartFailed(String), - StopFailed(String), - NotFound, - IoError(std::io::Error), -} - -impl std::fmt::Display for ServiceManagerError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::NotAvailable(msg) => write!(f, "Service manager not available: {msg}"), - Self::StartFailed(msg) => write!(f, "Failed to start service: {msg}"), - Self::StopFailed(msg) => write!(f, "Failed to stop service: {msg}"), - Self::NotFound => write!(f, "Service not found"), - Self::IoError(e) => write!(f, "IO error: {e}"), - } - } -} - -impl std::error::Error for ServiceManagerError {} - -impl From for ServiceManagerError { - fn from(e: std::io::Error) -> Self { - Self::IoError(e) - } -} - -pub struct ServiceController { - manager: Box, - label: ServiceLabel, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum InstalledServiceState { - Running, - Stopped, - Unknown, -} - -#[cfg(target_os = "linux")] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum LinuxServiceManager { - Systemd, - OpenRc, -} - -impl ServiceController { - const SERVICE_LABEL: &'static str = INSTALLED_SERVICE_LABEL; - - pub fn new() -> Result { - let manager = ::native() - .map_err(|e| ServiceManagerError::NotAvailable(e.to_string()))?; - let label = Self::SERVICE_LABEL.parse().unwrap(); - Ok(Self { manager, label }) - } - - pub fn start(&self) -> Result<(), ServiceManagerError> { - self.manager - .start(ServiceStartCtx { - label: self.label.clone(), - }) - .map_err(|e| ServiceManagerError::StartFailed(e.to_string())) - } - - pub fn stop(&self) -> Result<(), ServiceManagerError> { - self.manager - .stop(ServiceStopCtx { - label: self.label.clone(), - }) - .map_err(|e| ServiceManagerError::StopFailed(e.to_string())) - } -} - -#[cfg(target_os = "linux")] -pub fn is_systemd_service_active() -> Result { - linux::is_systemd_service_active() -} - -#[cfg(target_os = "linux")] -pub fn installed_service_is_active_with_linux_manager( - manager: LinuxServiceManager, -) -> Result { - match manager { - LinuxServiceManager::Systemd => linux::is_systemd_service_active(), - LinuxServiceManager::OpenRc => linux::is_openrc_service_active(), - } -} - -pub fn installed_service_state() -> Result { - #[cfg(target_os = "linux")] - { - return Ok(if linux::is_systemd_service_active()? { - InstalledServiceState::Running - } else { - InstalledServiceState::Stopped - }); - } - - #[cfg(target_os = "windows")] - { - return windows_installed_service_state(); - } - - #[cfg(not(any(target_os = "linux", target_os = "windows")))] - { - Err(ServiceManagerError::NotAvailable( - "service status queries are not supported on this platform".to_string(), - )) - } -} - -pub fn installed_service_is_running() -> Result { - Ok(installed_service_state()? == InstalledServiceState::Running) -} - -pub async fn ensure_installed_service_running() -> Result<(), ServiceManagerError> { - if IpcClient::is_running(get_default_socket_path()).await { - return Ok(()); - } - - #[cfg(target_os = "linux")] - { - if linux::is_systemd_service_active()? { - return Ok(()); - } - - linux::start_systemd_service()?; - return wait_for_installed_service().await; - } - - #[cfg(not(target_os = "linux"))] - #[cfg(not(target_os = "windows"))] - { - let controller = ServiceController::new()?; - controller.start()?; - wait_for_installed_service().await - } - - #[cfg(target_os = "windows")] - { - windows_start_installed_service()?; - wait_for_installed_service().await - } -} - -#[cfg(target_os = "linux")] -pub async fn ensure_installed_service_running_with_linux_manager( - manager: LinuxServiceManager, -) -> Result<(), ServiceManagerError> { - if IpcClient::is_running(get_default_socket_path()).await { - return Ok(()); - } - - if installed_service_is_active_with_linux_manager(manager)? { - return Ok(()); - } - - match manager { - LinuxServiceManager::Systemd => linux::start_systemd_service()?, - LinuxServiceManager::OpenRc => linux::start_openrc_service()?, - } - - wait_for_installed_service().await -} - -pub fn stop_installed_service() -> Result<(), ServiceManagerError> { - #[cfg(target_os = "linux")] - { - return linux::stop_systemd_service(); - } - - #[cfg(not(target_os = "linux"))] - #[cfg(not(target_os = "windows"))] - { - let controller = ServiceController::new()?; - controller.stop() - } - - #[cfg(target_os = "windows")] - { - windows_stop_installed_service() - } -} - -#[cfg(target_os = "linux")] -pub fn stop_installed_service_with_linux_manager( - manager: LinuxServiceManager, -) -> Result<(), ServiceManagerError> { - match manager { - LinuxServiceManager::Systemd => linux::stop_systemd_service(), - LinuxServiceManager::OpenRc => linux::stop_openrc_service(), - } -} - -async fn wait_for_installed_service() -> Result<(), ServiceManagerError> { - for _ in 0..50 { - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - if IpcClient::is_running(get_default_socket_path()).await { - return Ok(()); - } - } - - Err(ServiceManagerError::StartFailed( - "Service did not start within timeout".to_string(), - )) -} - -#[cfg(target_os = "windows")] -fn windows_start_installed_service() -> Result<(), ServiceManagerError> { - unsafe { - let manager = open_windows_service_manager()?; - let service_name = wide(INSTALLED_SERVICE_LABEL); - let service = OpenServiceW( - manager.0, - service_name.as_ptr(), - SERVICE_START | SERVICE_QUERY_STATUS, - ); - if service.is_null() { - return Err(open_service_error(ServiceAction::Start)); - } - - let service = ServiceHandle(service); - let started = StartServiceW(service.0, 0, std::ptr::null()); - if started != 0 { - return Ok(()); - } - - let error = std::io::Error::last_os_error(); - if error.raw_os_error() == Some(ERROR_SERVICE_ALREADY_RUNNING as i32) { - Ok(()) - } else { - Err(ServiceManagerError::StartFailed(error.to_string())) - } - } -} - -#[cfg(target_os = "windows")] -fn windows_stop_installed_service() -> Result<(), ServiceManagerError> { - unsafe { - let manager = open_windows_service_manager()?; - let service_name = wide(INSTALLED_SERVICE_LABEL); - let service = OpenServiceW( - manager.0, - service_name.as_ptr(), - SERVICE_STOP | SERVICE_QUERY_STATUS, - ); - if service.is_null() { - return Err(open_service_error(ServiceAction::Stop)); - } - - let service = ServiceHandle(service); - let mut status = std::mem::zeroed::(); - let stopped = ControlService(service.0, SERVICE_CONTROL_STOP, &mut status); - if stopped != 0 { - return Ok(()); - } - - let error = std::io::Error::last_os_error(); - if error.raw_os_error() == Some(ERROR_SERVICE_NOT_ACTIVE as i32) { - Ok(()) - } else { - Err(ServiceManagerError::StopFailed(error.to_string())) - } - } -} - -#[cfg(target_os = "windows")] -fn windows_installed_service_state() -> Result { - unsafe { - let manager = open_windows_service_manager()?; - let service_name = wide(INSTALLED_SERVICE_LABEL); - let service = OpenServiceW(manager.0, service_name.as_ptr(), SERVICE_QUERY_STATUS); - if service.is_null() { - return Ok(InstalledServiceState::Stopped); - } - - let service = ServiceHandle(service); - let mut status = std::mem::zeroed::(); - let mut bytes_needed = 0; - let queried = QueryServiceStatusEx( - service.0, - SC_STATUS_PROCESS_INFO, - (&mut status as *mut SERVICE_STATUS_PROCESS).cast::(), - std::mem::size_of::() as u32, - &mut bytes_needed, - ); - - if queried == 0 { - return Err(ServiceManagerError::NotAvailable(format!( - "failed to query Windows service status: {}", - std::io::Error::last_os_error() - ))); - } - - Ok(if status.dwCurrentState == SERVICE_RUNNING { - InstalledServiceState::Running - } else { - InstalledServiceState::Stopped - }) - } -} - -#[cfg(target_os = "windows")] -fn open_windows_service_manager() -> Result { - let manager = unsafe { OpenSCManagerW(std::ptr::null(), std::ptr::null(), SC_MANAGER_CONNECT) }; - if manager.is_null() { - return Err(ServiceManagerError::NotAvailable(format!( - "failed to open Windows service manager: {}", - std::io::Error::last_os_error() - ))); - } - - Ok(ServiceHandle(manager)) -} - -#[cfg(target_os = "windows")] -enum ServiceAction { - Start, - Stop, -} - -#[cfg(target_os = "windows")] -fn open_service_error(action: ServiceAction) -> ServiceManagerError { - let error = std::io::Error::last_os_error(); - if error.raw_os_error() == Some(ERROR_SERVICE_DOES_NOT_EXIST as i32) { - return ServiceManagerError::NotFound; - } - - let message = format!("failed to open Windows service: {error}"); - match action { - ServiceAction::Start => ServiceManagerError::StartFailed(message), - ServiceAction::Stop => ServiceManagerError::StopFailed(message), - } -} - -#[cfg(target_os = "windows")] -struct ServiceHandle(SC_HANDLE); - -#[cfg(target_os = "windows")] -impl Drop for ServiceHandle { - fn drop(&mut self) { - unsafe { - let _ = CloseServiceHandle(self.0); - } - } -} - -#[cfg(target_os = "windows")] -fn wide(value: &str) -> Vec { - use std::os::windows::ffi::OsStrExt; - - std::ffi::OsStr::new(value) - .encode_wide() - .chain(std::iter::once(0)) - .collect() -} diff --git a/packages/playitd/src/paths.rs b/packages/playitd/src/paths.rs deleted file mode 100644 index ad2a6638..00000000 --- a/packages/playitd/src/paths.rs +++ /dev/null @@ -1,67 +0,0 @@ -use std::path::{Path, PathBuf}; - -pub fn default_secret_path() -> PathBuf { - if Path::new("playit.toml").exists() { - return PathBuf::from("playit.toml"); - } - - #[cfg(target_os = "linux")] - if let Some(path) = linux_default_secret_path() { - return path; - } - - playit_ipc::paths::playit_config_dir().join("playit.toml") -} - -#[cfg(target_os = "linux")] -pub(crate) fn linux_default_secret_path() -> Option { - let path = PathBuf::from("/etc/playit/playit.toml"); - path.exists().then_some(path) -} - -#[cfg(target_os = "macos")] -pub fn macos_launch_agent_data_dir() -> PathBuf { - playit_ipc::paths::playit_config_dir() -} - -#[cfg(target_os = "macos")] -pub fn macos_launch_agent_secret_path() -> PathBuf { - macos_launch_agent_data_dir().join("playit.toml") -} - -#[cfg(target_os = "macos")] -pub fn macos_launch_agent_socket_path() -> PathBuf { - playit_ipc::paths::macos_launch_agent_socket_path() -} - -#[cfg(target_os = "macos")] -pub fn macos_launch_agent_log_dir() -> PathBuf { - dirs::home_dir() - .unwrap_or_else(|| ".".into()) - .join("Library") - .join("Logs") - .join("playit") -} - -#[cfg(target_os = "macos")] -pub fn macos_launch_agent_log_path() -> PathBuf { - macos_launch_agent_log_dir().join("playitd.log") -} - -#[cfg(target_os = "windows")] -pub fn windows_service_data_dir() -> PathBuf { - std::env::var_os("PROGRAMDATA") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(r"C:\ProgramData")) - .join("playit_gg") -} - -#[cfg(target_os = "windows")] -pub fn windows_service_secret_path() -> PathBuf { - windows_service_data_dir().join("playit.toml") -} - -#[cfg(target_os = "windows")] -pub fn windows_service_log_path() -> PathBuf { - windows_service_data_dir().join("logs").join("playitd.log") -} diff --git a/packages/playitd/tests/install_contract.rs b/packages/playitd/tests/install_contract.rs new file mode 100644 index 00000000..f2a11043 --- /dev/null +++ b/packages/playitd/tests/install_contract.rs @@ -0,0 +1,67 @@ +#[cfg(target_os = "linux")] +#[test] +fn linux_service_definitions_preserve_installed_paths() { + let systemd = include_str!("../../../linux/playit.service"); + let openrc = include_str!("../../../linux/playit.openrc"); + let expected_arguments = "--secret-path /etc/playit/playit.toml --socket-path /run/playit/playitd.sock -l /var/log/playit/playit.log"; + + assert!( + systemd + .lines() + .any(|line| { line == format!("ExecStart=/opt/playit/playitd {expected_arguments}") }) + ); + assert!( + openrc + .lines() + .any(|line| line == format!("command_args=\"{expected_arguments}\"")) + ); +} + +#[cfg(target_os = "linux")] +#[test] +fn linux_package_manifests_preserve_executable_destinations() { + for manifest in [ + include_str!("../../../build-scripts/nfpm.yaml"), + include_str!("../../../build-scripts/nfpm-openrc.yaml"), + ] { + assert!(manifest.contains("dst: /opt/playit/agent")); + assert!(manifest.contains("dst: /opt/playit/playitd")); + assert!(manifest.contains("dst: /usr/bin/playit")); + assert!(manifest.contains("dst: /usr/bin/playitd")); + assert!(manifest.contains("dst: /etc/playit")); + } +} + +#[cfg(target_os = "windows")] +#[test] +fn windows_service_paths_and_label_are_stable() { + use std::path::Path; + + assert_eq!(playit_platform::paths::WINDOWS_SERVICE_NAME, "playitd"); + assert!(playit_platform::paths::windows_service_data_dir().ends_with("playit_gg")); + assert!( + playit_platform::paths::windows_service_secret_path() + .ends_with(Path::new("playit_gg").join("playit.toml")) + ); + assert!( + playit_platform::paths::windows_service_log_path() + .ends_with(Path::new("playit_gg").join("logs").join("playitd.log")) + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn macos_launch_agent_paths_are_stable() { + assert_eq!( + playit_platform::paths::macos_launch_agent_secret_path(), + playit_platform::paths::macos_launch_agent_data_dir().join("playit.toml") + ); + assert_eq!( + playit_platform::paths::macos_launch_agent_socket_path(), + playit_platform::paths::macos_launch_agent_data_dir().join("playitd.sock") + ); + assert!( + playit_platform::paths::macos_launch_agent_log_path() + .ends_with("Library/Logs/playit/playitd.log") + ); +}