diff --git a/.claude/skills/serverbee-vps-e2e-test/SKILL.md b/.claude/skills/serverbee-vps-e2e-test/SKILL.md index 623e6320..3c99fab1 100644 --- a/.claude/skills/serverbee-vps-e2e-test/SKILL.md +++ b/.claude/skills/serverbee-vps-e2e-test/SKILL.md @@ -1,6 +1,6 @@ --- name: serverbee-vps-e2e-test -description: Run the ServerBee install.sh deploy / agent-recover end-to-end regression on a real Linux VPS — cross-compile the current branch locally, push the binary or docker image to the VPS, then execute install.sh against it and verify HTTPS + agent connection. Use this whenever the user wants to validate deploy changes on a real VPS, smoke-test install.sh after editing it, verify the agent.toml refresh / recover flow, check that Caddy + Let's Encrypt automation still works, or test the binary vs docker agent install paths. Trigger on phrases like "用 VPS 跑下测试", "跑 deploy 回归", "测 recover 闭环", "test the installer on VPS", "VPS regression", "在 VPS 上验一下", "跑下 runbook" — even when the user doesn't name a specific runbook. +description: Run the ServerBee install.sh deploy / Agent re-enrollment end-to-end regression on a real Linux VPS — cross-compile the current branch locally, push the binary or docker image to the VPS, then execute install.sh against it and verify HTTPS + agent connection. Use this whenever the user wants to validate deploy changes on a real VPS, smoke-test install.sh after editing it, verify the Agent Authority re-enrollment flow, check that Caddy + Let's Encrypt automation still works, or test the binary vs docker agent install paths. Trigger on phrases like "用 VPS 跑下测试", "跑 deploy 回归", "测重新接入闭环", "test the installer on VPS", "VPS regression", "在 VPS 上验一下", "跑下 runbook" — even when the user doesn't name a specific runbook. --- # ServerBee VPS e2e regression @@ -8,7 +8,7 @@ description: Run the ServerBee install.sh deploy / agent-recover end-to-end regr End-to-end regression for ServerBee's `deploy/install.sh` against a real Linux VPS, using the **current branch's** code (not the released ghcr image). Two canonical runbooks already live in the repo: - `tests/manual/full-deploy-e2e.md` — full lifecycle (server + agent docker + agent binary + uninstall) -- `tests/manual/agent-recover-e2e.md` — narrow recover-only flow (agent.toml refresh, ~3 min) +- `tests/manual/agent-reenrollment-e2e.md` — narrow Agent Authority re-enrollment flow This skill orchestrates them autonomously. The runbooks are reference; SKILL.md routes + handles credentials + reports. @@ -17,26 +17,26 @@ This skill orchestrates them autonomously. The runbooks are reference; SKILL.md Run this skill whenever the user wants to exercise the deploy flow on a real VPS — explicitly named or not. Concrete triggers: - After editing `deploy/install.sh` (any path) -- After editing `crates/server/src/router/api/server.rs` recover endpoint or related routes -- After editing front-end `recover-agent-dialog.tsx` / `regenerate-code-dialog.tsx` / `add-server-dialog.tsx` mutation paths +- After editing the Agent Authority re-enrollment, offer, or revocation routes +- After editing front-end `agent-reenrollment-dialog.tsx` / `enrollment-offer-dialog.tsx` / `add-server-dialog.tsx` mutation paths - After editing `Dockerfile*` or the `docker-compose.*.yml` shapes install.sh generates - After a sea-orm migration / OnboardingResponse change -- Whenever the user says some variation of "在 VPS 上跑一遍", "测下 deploy", "跑下 recover", "VPS 回归", "run the install.sh test" +- Whenever the user says some variation of "在 VPS 上跑一遍", "测下 deploy", "跑下重新接入", "VPS 回归", "run the install.sh test" Do **not** trigger for: `cargo test` / `bun run test` unit tests, local-only smoke tests on the user's laptop, or anything that doesn't touch deploy/install. ## Step 1 — Pick the runbook -Default to **full-deploy**. Narrow to recover-only if and only if the change is clearly localized to the recover slice. +Default to **full-deploy**. Narrow to re-enrollment-only if and only if the change is clearly localized to the Agent Authority lifecycle slice. | Recent change touches… | Use | | --- | --- | | `deploy/install.sh` main paths / `cmd_domain` / Caddyfile gen / `install_*_{server,agent}` | full-deploy | | Migration / `auth.rs` middleware / OnboardingResponse / Dockerfiles | full-deploy | | `Cargo.toml` / dep bumps where you want a smoke confirmation | full-deploy | -| Only the recover endpoint, recover-agent-dialog, or the `agent.toml` `else` branch (`toml_set` of `server_url` / `enrollment_code` / `token`) | agent-recover | +| Only Agent Authority routes, re-enrollment UI, or Agent-side staged-token claim recovery | agent-reenrollment | | User says "全部跑一遍" / "complete deploy" / "from scratch" | full-deploy | -| User says "只测 recover" / "fast" / "just the refresh" | agent-recover | +| User says "只测重新接入" / "fast" / "just re-enrollment" | agent-reenrollment | | Genuinely unsure | full-deploy (superset) | State your choice in one sentence before going further: e.g. *"Running tests/manual/full-deploy-e2e.md because the change touches install.sh §install_docker_server."* @@ -65,7 +65,7 @@ Read the chosen runbook top to bottom. Follow it section by section, but keep th - **Binary mode test**: install.sh's `install_binary_agent` has an adopt-mode short-circuit at [`deploy/install.sh:1502`](../../../deploy/install.sh#L1502): `if [ -f "${INSTALL_DIR}/serverbee-agent" ] then ... "skipping download (adopting existing)"`. Before running install.sh in binary mode, `scp` your locally built `target/x86_64-unknown-linux-musl/release/serverbee-agent` to `/opt/serverbee/bin/serverbee-agent` and `chmod +x`. install.sh will then use *your* binary and still write agent.toml + systemd unit. Without this, install.sh downloads the released v1.0.0-alpha.4 binary from GitHub — useful for testing install.sh itself, but not testing your branch's binary. -- **Recover test** (the agent.toml `else` branch): the exact sequence is `uninstall agent --yes` (no `--purge` — that preserves agent.toml, which is the prerequisite for hitting the refresh branch) → recover endpoint with `revoke_immediately: true` → `install agent --enrollment-code ` → cat `/opt/serverbee/etc/agent.toml` and check the three fields (see §7.4 of the recover runbook). Directly re-running `install agent` without uninstall is rejected by install.sh's `meta_has` guard. +- **Re-enrollment test**: follow `tests/manual/agent-reenrollment-e2e.md` exactly. Exercise both `graceful` and `emergency`, use the exact visible offer ID for replacement or revocation, confirm the Agent persists its proposed run token before claim, and verify the old WebSocket is fenced. Do not expect the Server to return a run token. - **Onboarding**: a freshly installed server has `must_change_password=true` on the admin user. Only `POST /api/auth/onboarding` is whitelisted (see `is_onboarding_whitelisted` in `crates/server/src/middleware/auth.rs`); calling `PUT /api/auth/password` first returns `MUST_CHANGE_PASSWORD` and stops you. Login → `/api/auth/onboarding` with `new_password` → then everything else opens up. @@ -87,7 +87,7 @@ End with a summary in this exact shape so multiple runs are easy to compare: ``` ## VPS e2e regression result -Runbook: +Runbook: VPS: , , Domain: Total: @@ -100,7 +100,7 @@ Total: - [✓ | ✗ | -] onboarding + create server - [✓ | ✗ | -] install.sh install agent --method docker - [✓ | ✗ | -] install.sh install agent --method binary -- [✓ | ✗ | -] recover flow (agent.toml three-field check) +- [✓ | ✗ | -] Agent Authority re-enrollment + fencing - [✓ | ✗ | -] uninstall --purge → clean ### Evidence @@ -113,7 +113,7 @@ Total: ``` -Use `-` for stages the chosen runbook doesn't cover (e.g. recover-only skips most full-deploy stages). On a hard fail, name the runbook section, paste the exact error, and stop the remaining stages rather than charging through teardown. +Use `-` for stages the chosen runbook doesn't cover (e.g. re-enrollment-only skips most full-deploy stages). On a hard fail, name the runbook section, paste the exact error, and stop the remaining stages rather than charging through teardown. ## Failure-mode quick map @@ -122,8 +122,8 @@ The canonical table is in [`tests/manual/full-deploy-e2e.md` §9](../../../tests - `[ERROR] Failed to get latest version from GitHub` → VPS can't reach api.github.com (firewall / DNS), or you tried to use `RESOLVED_VERSION` env to override (doesn't work, line 745 nukes it). - `MUST_CHANGE_PASSWORD` on `/api/servers` → forgot `POST /api/auth/onboarding` first. - compose `Pulling` from ghcr instead of using local image → local tag isn't `${PROD_TAG}` exactly. Re-`docker tag` and re-run. -- `serverbee-agent.service: status=78/CONFIG` → enrollment code expired/used; recover for a fresh one, clear `token` line, restart. -- `serverbee-agent is already installed (...). Use 'upgrade'` → meta-file guard; for recover test you want `uninstall agent --yes` (no `--purge`) first, then re-install with new code. +- `serverbee-agent.service: status=78/CONFIG` → enrollment code expired/used; issue or exactly replace the current offer, then retry with the same staged run token. +- `serverbee-agent is already installed (...). Use 'upgrade'` → meta-file guard; use the dedicated temporary-Agent procedure in the re-enrollment runbook instead of overwriting a live installation. ## Related skills / docs diff --git a/CONTEXT.md b/CONTEXT.md index 0875b9d8..f3f6de60 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -8,3 +8,17 @@ - **Full server status** — the complete ~35-field snapshot (`ServerStatus`) carried by `full_sync` and REST. The only sources allowed to seed the catalog or overwrite static facts (totals, os, tags, geo, enrollment). - **Rollup policy (降采样策略)** — how raw metric records compress over time: per-column aggregation (AVG vs window MAX), the raw/hourly table switch point, and which columns alerts may read. Owned by `service::rollup`; declared per column in the **metric column descriptor** (see ADR-0003). _Avoid_: aggregation logic, downsampling code. - **Metric column descriptor** — the one-row-per-column table (`METRIC_COLUMNS`) declaring a scalar metric's SQL name, rollup aggregation, alert rule type, and typed accessor. Adding a scalar metric means adding one descriptor row (plus entity/migration, which the compiler enforces). The web analogue is `METRIC_CHART_SPECS` for chart display. +- **Enrollment code (Agent 注册码)** — a short-lived, single-use credential bound to exactly one pre-created Server identity. Possession authorizes one Agent enrollment; the plaintext is shown only when the code is issued. +- **Agent enrollment (Agent 注册)** — the act of an Agent claiming the Server identity bound to an Enrollment code. The Agent durably stages its proposed run token before submitting the claim, so authority does not depend on receiving a once-only secret in the HTTP response. This claim protocol is a hard cut: requests without an Agent-generated run token are rejected rather than routed through a legacy server-generated-token adapter. Enrollment does not collect a machine fingerprint or discover, create, merge, or deduplicate Server identities from host-derived identifiers. _Avoid_: Agent fingerprint, fingerprint-based enrollment, legacy enrollment claim. +- **Agent run token (Agent 运行令牌)** — the persistent, per-Server credential authorizing an Agent to connect as that Server identity. The Agent generates and durably stages this high-entropy secret before enrollment; the Server accepts its hash atomically with the claim and never returns the plaintext. Re-enrollment replaces it; Agent authority revocation removes it. +- **Agent authority (Agent 身份权限)** — whether a Server identity is currently claimed by an Agent with a valid run token. It is either **Claimed** or **Unclaimed**, independently of whether that Agent is currently online. The **Agent Authority** module owns Enrollment offer transitions, run-token transitions, and the resulting connection-fencing request; it does not own Server onboarding, profile data, or online/offline connection state (see ADR-0004). +- **Enrollment offer (Agent 注册许可)** — the current opportunity to claim a Server identity with an Enrollment code. An offer begins **Outstanding** and ends in exactly one immutable outcome: **Consumed**, **Revoked**, **Replaced**, or **Expired**. Replacement identifies the successor offer; expiry takes effect from `expires_at` without requiring a background task. Terminal offers remain non-blocking enrollment history. Offer lifecycle is independent of Agent authority, so a Claimed or Unclaimed Server may each have an Enrollment offer. +- **Enrollment offer replacement (注册码替换)** — the explicit exchange of one Outstanding Enrollment offer for a new one. Replacement identifies the exact offer being superseded; a missing or stale identity is rejected instead of silently applying last-writer-wins. +- **Pending Server (待接入服务器)** — a UI projection for an **Unclaimed** Server identity, not a separate enrollment lifecycle state. Online and offline are connection facts outside the enrollment model. +- **Server onboarding (Server 接入建档)** — the atomic, request-idempotent creation of a new Server identity, its operator-supplied profile, its default monitoring configuration, and its first Enrollment offer. Retrying the same onboarding request identity with the same normalized input returns the same Server; reusing it with different input conflicts. Server onboarding composes enrollment but is not part of Agent enrollment itself. +- **Agent re-enrollment (Agent 重新注册)** — a new enrollment of an existing Server identity, used when an Agent must be reinstalled or replaced without creating another Server or losing its history. Re-enrollment is either graceful or emergency. _Avoid_: Agent recovery, recover. +- **Graceful re-enrollment (平滑重新注册)** — re-enrollment in which the current Agent remains authorized while the Enrollment code is outstanding. Consuming the code transfers authority to the newly enrolled Agent and immediately invalidates the previous run token and live connection. +- **Emergency re-enrollment (紧急重新注册)** — re-enrollment in which the current Agent's run token and live connection are invalidated when the Enrollment code is issued, leaving the Server unclaimed until that code is consumed. +- **Agent authority revocation (Agent 身份权限撤销)** — an operator action that removes the current Agent run token and fences its live connection, leaving the Server **Unclaimed** without creating an Enrollment offer. Restoring authority requires enrollment. _Avoid_: run-token rotation, token rotation. +- **Agent authority event (Agent 身份权限事件)** — an immutable, structured, secret-free record of an Enrollment offer or Agent authority transition, including its actor, Server snapshot, related offer, mode, request source, and time. The **Agent Authority** module commits the event atomically with the state transition; generic best-effort audit logs are not lifecycle history. Authority events survive Server deletion until an explicit audit-retention purge, while credential hashes and offer rows are deleted with the Server. +- **Connection fencing (Agent 连接隔离)** — the hard guarantee that an Agent whose authority was transferred or revoked can neither continue on its existing WebSocket nor enter through a handshake that raced with the transition. Agent Authority transitions and final WebSocket admission serialize through the same per-Server lock, and the token is revalidated immediately before admission. A transition reports success only after its durable state, authority event, and runtime fence are complete. diff --git a/Cargo.lock b/Cargo.lock index a17c9ed7..9b8e7c1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1372,16 +1372,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "gethostname" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc3655aa6818d65bc620d6911f05aa7b6aeb596291e1e9f79e52df85583d1e30" -dependencies = [ - "rustix 0.38.44", - "windows-targets 0.52.6", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -2187,12 +2177,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3447,19 +3431,6 @@ dependencies = [ "nom", ] -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags 2.11.0", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - [[package]] name = "rustix" version = "1.1.4" @@ -3469,7 +3440,7 @@ dependencies = [ "bitflags 2.11.0", "errno", "libc", - "linux-raw-sys 0.12.1", + "linux-raw-sys", "windows-sys 0.61.2", ] @@ -3971,7 +3942,6 @@ dependencies = [ "dashmap", "figment", "futures-util", - "gethostname", "hex", "ipnet", "libc", @@ -4575,7 +4545,7 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix 1.1.4", + "rustix", "windows-sys 0.61.2", ] diff --git a/ENV.md b/ENV.md index 35595fd9..12120d20 100644 --- a/ENV.md +++ b/ENV.md @@ -160,7 +160,7 @@ Agent top-level keys use single underscore. Nested keys use `__` (double undersc | Environment Variable | TOML Key | Type | Default | Description | |---------------------|----------|------|---------|-------------| | `SERVERBEE_SERVER_URL` | `server_url` | string | - (required) | Server HTTP base URL (e.g. `http://your-server:9527`). Agent appends API paths automatically | -| `SERVERBEE_ENROLLMENT_CODE` | `enrollment_code` | string | `""` | One-time enrollment code for first-time agent registration. Generated by an admin in the server UI Settings (or `POST /api/agent/enrollments`). Single-use and short-lived (default 10 min); only needed until the agent has a persisted token | +| `SERVERBEE_ENROLLMENT_CODE` | `enrollment_code` | string | `""` | One-time offer shown by Add Server or an Agent Authority re-enrollment action. Single-use and short-lived (default 10 min); used only while the Agent has no persisted run token | ### Common @@ -185,7 +185,7 @@ Agent top-level keys use single underscore. Nested keys use `__` (double undersc | Environment Variable | TOML Key | Type | Default | Description | |---------------------|----------|------|---------|-------------| -| `SERVERBEE_TOKEN` | `token` | string | `""` | Agent authentication token. Auto-populated after registration, do not set manually | +| `SERVERBEE_TOKEN` | `token` | string | `""` | Agent run token. When empty during enrollment, the Agent generates and persists one before claim; an explicit environment override is used as provided and is not written to disk | | `SERVERBEE_FILE__MAX_FILE_SIZE` | `file.max_file_size` | u64 | `1073741824` | Maximum file size in bytes for read/download (default 1GB) | | `SERVERBEE_FILE__DENY_PATTERNS` | `file.deny_patterns` | string[] | `*.key,*.pem,id_rsa*,.env*,shadow,passwd` | Glob patterns for files the agent will refuse to access | | `SERVERBEE_IP_CHANGE__EXTERNAL_IP_URLS` | `ip_change.external_ip_urls` | string[] | `["https://api.ipify.org","https://ifconfig.me/ip","https://icanhazip.com","https://checkip.amazonaws.com"]` | Ordered list of external IP services tried at startup and on every IP-change check. First success wins. Required for agents behind NAT, in containers, or anywhere interface enumeration can't see the routable public IP. Air-gapped deployments can set this to an empty list to skip external lookups entirely | diff --git a/README.md b/README.md index 0d0fbd18..aae6cf4c 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Open `http://your-server:9527`. The admin password is auto-generated and printed ### 2. Enroll an agent -Sign in as admin → **Settings** → generate a one-time **enrollment code** (single-use, expires in ~10 min). Then on each node: +Sign in as admin, choose **Add Server**, and copy the generated install command. Its Server-bound enrollment offer is single-use and expires in about 10 minutes. Then run it on that node: ```bash curl -fsSL https://raw.githubusercontent.com/ZingerLittleBee/ServerBee/main/deploy/install.sh | sudo sh -s -- agent --method binary \ @@ -54,7 +54,7 @@ curl -fsSL https://raw.githubusercontent.com/ZingerLittleBee/ServerBee/main/depl > A **native binary is recommended for agents** — smallest footprint and full host-level metrics. Pass `--method docker` to run the agent in a container instead. -The agent saves a per-server token on first connect and reconnects automatically afterwards — the code is only needed once. That's it. 🎉 +The Agent generates and persists its run token before claiming the offer, then reconnects automatically afterwards. The code is only needed once. That's it. 🎉 ## Features @@ -85,7 +85,7 @@ password = "" # leave empty to auto-generate ```toml # /etc/serverbee/agent.toml server_url = "http://your-server:9527" -enrollment_code = "" # one-time code from Settings; only used for first registration +enrollment_code = "" # one-time code from Add Server; only used for first claim [collector] interval = 3 # seconds between reports @@ -129,7 +129,7 @@ make test # frontend tests make cargo-clippy # Rust lint ``` -> `make dev-full` runs Vite with HMR at `http://localhost:5173` and proxies `/api/*` to the Rust server at `:9527`. Generate a one-time enrollment code in **Settings** to connect a dev agent. +> `make dev-full` runs Vite with HMR at `http://localhost:5173`, proxies `/api/*` to the Rust server at `:9527`, creates a development Server, and prints its one-time Agent enrollment command. **Stack:** Rust (Axum 0.8 · sea-orm · SQLite WAL) · React 19 (Vite 7 · TanStack Router/Query · Recharts · shadcn/ui · Tailwind CSS v4) · Rust agents (sysinfo · tokio-tungstenite). diff --git a/apps/docs/content/docs/en/admin.mdx b/apps/docs/content/docs/en/admin.mdx index 4ee00c56..2e4a2a5e 100644 --- a/apps/docs/content/docs/en/admin.mdx +++ b/apps/docs/content/docs/en/admin.mdx @@ -248,17 +248,17 @@ Instead of a composite value score, each configured server surfaces a list of ob For automation, the same data is exposed read-only at `GET /api/cost/overview` (fleet-wide currency totals + per-server summaries) and `GET /api/servers/{id}/cost-insights` (full per-server breakdown). See the [API Reference](/en/docs/api-reference#authenticated-read-endpoints) for authentication details. -## Agent Registration Management +## Agent Authority Management -Admins can manage first-time agent onboarding directly from the UI. +Admins manage onboarding and re-enrollment from **Add Server** and each Server's detail actions. Adding a Server creates its profile and one bound enrollment offer atomically. The plaintext code is displayed once; later views expose only the offer ID, prefix, expiry, and Agent Authority state. -### Enrollment Codes +For an unclaimed Server, an admin may issue an offer. For a claimed Server, start either graceful re-enrollment (keep current authority until the replacement claim) or emergency re-enrollment (revoke and fence it immediately). Only one offer may be outstanding, and replacement or revocation targets that exact offer ID. Revoking Agent Authority is a separate action and never creates an offer implicitly. -Go to **Settings** to mint a one-time enrollment code for a new agent. Each code is single-use and short-lived (default 10-minute expiry) and is consumed on the agent's first successful registration. You can list previously issued codes (the plaintext is never shown again -- only an 8-character prefix and metadata) and delete unused ones. Already-connected agents are unaffected because they use their own stored per-server tokens; to revoke a specific agent, rotate its run token from the server detail actions, which forces that agent to reconnect. +The Agent Authority history records actor, source, transition, mode, offer outcome, and before/after status without storing plaintext secrets. These events survive Server deletion for operational investigation. ### Clean Up Unconnected Placeholders -Failed onboarding can leave behind offline `New Server` placeholders. The Servers page then shows a **Clean up unconnected** action that removes only never-initialized offline placeholders and deliberately keeps online-but-uninitialized agents. +Abandoned onboarding can leave behind offline Server placeholders. The Servers page then shows a **Clean up unconnected** action that removes only never-initialized offline placeholders and deliberately keeps online-but-uninitialized agents. diff --git a/apps/docs/content/docs/en/agent.mdx b/apps/docs/content/docs/en/agent.mdx index c2fb9267..766a0444 100644 --- a/apps/docs/content/docs/en/agent.mdx +++ b/apps/docs/content/docs/en/agent.mdx @@ -93,18 +93,13 @@ docker run -d \ --pid=host \ -v /proc:/host/proc:ro \ -v /sys:/host/sys:ro \ - -v /etc/machine-id:/etc/machine-id:ro \ -v /etc/serverbee:/etc/serverbee \ --restart unless-stopped \ ghcr.io/zingerlittlebee/serverbee-agent:latest ``` -The `/etc/serverbee` volume mount is mandatory. Agent writes its registration token to `agent.toml` after first connection. Without this volume, container recreation causes duplicate server entries. - - - -Mount `/etc/machine-id` from the host to keep the agent fingerprint stable across container recreation. +The `/etc/serverbee` volume mount is mandatory. Before claiming an Enrollment offer, the Agent generates and atomically stores its run token in `agent.toml`. Without persistent storage, a recreated container loses the credential that the Server accepted. **Limitations of Docker deployment:** @@ -115,11 +110,11 @@ Mount `/etc/machine-id` from the host to keep the agent fingerprint stable acros ## Registration Flow -Agents authenticate with the server using a **token**. There are two ways to obtain a token: a one-time enrollment code (recommended) or a manually created token. +Agents authenticate with the Server using an Agent-owned **run token**. The Agent generates and persists this secret locally before it claims a one-time Enrollment offer. The Server stores only its hash and never returns the plaintext token. ### Enrollment Code Registration (Recommended) -1. Sign in to the web UI as an admin, open **Settings**, and generate a one-time **enrollment code** (also available via `POST /api/agent/enrollments`, admin-only). The code is **single-use** and **short-lived** (default 10 minute expiry), and a fresh code is needed for each new agent. +1. Sign in as an admin and choose **Add Server**. Server onboarding atomically creates the Server identity and a one-time **Enrollment offer**. Its code is single-use, short-lived (10 minutes by default), and shown only once. 2. Configure the agent with the code, either via environment variables: ```bash @@ -132,24 +127,24 @@ or in the config file: ```toml title="/etc/serverbee/agent.toml" server_url = "http://your-server-ip:9527" -enrollment_code = "" -# Leave empty on first run; auto-populated after registration +enrollment_code = "" +# Leave empty on first run; the Agent generates and persists it before claim token = "" ``` 3. Start the agent. On first run (no token), it will: - - Send a registration request to `POST /api/agent/register` presenting the one-time enrollment code - - Receive a `server_id` and per-server `token` from the server (the code is consumed on this first successful registration) - - Save the token to the config file automatically + - Generate a high-entropy run token and atomically save it before any claim request + - Send `POST /api/agent/register` with the one-time code and `proposed_run_token` + - Receive only `server_id`; the Server hashes the proposed token while consuming the offer - Connect via WebSocket using the token for all future sessions -- the enrollment code is no longer needed On subsequent runs (token present), the agent connects directly over WebSocket, sends its static system info, and reports metrics on the configured interval. -When the agent can read a stable machine identifier, it also sends a fingerprint during registration. Repeated registration from the same machine reuses the existing server row instead of creating duplicate placeholders. If a code is lost, expired, or already used, the server responds with HTTP 401 and the agent logs `Registration failed: HTTP 401 ... enrollment code ... expired or already used`; mint a fresh code in Settings to retry. +If the HTTP result is ambiguous, the Agent first tries WebSocket authentication with the already-staged token. Success proves the claim committed; rejection permits retrying the same code and token. If a code was lost, use the exact Outstanding offer shown on the existing Server to replace it. Replacement is compare-and-swap by offer ID; it never silently replaces an unknown newer offer. ### Correcting a Wrong Enrollment Code -If the agent was installed with a mistyped enrollment code (or wrong `server_url`) and has **not yet registered** (no `token` saved), fix it without reinstalling. Enrollment codes are single-use, so a typo'd or already-consumed code cannot be reused — generate a fresh one in **Settings** first if needed, then: +If the Agent was installed with a mistyped enrollment code (or wrong `server_url`) and has not claimed the Server, fix it without reinstalling. Replace the exact Outstanding offer from the Server page when a new code is required, then: ```bash serverbee config set enrollment_code -y @@ -167,16 +162,7 @@ serverbee install agent --method \ --enrollment-code -y ``` -Once the agent has **already registered** (a `token` is present in `agent.toml`), the enrollment code is no longer used and does not need correcting; point the agent at a different server only by re-registering with a fresh code from that server. - -### Manual Token - -If you prefer not to use enrollment codes, you can manually create a server entry in the dashboard and provide the token directly: - -```toml -server_url = "http://your-server-ip:9527" -token = "the-agent-token-from-dashboard" -``` +Once the Agent has claimed the Server, the enrollment code is no longer used. Use **Agent re-enrollment** for replacement or reinstall scenarios: Graceful mode preserves current authority until the new claim, while Emergency mode revokes authority and fences the current connection immediately. ## Configuration @@ -196,7 +182,7 @@ Here is a complete `agent.toml` with all available options: # Required: URL of your ServerBee server server_url = "http://your-server-ip:9527" -# Authentication token (auto-populated after registration) +# Agent-owned run token (generated and staged locally before registration) token = "" # One-time enrollment code for first-time registration (used only if token is empty) @@ -216,7 +202,7 @@ file = "" # Log file path (empty = stdout only) |--------|------|---------|-------------| | `server_url` | string | required | URL of your ServerBee server | | `enrollment_code` | string | `""` | One-time enrollment code, needed only for first registration; consumed on success and unused once a token is present | -| `token` | string | auto-generated | Agent token, written automatically after registration | +| `token` | string | Agent-generated | Agent run token, atomically written before the claim request; the Server stores only its hash | | `collector.interval` | int | `3` | Metric collection interval in seconds; can be overridden by the server's Welcome message | | `collector.enable_gpu` | bool | `false` | Enable GPU metric collection | | `collector.enable_temperature` | bool | `true` | Enable temperature collection | diff --git a/apps/docs/content/docs/en/api-reference.mdx b/apps/docs/content/docs/en/api-reference.mdx index 0fc38103..51c0074e 100644 --- a/apps/docs/content/docs/en/api-reference.mdx +++ b/apps/docs/content/docs/en/api-reference.mdx @@ -84,7 +84,7 @@ curl https://your-server/api/auth/me \ | POST | `/api/mobile/auth/login` | Mobile login | | POST | `/api/mobile/auth/refresh` | Refresh a mobile session | | POST | `/api/mobile/auth/pair` | Redeem a mobile pairing code | -| POST | `/api/agent/register` | Agent registration using a one-time enrollment code (the code is consumed on first successful registration) | +| POST | `/api/agent/register` | Claim a bound enrollment offer with an Agent-proposed run token | | GET | `/api/status` | Default public status page data | | GET | `/api/status/{slug}` | Configurable public status page data | | GET | `/api/settings/brand` | Public brand settings | @@ -101,6 +101,7 @@ Read endpoints are available to Admin and Member users unless noted otherwise. | 2FA and OAuth accounts | `/api/auth/2fa/*`, `GET/DELETE /api/auth/oauth/accounts/*` | | Mobile devices | `POST /api/mobile/auth/logout`, `GET /api/mobile/auth/devices`, `DELETE /api/mobile/auth/devices/{id}` | | Servers | `GET /api/servers`, `GET /api/servers/{id}`, `GET /api/servers/{id}/records`, `GET /api/servers/{id}/gpu-records` | +| Agent Authority | `GET /api/servers/{id}/agent-authority`, `GET /api/agent-authority/events?server_id={id}` | | Groups and tags | `GET /api/server-groups`, `GET /api/server-tags` | | Uptime and traffic | `GET /api/servers/{id}/uptime-daily`, `GET /api/servers/{id}/traffic` | | GeoIP | `GET /api/geoip/status` | @@ -122,9 +123,8 @@ Admin role is required for write operations and system management. | Family | Representative endpoints | |--------|--------------------------| -| Server management | `POST/PUT/DELETE /api/servers/*`, `POST /api/servers/{id}/upgrade` | -| Agent enrollment | `POST /api/agent/enrollments`, `GET /api/agent/enrollments`, `DELETE /api/agent/enrollments/{id}`, `POST /api/agent/{id}/rotate-token` | -| Agent recovery | `GET /api/servers/{target_id}/recovery-candidates`, `GET /api/servers/recovery-jobs/{job_id}`, `POST /api/servers/{target_id}/recover-merge` | +| Server management | `POST /api/servers` (idempotent onboarding), `PUT/DELETE /api/servers/{id}`, `POST /api/servers/{id}/upgrade` | +| Agent Authority | re-enrollment, offer issue/replace/revoke, and authority revocation under `/api/servers/{id}/agent-authority/*` | | Groups and tags | CRUD `/api/server-groups/*`, CRUD `/api/server-tags/*` | | Ping and network probes | CRUD `/api/ping-tasks/*`, write endpoints under `/api/network-probes/*` | | Traceroute | `POST /api/servers/{id}/traceroute` | @@ -143,52 +143,49 @@ Admin role is required for write operations and system management. | GeoIP | `POST /api/geoip/download` | | Mobile push | `POST /api/mobile/pair`, `POST /api/mobile/push/register`, `POST /api/mobile/push/unregister` | -## Agent Enrollment Endpoints +## Agent Authority and Enrollment -All of these endpoints require the Admin role. Responses follow the standard `{ "data": ... }` envelope. +Responses follow the standard `{ "data": ... }` envelope. Agent Authority management requires Admin; state and event history are authenticated read endpoints. -### `POST /api/agent/enrollments` -- Mint an enrollment code +### `POST /api/servers` -- Onboard a Server -Mints a one-time, short-lived enrollment code (default TTL 600 seconds / 10 minutes; single-use; consumed on the agent's first successful registration). - -Response: +Creates the Server profile and its initial bound offer atomically. `onboarding_request_id` is required and is scoped to the authenticated actor. Retrying the same ID with identical normalized input returns the same `server_id`; reusing it with different input returns `409 ONBOARDING_IDEMPOTENCY_CONFLICT`. ```json { - "data": { - "id": "01J...", - "code": "", - "expires_at": "2026-05-17T12:34:56Z" - } + "onboarding_request_id": "019f...", + "name": "edge-tpe-1", + "tags": ["production"], + "ttl_secs": 600 } ``` -The plaintext `code` is returned only at mint time -- copy it immediately. +A new operation returns `replayed: false` and `enrollment: { id, code, code_prefix, expires_at }`. A replay returns `replayed: true`, never returns plaintext `code`, and may return `outstanding_offer` metadata. -### `GET /api/agent/enrollments` -- List enrollment codes +### `POST /api/agent/register` -- Claim an offer -Lists issued enrollment codes with metadata. For security this endpoint **never returns the plaintext code**, only an 8-character prefix plus status/expiry metadata. +This public Agent endpoint authenticates with `Authorization: Bearer <enrollment_code>` and requires an Agent-generated token: ```json -{ - "data": [ - { - "id": "01J...", - "code_prefix": "ab12cd34", - "expires_at": "2026-05-17T12:34:56Z", - "consumed_at": null - } - ] -} +{ "proposed_run_token": "<32-or-more non-whitespace characters>" } ``` -### `DELETE /api/agent/enrollments/{id}` -- Delete an enrollment code +On success it consumes the offer, stores only the run-token hash, and returns `{ "data": { "server_id": "..." } }`. The Server never returns the plaintext run token. + +### Agent Authority state and history + +- `GET /api/servers/{id}/agent-authority` returns `status` (`claimed` or `unclaimed`) and the current `outstanding_offer`, if any. +- `GET /api/agent-authority/events?server_id={id}&limit=100` returns secret-free transition history. Events survive Server deletion. -Deletes an unused enrollment code, invalidating it immediately. +### Offer and authority actions -### `POST /api/agent/{id}/rotate-token` -- Rotate/revoke a run token +- `POST /api/servers/{id}/agent-authority/re-enrollment` with `{ "mode": "graceful" | "emergency", "ttl_secs": 600 }` starts re-enrollment for a claimed authority. Graceful keeps the old authority valid; emergency revokes and fences it immediately. +- `POST /api/servers/{id}/agent-authority/offers` with optional `{ "ttl_secs": 600 }` issues an offer only when authority is unclaimed. +- `POST /api/servers/{id}/agent-authority/offers/{offer_id}/replace` replaces exactly the current outstanding offer and returns new plaintext once. +- `DELETE /api/servers/{id}/agent-authority/offers/{offer_id}` revokes exactly that offer. +- `DELETE /api/servers/{id}/agent-authority` revokes the current authority and disconnects its Agent without issuing an offer. -Rotates (revokes) the agent run token for the given server. The old token is invalidated immediately and the affected agent is forced to disconnect and must reconnect with the new token. +Only one offer may be outstanding per Server. Its terminal outcome is exactly one of `consumed`, `revoked`, `replaced`, or `expired`. Exact-ID replacement is compare-and-swap: stale or terminal offers return `409` and cannot overwrite newer state. ## WebSocket Endpoints diff --git a/apps/docs/content/docs/en/architecture.mdx b/apps/docs/content/docs/en/architecture.mdx index c1b61078..e2eb2e26 100644 --- a/apps/docs/content/docs/en/architecture.mdx +++ b/apps/docs/content/docs/en/architecture.mdx @@ -323,10 +323,12 @@ Request arrives #### Agent Token (agents) -- During registration, an agent authenticates with a one-time enrollment code (single-use, short-lived). -- The returned token is used for subsequent WebSocket connections. -- The token is hashed with argon2 and stored in the `servers` table. +- An enrollment offer is bound to one Server, single-use, and short-lived. Its lifecycle is owned by the Agent Authority module. +- Before claiming an offer, the Agent generates and persists its run token, then submits it as `proposed_run_token`. The Server never generates or returns that token. +- The run token is hashed with argon2 and stored in the `servers` table; only the Agent retains the plaintext. - Agents authenticate by providing the raw token as a query parameter on the WebSocket connection. +- WebSocket admission has two stages: preflight validation before the HTTP upgrade, then final validation while holding the Server lifecycle lock. Authority transitions and frame dispatch share that lock, so a revoked or replaced connection cannot race through after fencing. +- Every authority/offer transition appends a secret-free event. Events retain a snapshot of the Server identity and survive Server deletion. ### Role-Based Access diff --git a/apps/docs/content/docs/en/configuration.mdx b/apps/docs/content/docs/en/configuration.mdx index 569da430..5860e157 100644 --- a/apps/docs/content/docs/en/configuration.mdx +++ b/apps/docs/content/docs/en/configuration.mdx @@ -175,7 +175,7 @@ Agent top-level keys use single underscore. Nested keys use `__` (double undersc | Environment Variable | Default | Description | |---------------------|---------|-------------| | `SERVERBEE_SERVER_URL` | -- (required) | Server HTTP base URL (e.g. `http://your-server:9527`). Agent appends API paths automatically | -| `SERVERBEE_ENROLLMENT_CODE` | `""` | One-time enrollment code minted by an admin in Settings. Single-use and short-lived (default 10 min). Only used when token is empty | +| `SERVERBEE_ENROLLMENT_CODE` | `""` | One-time enrollment offer shown when a Server is added or re-enrollment begins. Single-use and short-lived (default 10 min). Only used when `token` is empty | #### Common @@ -200,7 +200,7 @@ Agent top-level keys use single underscore. Nested keys use `__` (double undersc | Environment Variable | Default | Description | |---------------------|---------|-------------| -| `SERVERBEE_TOKEN` | auto-populated | Agent auth token. Auto-populated after registration, do not set manually | +| `SERVERBEE_TOKEN` | generated when empty | Agent run token. During enrollment the Agent generates and persists one before claim; an explicit environment override is used as provided and is not written to disk | | `SERVERBEE_FILE__MAX_FILE_SIZE` | `1073741824` | Max file size in bytes for read/download (default 1GB) | | `SERVERBEE_FILE__DENY_PATTERNS` | `*.key,*.pem,...` | Glob patterns for files the agent refuses to access | | `SERVERBEE_IP_CHANGE__EXTERNAL_IP_URLS` | `["https://api.ipify.org","https://ifconfig.me/ip","https://icanhazip.com","https://checkip.amazonaws.com"]` | Ordered list of public IP services tried at startup and on every IP-change tick. First success wins. Required for agents behind NAT or in containers where interface enumeration can't see the routable IP. Set to `[]` to skip external lookups for air-gapped deployments | @@ -397,18 +397,13 @@ Default risk-scoring works out of the box via [ipapi.is](https://ipapi.is) (no A ## Agent Configuration (agent.toml) -> **Docker Agent:** Mount the host's machine-id for correct fingerprint identification: -> ``` -> -v /etc/machine-id:/etc/machine-id:ro -> ``` - ### Top-Level Options | Key | Type | Default | Description | |-----|------|---------|-------------| | `server_url` | string | **required** | URL of the ServerBee server (e.g., `http://10.0.0.1:9527`) | -| `token` | string | `""` | Agent authentication token (auto-populated after registration) | -| `enrollment_code` | string | `""` | One-time enrollment code from server Settings (used only when `token` is empty; consumed on first successful registration) | +| `token` | string | `""` | Agent authentication token. When enrollment is needed, the Agent generates and persists this token before claiming the offer; the Server stores only its hash | +| `enrollment_code` | string | `""` | One-time enrollment offer from Add Server or the Server's Agent Authority actions (used only when `token` is empty; consumed on first successful claim) | ### `[collector]` -- Metric Collection @@ -538,14 +533,14 @@ client_secret = "secret123" ```toml server_url = "http://your-server-ip:9527" -enrollment_code = "<one-time code from Settings>" +enrollment_code = "<one-time code from Add Server>" ``` ## Example: Production Agent Configuration ```toml server_url = "https://monitor.example.com" -token = "previously-obtained-token" +token = "<agent-generated persisted token>" [collector] interval = 3 diff --git a/apps/docs/content/docs/en/deployment.mdx b/apps/docs/content/docs/en/deployment.mdx index ddee6b7a..51d84526 100644 --- a/apps/docs/content/docs/en/deployment.mdx +++ b/apps/docs/content/docs/en/deployment.mdx @@ -533,7 +533,7 @@ Before exposing ServerBee to the internet: - [ ] Bind the server to localhost and use a reverse proxy - [ ] Set strong rate limits for login attempts - [ ] Enable TOTP two-factor authentication for admin accounts -- [ ] Mint enrollment codes only when onboarding an agent and keep them short-lived +- [ ] Use Add Server or explicit re-enrollment to issue a bound, short-lived offer only when needed - [ ] Keep the GeoIP database updated (if used) - [ ] Set up automated backups - [ ] Monitor the server itself with an external health check diff --git a/apps/docs/content/docs/en/quick-start.mdx b/apps/docs/content/docs/en/quick-start.mdx index e3c5c90a..35f385ba 100644 --- a/apps/docs/content/docs/en/quick-start.mdx +++ b/apps/docs/content/docs/en/quick-start.mdx @@ -114,9 +114,9 @@ sudo serverbee uninstall server --purge # Uninstall and wipe data The agent is a lightweight probe running on each monitored machine; it reports CPU, memory, disk, and network metrics back to the server. -### Step 1: Generate a one-time enrollment code on the server +### Step 1: Add the Server and receive its enrollment offer -Sign in to the dashboard as an admin, go to **Servers → Add Server**, enter a name for the new machine, click **Generate code**, and copy the one-time enrollment code. This also creates a pending server entry that the agent will claim when it first connects. +Sign in to the dashboard as an admin, go to **Servers → Add Server**, enter the machine profile, and submit it. ServerBee atomically creates the pending Server and its bound enrollment offer, then shows the one-time code and install command. The code is **single-use** and **short-lived** (10 minute default expiry), and is only needed the first time an agent registers. Generate a fresh one for each new agent. @@ -136,11 +136,7 @@ The script detects the architecture, downloads the binary, writes the config, re Install agents via the binary method — that's the only way to collect full host metrics. Running the agent in Docker is not recommended. </Callout> -On its first start the agent registers with the server: once registration succeeds the code is spent, and the agent receives its own token and writes it back to the config file. Later starts use that persisted token and no longer need the code. If an agent loses its token and has to re-onboard, just generate a new code in Settings. - -<Callout type="info"> -When the agent can read a stable machine identifier, re-registering from the same host reuses the existing server entry instead of creating a duplicate placeholder. -</Callout> +On its first start, the Agent generates and persists its run token before claiming the offer. The Server stores only the hash and returns the existing `server_id`. Later starts use that persisted token and no longer need the code. If the token is lost, start graceful or emergency re-enrollment from that Server's detail page. ### Step 3: Verify the connection diff --git a/apps/docs/content/docs/en/server.mdx b/apps/docs/content/docs/en/server.mdx index 09c0f638..aae46438 100644 --- a/apps/docs/content/docs/en/server.mdx +++ b/apps/docs/content/docs/en/server.mdx @@ -184,30 +184,26 @@ Capture this password from the logs. On first login you are required to change i The generated password is shown only once in the logs. Record it before the logs rotate, and complete the forced password change on first login before exposing the server to the internet. </Callout> -## Enrollment Codes +## Agent Enrollment -Agents enroll with a **one-time enrollment code** instead of a permanent shared secret. An admin mints a code from **Settings** in the web UI (or via `POST /api/agent/enrollments`, admin-only, which returns `{ id, code, expires_at }`). Enrollment codes have the following properties: +Adding a Server is one idempotent onboarding operation. `POST /api/servers` requires an `onboarding_request_id` and atomically creates the Server profile, tags, default probes, Agent Authority event, and one bound enrollment offer. Retrying the same request ID and input returns the existing Server. A replay never returns the plaintext code again, but it may identify the current outstanding offer so an administrator can replace that exact offer. -1. **Single-use** -- the code is consumed on the agent's first successful registration and cannot be reused afterwards -2. **Short-lived** -- expires after a default TTL of 600 seconds (10 minutes) -3. After registration the agent uses its persisted per-server token; the code is no longer needed +Enrollment offers are server-bound, single-use, and short-lived (10 minutes by default). Their terminal outcomes are `consumed`, `revoked`, `replaced`, or `expired`; a terminal offer can never become outstanding again. The plaintext code is returned only when an offer is created or replaced. -If a code is lost, expired, or already used, the server responds with HTTP 401 and the agent logs `Registration failed: HTTP 401 ... enrollment code ... expired or already used`. To onboard another agent (or re-enroll one that lost its token), mint a fresh code in Settings. Use `GET /api/agent/enrollments` to list issued codes (returns only an 8-character prefix and metadata, never the plaintext code) and `DELETE /api/agent/enrollments/{id}` to delete an unused code. +Before claiming an offer, the Agent generates and persists its own run token. It sends that proposed token with `POST /api/agent/register`; the Server stores only its hash and returns only the `server_id`. If the HTTP result is ambiguous, the Agent tries the WebSocket with its staged token before attempting another claim. -To reduce duplicate placeholder servers, agents reuse the existing server row when they re-register with the same machine fingerprint. You can also set `auth.max_servers` to soft-cap newly enrolled servers, and use **Clean up unconnected** on the Servers page to remove offline placeholders that never finished initialization. A connected agent's run token can be rotated or revoked via `POST /api/agent/{id}/rotate-token`, which forces that agent to reconnect. After rotation the affected agent is disconnected and must be reconfigured with the new token, or re-enrolled with a fresh one-time enrollment code (the original code is single-use and was already consumed). +The Server's `agent_authority.status` is either `claimed` or `unclaimed` and is independent of whether the Agent is currently online. The Agent Authority state and its outstanding offer are available from the Server detail APIs and UI. You can set `auth.max_servers` to cap new Server onboarding, and use **Clean up unconnected** to remove never-initialized offline placeholders. -## Recovering a Reinstalled Agent +## Re-enrollment and Revocation -If an existing server was reinstalled and then re-registered as a new temporary online node, you can recover it from the original offline server detail page: +Use the Server detail page's **Agent re-enrollment** action when a claimed Agent must receive a new run token: -1. Open the original offline server. -2. Click **Recover Agent**. -3. Choose the recommended online replacement candidate. -4. Start the recovery job. +1. **Graceful** keeps the current authority valid while issuing an enrollment offer. The currently connected Agent continues running until the new claim succeeds. +2. **Emergency** revokes the current authority immediately, fences the old connection, and issues an enrollment offer in the same transition. -The original server record is kept. The replacement agent is asked to rebind onto the original server identity, and the recovery flow continues from there. +Only one offer may be outstanding for a Server. Replacing or revoking it always targets the exact visible offer ID, so a stale browser cannot overwrite a newer offer. Revoking Agent Authority is a separate destructive action: it invalidates the current run token and disconnects the Agent, but deliberately does not issue a new offer. -Recovery is designed for reinstalling the same logical machine. It is not intended for arbitrary record merges or hardware migrations to a different host. +Agent Authority transitions are recorded as secret-free events. Their history remains available after the Server record is deleted. ## GeoIP Setup diff --git a/apps/docs/content/docs/zh/admin.mdx b/apps/docs/content/docs/zh/admin.mdx index f0b162e1..a82ded12 100644 --- a/apps/docs/content/docs/zh/admin.mdx +++ b/apps/docs/content/docs/zh/admin.mdx @@ -248,17 +248,17 @@ ServerBee 不再计算合成价值分,而是为每台已配置成本的服务 同样的数据也通过只读 API 暴露,便于自动化使用:`GET /api/cost/overview`(按币种汇总的机群总览 + 每台服务器摘要)和 `GET /api/servers/{id}/cost-insights`(单台服务器的完整明细)。认证细节见 [API 参考](/zh/docs/api-reference#已认证读取端点)。 -## Agent 注册管理 +## Agent Authority 管理 -管理员可以直接在界面里管理 Agent 的首次接入流程。 +管理员通过 **添加 Server** 和各 Server 的详情操作管理首次接入与重新接入。添加 Server 会原子地创建配置和一个绑定到该 Server 的 enrollment offer。明文 code 仅显示一次,后续页面只展示 offer ID、前缀、过期时间和 Agent Authority 状态。 -### 一次性注册码 +对于 unclaimed Server,管理员可以发出 offer。对于 claimed Server,可以选择 graceful 重新接入(新 claim 完成前保留当前 authority)或 emergency 重新接入(立即吊销并隔离当前 authority)。每个 Server 同时最多一个 outstanding offer,替换或吊销都必须针对准确 offer ID。吊销 Agent Authority 是独立操作,不会隐式生成 offer。 -进入 **设置** 页面,为新 Agent 生成一个一次性注册码。每个注册码单次使用且短时有效(默认 10 分钟过期),在 Agent 首次成功注册时被消费。你可以查看已生成的注册码列表(不会再次显示明文,仅显示 8 位前缀和元信息)并删除未使用的注册码。已连接的 Agent 不受影响,因为它们使用各自已保存的每台服务器专属 token;如需吊销某个 Agent,可在服务器详情操作中轮换其 run token,该 Agent 会被强制重新连接。 +Agent Authority 历史会记录操作者、来源、状态转换、模式、offer 结果和前后状态,但不会保存明文密钥。这些事件在 Server 删除后仍会保留,便于运维追查。 ### 清理未连接的占位服务器 -接入失败可能会留下离线的 `New Server` 占位条目。此时 **服务器** 页面会显示 **Clean up unconnected** 操作,它只删除从未完成初始化的离线占位服务器,并刻意保留在线但尚未初始化的 Agent。 +中止的接入可能会留下离线 Server 占位条目。此时 **服务器** 页面会显示 **Clean up unconnected** 操作,它只删除从未完成初始化的离线占位服务器,并刻意保留在线但尚未初始化的 Agent。 <Cards> <Card title="功能开关" href="/zh/docs/capabilities" /> diff --git a/apps/docs/content/docs/zh/agent.mdx b/apps/docs/content/docs/zh/agent.mdx index 893a4f24..efac3adf 100644 --- a/apps/docs/content/docs/zh/agent.mdx +++ b/apps/docs/content/docs/zh/agent.mdx @@ -93,18 +93,13 @@ docker run -d \ --pid=host \ -v /proc:/host/proc:ro \ -v /sys:/host/sys:ro \ - -v /etc/machine-id:/etc/machine-id:ro \ -v /etc/serverbee:/etc/serverbee \ --restart unless-stopped \ ghcr.io/zingerlittlebee/serverbee-agent:latest ``` <Callout type="warn"> -`/etc/serverbee` 挂载是必须的。Agent 注册成功后会将 Token 写入 `agent.toml`。如果不挂载此目录,容器重建后会重新注册并产生重复的服务器记录。 -</Callout> - -<Callout type="warn"> -请同时挂载宿主机的 `/etc/machine-id`,这样容器重建后 Agent 指纹才能保持稳定。 +`/etc/serverbee` 挂载是必须的。Agent 在领取 Enrollment Offer 前会生成 run token,并原子写入 `agent.toml`。若没有持久化存储,容器重建后会丢失 Server 已接受的凭据。 </Callout> **Docker 部署的限制:** @@ -115,11 +110,11 @@ docker run -d \ ## 注册流程 -Agent 通过 **Token** 向 Server 认证。获取 Token 有两种方式:一次性注册码(推荐)或手动创建的 Token。 +Agent 使用自己持有的 **run token** 向 Server 认证。Agent 会在领取一次性 Enrollment Offer 前本地生成并持久化该 secret;Server 只保存哈希,永远不会返回明文 token。 ### 通过注册码注册(推荐) -1. 以管理员身份登录 Server 管理面板,进入「设置」页面生成一个一次性**注册码**(也可通过 `POST /api/agent/enrollments` 生成,仅管理员可用)。注册码为**单次使用**且**短时有效**(默认 10 分钟过期),每接入一台新 Agent 都需重新生成。 +1. 以管理员身份登录后选择「添加服务器」。Server onboarding 会在同一事务中创建 Server 身份和一次性 **Enrollment Offer**。注册码单次使用、默认 10 分钟有效,且明文只显示一次。 2. 用注册码配置 Agent,可通过环境变量: ```bash @@ -132,24 +127,24 @@ serverbee-agent ```toml title="/etc/serverbee/agent.toml" server_url = "http://your-server-ip:9527" -enrollment_code = "<设置页生成的一次性注册码>" -# 首次运行时留空,注册成功后会自动写入 +enrollment_code = "<添加服务器时显示的一次性注册码>" +# 首次运行时留空,Agent 会在 claim 前生成并持久化 token = "" ``` 3. 启动 Agent。首次运行(无 token)时,它会: - - 向 Server 发送注册请求 `POST /api/agent/register`,携带一次性注册码 - - 从 Server 接收 `server_id` 和每服务器的 `token`(注册码在此次首次注册成功时被消费) - - 将 token 自动写回配置文件 + - 生成高熵 run token,并在发出 claim 前原子写入配置 + - 调用 `POST /api/agent/register`,同时提交一次性注册码和 `proposed_run_token` + - 只接收 `server_id`;Server 在消费 Offer 的同一事务中保存 token 哈希 - 后续所有会话都使用 token 通过 WebSocket 连接——注册码不再需要 后续运行(已有 token)时,Agent 直接通过 WebSocket 连接,发送静态系统信息,并按配置的间隔周期上报指标。 -当 Agent 能读取稳定的机器标识时,还会在注册请求中携带指纹。相同机器重复注册时会复用原有服务器记录,而不是继续创建重复的占位条目。如果注册码丢失、过期或已被使用,Server 会返回 HTTP 401,Agent 日志中会出现 `Registration failed: HTTP 401 ... enrollment code ... expired or already used`;到「设置」页重新生成一个新码即可重试。 +若 HTTP 结果不明确,Agent 会先用已落盘的 token 尝试 WebSocket。成功即可证明 claim 已提交;被拒绝时仍可用同一 code/token 重试。若注册码丢失,应在既有 Server 上按可见的精确 Offer ID 替换 Outstanding Offer,不会发生无条件覆盖。 ### 更正错误的注册码 -如果安装时把注册码(或 `server_url`)填错了,且 Agent **尚未注册成功**(配置中还没有 `token`),无需重装即可更正。注册码为单次使用,填错或已被消费的码无法重复使用——必要时先到管理面板「设置」页重新生成一个新码,再执行: +如果安装时把注册码(或 `server_url`)填错了,且 Agent 尚未完成 claim,无需重装即可更正。需要新码时,先在 Server 页面按精确 ID 替换当前 Outstanding Offer,再执行: ```bash serverbee config set enrollment_code <新注册码> -y @@ -167,16 +162,7 @@ serverbee install agent --method <binary|docker> \ --enrollment-code <新注册码> -y ``` -如果 Agent **已注册成功**(`agent.toml` 中已有 `token`),注册码便不再使用,无需更正;要把该 Agent 接到另一台 Server,只能用新 Server 的新码重新注册。 - -### 手动指定 Token - -如果不想使用注册码,也可以在管理面板手动创建一条服务器记录,然后把它的 token 直接填进配置: - -```toml -server_url = "http://your-server-ip:9527" -token = "从管理面板获取的 Agent Token" -``` +Agent 完成 claim 后便不再使用注册码。替换或重装时使用「Agent 重新接入」:平滑模式会保留当前 Authority,直到新 Agent claim;紧急模式会立即吊销 Authority 并封锁当前连接。 ## 配置文件 @@ -196,7 +182,7 @@ Agent 按以下顺序读取 TOML 配置文件: # 必填:ServerBee Server 的地址 server_url = "http://your-server-ip:9527" -# 认证 Token(注册成功后自动写入) +# Agent 自有 run token(注册前由 Agent 生成并落盘) token = "" # 首次注册用的一次性注册码(仅在 token 为空时使用) @@ -216,7 +202,7 @@ file = "" # 日志文件路径(留空仅输出到 stdout |--------|------|--------|------| | `server_url` | string | 必填 | ServerBee Server 的地址 | | `enrollment_code` | string | `""` | 一次性注册码,仅首次注册时需要;注册成功后即被消费,拥有 token 后无需再填 | -| `token` | string | 自动生成 | Agent Token,注册成功后自动写入 | +| `token` | string | Agent 生成 | Agent 在 claim 前原子写入的 run token;Server 仅保存哈希 | | `collector.interval` | int | `3` | 指标采集间隔,单位秒;可被 Server 的 Welcome 消息覆盖 | | `collector.enable_gpu` | bool | `false` | 是否启用 GPU 指标采集 | | `collector.enable_temperature` | bool | `true` | 是否启用温度采集 | diff --git a/apps/docs/content/docs/zh/api-reference.mdx b/apps/docs/content/docs/zh/api-reference.mdx index c285d266..c14100b8 100644 --- a/apps/docs/content/docs/zh/api-reference.mdx +++ b/apps/docs/content/docs/zh/api-reference.mdx @@ -84,7 +84,7 @@ curl https://your-server/api/auth/me \ | POST | `/api/mobile/auth/login` | 移动端登录 | | POST | `/api/mobile/auth/refresh` | 刷新移动端会话 | | POST | `/api/mobile/auth/pair` | 兑换移动端配对码 | -| POST | `/api/agent/register` | Agent 使用一次性注册码注册(注册码在首次成功注册时被消费) | +| POST | `/api/agent/register` | 使用 Agent 提议的 run token claim 已绑定的 enrollment offer | | GET | `/api/status` | 默认公开状态页数据 | | GET | `/api/status/{slug}` | 可配置公开状态页数据 | | GET | `/api/settings/brand` | 公开品牌设置 | @@ -101,6 +101,7 @@ curl https://your-server/api/auth/me \ | 2FA 和 OAuth 账号 | `/api/auth/2fa/*`、`GET/DELETE /api/auth/oauth/accounts/*` | | 移动端设备 | `POST /api/mobile/auth/logout`、`GET /api/mobile/auth/devices`、`DELETE /api/mobile/auth/devices/{id}` | | 服务器 | `GET /api/servers`、`GET /api/servers/{id}`、`GET /api/servers/{id}/records`、`GET /api/servers/{id}/gpu-records` | +| Agent Authority | `GET /api/servers/{id}/agent-authority`、`GET /api/agent-authority/events?server_id={id}` | | 分组和标签 | `GET /api/server-groups`、`GET /api/server-tags` | | 可用性和流量 | `GET /api/servers/{id}/uptime-daily`、`GET /api/servers/{id}/traffic` | | GeoIP | `GET /api/geoip/status` | @@ -122,9 +123,8 @@ curl https://your-server/api/auth/me \ | 端点族 | 代表端点 | |--------|----------| -| 服务器管理 | `POST/PUT/DELETE /api/servers/*`、`POST /api/servers/{id}/upgrade` | -| Agent 注册码 | `POST /api/agent/enrollments`、`GET /api/agent/enrollments`、`DELETE /api/agent/enrollments/{id}`、`POST /api/agent/{id}/rotate-token` | -| Agent 恢复 | `GET /api/servers/{target_id}/recovery-candidates`、`GET /api/servers/recovery-jobs/{job_id}`、`POST /api/servers/{target_id}/recover-merge` | +| 服务器管理 | `POST /api/servers`(幂等 onboarding)、`PUT/DELETE /api/servers/{id}`、`POST /api/servers/{id}/upgrade` | +| Agent Authority | `/api/servers/{id}/agent-authority/*` 下的重新接入、offer 发出/替换/吊销及 authority 吊销 | | 分组和标签 | CRUD `/api/server-groups/*`、CRUD `/api/server-tags/*` | | Ping 和网络探测 | CRUD `/api/ping-tasks/*`、`/api/network-probes/*` 下的写入端点 | | Traceroute | `POST /api/servers/{id}/traceroute` | @@ -143,52 +143,49 @@ curl https://your-server/api/auth/me \ | GeoIP | `POST /api/geoip/download` | | 移动端推送 | `POST /api/mobile/pair`、`POST /api/mobile/push/register`、`POST /api/mobile/push/unregister` | -## Agent 注册码端点 +## Agent Authority 与接入 -以下端点均需 Admin 角色。所有响应遵循统一的 `{ "data": ... }` 包装格式。 +所有响应遵循统一的 `{ "data": ... }` 包装格式。Agent Authority 管理操作需要 Admin;状态与事件历史属于已认证读取端点。 -### `POST /api/agent/enrollments` —— 生成注册码 +### `POST /api/servers` —— Onboard Server -生成一个一次性、短时有效的注册码(默认 TTL 600 秒 / 10 分钟,单次使用,在 Agent 首次成功注册时被消费)。 - -响应: +原子地创建 Server 配置及其初始绑定 offer。`onboarding_request_id` 必填,并按当前认证操作者隔离。使用相同 ID 和相同标准化输入重试会返回同一个 `server_id`;同一 ID 搭配不同输入会返回 `409 ONBOARDING_IDEMPOTENCY_CONFLICT`。 ```json { - "data": { - "id": "01J...", - "code": "<明文注册码,仅此一次返回>", - "expires_at": "2026-05-17T12:34:56Z" - } + "onboarding_request_id": "019f...", + "name": "edge-tpe-1", + "tags": ["production"], + "ttl_secs": 600 } ``` -明文 `code` 只在生成时返回一次,请立即复制。 +首次执行返回 `replayed: false` 和 `enrollment: { id, code, code_prefix, expires_at }`。重放返回 `replayed: true`,绝不再次返回明文 `code`,并可能返回 `outstanding_offer` 元数据。 -### `GET /api/agent/enrollments` —— 列出注册码 +### `POST /api/agent/register` —— Claim offer -列出已生成的注册码及其元信息。出于安全考虑,**永不返回明文注册码**,仅返回 8 位前缀和状态/过期等元数据。 +此公开 Agent 端点使用 `Authorization: Bearer <enrollment_code>` 认证,并要求 Agent 自己生成 token: ```json -{ - "data": [ - { - "id": "01J...", - "code_prefix": "ab12cd34", - "expires_at": "2026-05-17T12:34:56Z", - "consumed_at": null - } - ] -} +{ "proposed_run_token": "<至少 32 个非空白字符>" } ``` -### `DELETE /api/agent/enrollments/{id}` —— 删除注册码 +成功后会消费 offer、只保存 run token 哈希,并返回 `{ "data": { "server_id": "..." } }`。Server 永不返回明文 run token。 + +### Agent Authority 状态与历史 + +- `GET /api/servers/{id}/agent-authority` 返回 `status`(`claimed` 或 `unclaimed`)以及当前 `outstanding_offer`(如有)。 +- `GET /api/agent-authority/events?server_id={id}&limit=100` 返回不含密钥的状态转换历史。Server 删除后事件仍会保留。 -删除一个尚未使用的注册码,使其立即失效。 +### Offer 与 authority 操作 -### `POST /api/agent/{id}/rotate-token` —— 轮换/吊销 run token +- `POST /api/servers/{id}/agent-authority/re-enrollment` 携带 `{ "mode": "graceful" | "emergency", "ttl_secs": 600 }`,为 claimed authority 开始重新接入。Graceful 保留旧 authority,emergency 立即吊销并隔离它。 +- `POST /api/servers/{id}/agent-authority/offers` 携带可选的 `{ "ttl_secs": 600 }`,仅在 authority 为 unclaimed 时发出 offer。 +- `POST /api/servers/{id}/agent-authority/offers/{offer_id}/replace` 精确替换当前 outstanding offer,并仅返回一次新的明文 code。 +- `DELETE /api/servers/{id}/agent-authority/offers/{offer_id}` 精确吊销该 offer。 +- `DELETE /api/servers/{id}/agent-authority` 吊销当前 authority 并断开 Agent,但不生成 offer。 -轮换(吊销)指定服务器的 Agent run token。旧 token 立即失效,对应 Agent 会被强制断开并需使用新 token 重新连接。 +每个 Server 同时最多一个 outstanding offer。它的终态严格为 `consumed`、`revoked`、`replaced` 或 `expired` 之一。精确 ID 替换采用 compare-and-swap,过期页面或已进入终态的 offer 返回 `409`,不能覆盖更新状态。 ## WebSocket 端点 diff --git a/apps/docs/content/docs/zh/architecture.mdx b/apps/docs/content/docs/zh/architecture.mdx index 7ff053a5..cb67485b 100644 --- a/apps/docs/content/docs/zh/architecture.mdx +++ b/apps/docs/content/docs/zh/architecture.mdx @@ -323,10 +323,12 @@ ServerBee 支持三种认证机制,均在认证中间件中校验: #### Agent Token 认证(Agent) -- 注册时通过一次性注册码(Enrollment Code)认证,注册码单次使用且短时有效。 -- 返回的 Token 用于后续 WebSocket 连接。 -- Token 以 argon2 哈希存储在 `servers` 表。 -- Agent 在 WebSocket 连接的查询参数中提供原始 Token 进行认证。 +- Enrollment offer 绑定到一个 Server,单次使用且短时有效,其生命周期由 Agent Authority 模块统一管理。 +- Agent 在 claim offer 前生成并持久化 run token,然后将其作为 `proposed_run_token` 提交。Server 不生成也不返回该 token。 +- Run token 以 argon2 哈希存储在 `servers` 表,只有 Agent 保留明文。 +- Agent 在 WebSocket 连接的查询参数中提供原始 token 进行认证。 +- WebSocket admission 分两阶段:HTTP upgrade 前做 preflight,再持有 Server lifecycle lock 做最终校验。Authority 转换与消息分发共享该锁,被吊销或替换的连接无法在 fencing 后抢跑。 +- 每次 authority/offer 转换都会追加一个不含密钥的事件。事件保存 Server 身份快照,并在 Server 删除后继续保留。 ### 基于角色的访问控制(RBAC) diff --git a/apps/docs/content/docs/zh/configuration.mdx b/apps/docs/content/docs/zh/configuration.mdx index 3f967619..7a169f00 100644 --- a/apps/docs/content/docs/zh/configuration.mdx +++ b/apps/docs/content/docs/zh/configuration.mdx @@ -175,7 +175,7 @@ Agent 顶层键使用单下划线,嵌套键使用 `__`(双下划线)。 | 环境变量 | 默认值 | 说明 | |----------|--------|------| | `SERVERBEE_SERVER_URL` | --(必填) | Server 的 HTTP 基础地址(如 `http://your-server:9527`),Agent 自动拼接 API 路径 | -| `SERVERBEE_ENROLLMENT_CODE` | `""` | 一次性注册码,由管理员在设置页生成;单次使用、短时有效(默认 10 分钟),仅在 Token 为空时使用 | +| `SERVERBEE_ENROLLMENT_CODE` | `""` | 添加 Server 或开始重新接入时显示的一次性注册 offer;单次使用、短时有效(默认 10 分钟),仅在 `token` 为空时使用 | #### 常用配置(Common) @@ -200,7 +200,7 @@ Agent 顶层键使用单下划线,嵌套键使用 `__`(双下划线)。 | 环境变量 | 默认值 | 说明 | |----------|--------|------| -| `SERVERBEE_TOKEN` | 注册后自动填充 | Agent 认证 Token。注册后自动填充,无需手动设置 | +| `SERVERBEE_TOKEN` | 留空时由 Agent 生成 | Agent run token。接入时 Agent 会在 claim 前生成并持久化;显式环境变量值会原样使用且不写入磁盘 | | `SERVERBEE_FILE__MAX_FILE_SIZE` | `1073741824` | 文件读取 / 下载的最大字节数(默认 1GB) | | `SERVERBEE_FILE__DENY_PATTERNS` | `*.key,*.pem,...` | Agent 拒绝访问的文件名 glob 模式 | | `SERVERBEE_IP_CHANGE__EXTERNAL_IP_URLS` | `["https://api.ipify.org","https://ifconfig.me/ip","https://icanhazip.com","https://checkip.amazonaws.com"]` | 公网 IP 查询服务的有序列表,Agent 启动和每次 IP 变更检测时按顺序逐个尝试,首个成功即采用。在 NAT、容器或网卡看不到可路由 IP 的环境下是必需的。完全离线部署可设为 `[]` 跳过外部查询 | @@ -397,18 +397,13 @@ Agent 顶层键使用单下划线,嵌套键使用 `__`(双下划线)。 ## Agent 配置(agent.toml) -> **Docker Agent:** 挂载宿主机的 machine-id 以确保指纹识别正确: -> ``` -> -v /etc/machine-id:/etc/machine-id:ro -> ``` - ### 顶层选项 | 键 | 类型 | 默认值 | 说明 | |----|------|--------|------| | `server_url` | string | **必填** | ServerBee Server 的 URL(如 `http://10.0.0.1:9527`) | -| `token` | string | `""` | Agent 认证 Token(注册后自动填充) | -| `enrollment_code` | string | `""` | 一次性注册码,来自 Server 设置页(仅在 `token` 为空时使用;首次注册成功后即被消费) | +| `token` | string | `""` | Agent 认证 Token。需要接入时由 Agent 在 claim offer 前生成并持久化,Server 仅存储其哈希 | +| `enrollment_code` | string | `""` | 来自「添加 Server」或 Server 的 Agent Authority 操作的一次性注册 offer(仅在 `token` 为空时使用;首次成功 claim 后即被消费) | ### `[collector]` —— 指标采集 @@ -538,14 +533,14 @@ client_secret = "secret123" ```toml server_url = "http://your-server-ip:9527" -enrollment_code = "<来自设置页的一次性注册码>" +enrollment_code = "<添加 Server 时显示的一次性注册码>" ``` ## 示例:生产 Agent 配置 ```toml server_url = "https://monitor.example.com" -token = "previously-obtained-token" +token = "<Agent 生成并持久化的 Token>" [collector] interval = 3 diff --git a/apps/docs/content/docs/zh/deployment.mdx b/apps/docs/content/docs/zh/deployment.mdx index fcea8400..44edd09a 100644 --- a/apps/docs/content/docs/zh/deployment.mdx +++ b/apps/docs/content/docs/zh/deployment.mdx @@ -533,7 +533,7 @@ ServerBee 面向轻量级 VPS 实例设计: - [ ] 将服务端绑定到 localhost,并通过反向代理对外暴露 - [ ] 为登录尝试设置严格的速率限制 - [ ] 为管理员账号启用 TOTP 两步验证 -- [ ] 仅在接入 Agent 时签发注册码,并保持其短时有效 +- [ ] 仅在需要时通过「添加 Server」或显式重新接入发出绑定且短时有效的 offer - [ ] 保持 GeoIP 数据库更新(如启用) - [ ] 配置自动备份 - [ ] 用外部健康检查监控服务端自身 diff --git a/apps/docs/content/docs/zh/quick-start.mdx b/apps/docs/content/docs/zh/quick-start.mdx index a95397a7..ea1c0a24 100644 --- a/apps/docs/content/docs/zh/quick-start.mdx +++ b/apps/docs/content/docs/zh/quick-start.mdx @@ -114,9 +114,9 @@ sudo serverbee uninstall server --purge # 卸载服务端并清除数据 Agent 是跑在被监控机器上的轻量探针,负责把 CPU、内存、磁盘、网络等指标上报给 Server。 -### 第一步:在 Server 生成一次性注册码 +### 第一步:添加 Server 并获取 enrollment offer -以管理员身份登录管理面板,进入「Servers(服务器)」页面,点击「Add Server(添加服务器)」,输入新机器的名称并点击「Generate code(生成注册码)」,复制生成的一次性注册码(Enrollment Code)。此操作会同时创建一条待接入(Pending)的服务器记录,Agent 首次连接时会自动认领。 +以管理员身份登录管理面板,进入「Servers(服务器)」页面,点击「Add Server(添加服务器)」并提交机器配置。ServerBee 会原子地创建待接入 Server 及其绑定的 enrollment offer,然后显示一次性 code 和安装命令。 注册码**只能用一次**,而且**很快过期**(默认 10 分钟),只在 Agent 首次注册时用得到。每接入一台新 Agent,就重新生成一个。 @@ -136,11 +136,7 @@ curl -fsSL https://raw.githubusercontent.com/ZingerLittleBee/ServerBee/main/depl Agent 推荐用二进制方式安装,这样才能采集到宿主机的完整指标;不建议用 Docker 跑 Agent。 </Callout> -Agent 首次启动会自动向 Server 注册:注册成功后注册码即作废,Agent 拿到一份专属 Token 并写回配置文件;之后启动都用这份持久化的 Token,不再需要注册码。如果某台 Agent 丢了 Token 要重新接入,去设置页再生成一个新码即可。 - -<Callout type="info"> -当 Agent 能读到稳定的机器标识时,重复注册会复用已有的服务器记录,而不是新建一条占位条目。 -</Callout> +Agent 首次启动时会在 claim offer 前自行生成并持久化 run token。Server 只保存其哈希并返回已有 `server_id`;之后启动都用这份持久化 token,不再需要注册码。如果 token 丢失,在该 Server 详情页发起 graceful 或 emergency 重新接入。 ### 第三步:验证连接 diff --git a/apps/docs/content/docs/zh/server.mdx b/apps/docs/content/docs/zh/server.mdx index 0a8c7287..5c321844 100644 --- a/apps/docs/content/docs/zh/server.mdx +++ b/apps/docs/content/docs/zh/server.mdx @@ -182,30 +182,26 @@ Server 首次启动时,如果 `users` 表为空,会自动创建管理员账 自动生成的密码只在日志中显示一次。请在日志轮转前记录下来,并在首次登录时完成强制改密,再将 Server 暴露到公网。 </Callout> -## 一次性注册码(Enrollment Code) +## Agent 接入 -Agent 首次注册通过一次性注册码完成。由管理员在 Web 管理面板的「设置」页面生成(或调用 `POST /api/agent/enrollments`,仅管理员可用,返回 `{ id, code, expires_at }`)。注册码具有以下特性: +添加 Server 是一个幂等的 onboarding 操作。`POST /api/servers` 必须携带 `onboarding_request_id`,并在同一个事务中创建 Server 配置、标签、默认探测目标、Agent Authority 事件和一个绑定到该 Server 的 enrollment offer。使用相同 request ID 和相同输入重试会返回已有 Server。重放响应绝不会再次返回明文 code,但可以标识当前 outstanding offer,管理员随后可精确替换该 offer。 -1. **单次使用** —— 在 Agent 首次成功注册时即被消费,之后不可再用 -2. **短时有效** —— 默认 10 分钟(600 秒)后过期 -3. 注册成功后 Agent 使用持久化的每台服务器专属 token,不再需要注册码 +Enrollment offer 绑定到具体 Server,单次使用且短时有效(默认 10 分钟)。它的终态只有 `consumed`、`revoked`、`replaced` 和 `expired`,进入终态后不能恢复为 outstanding。明文 code 仅在新建或替换 offer 时返回一次。 -如果注册码丢失、过期或已被使用,Server 返回 HTTP 401,Agent 日志会显示 `Registration failed: HTTP 401 ... enrollment code ... expired or already used`。需要接入新 Agent(或为丢失 token 的 Agent 重新接入)时,在设置页重新生成一个新注册码即可。可通过 `GET /api/agent/enrollments` 查看已生成的注册码列表(仅返回 8 位前缀等元信息,不会返回明文注册码),通过 `DELETE /api/agent/enrollments/{id}` 删除尚未使用的注册码。 +Agent 在 claim offer 前自行生成并持久化 run token,然后通过 `POST /api/agent/register` 提交 proposed token。Server 只保存哈希,并且只返回 `server_id`。如果 HTTP 结果不明确,Agent 会先用已暂存的 token 尝试 WebSocket,再决定是否重新 claim。 -为减少重复的占位服务器,Agent 在可读取稳定机器标识时会携带指纹,相同机器重复注册会复用原有服务器记录。你还可以通过 `auth.max_servers` 软限制通过注册码新建的服务器数量;如果历史失败注册留下了离线占位条目,可在 `/servers` 页面使用 **Clean up unconnected** 清理。已连接 Agent 的 run token 可通过 `POST /api/agent/{id}/rotate-token` 轮换/吊销,吊销后该 Agent 会被强制重新连接。轮换后该 Agent 会断开连接,必须用新 token 重新配置,或使用一个新的一次性 enrollment code 重新注册(原 code 单次使用且已被消费)。 +Server 的 `agent_authority.status` 只有 `claimed` 与 `unclaimed`,它与 Agent 当前是否在线相互独立。Server 详情 API 和 UI 会展示 Agent Authority 状态及 outstanding offer。你可以用 `auth.max_servers` 限制新 Server onboarding,并用 **Clean up unconnected** 清理从未初始化的离线占位条目。 -## 恢复重装后的 Agent +## 重新接入与吊销 -如果一台已有服务器重装系统后重新注册成了一条新的临时在线节点,可以在原来的离线服务器详情页里发起恢复: +已 claimed 的 Agent 需要更换 run token 时,在 Server 详情页使用 **Agent 重新接入**: -1. 打开原来的离线服务器 -2. 点击 **恢复 Agent** -3. 选择推荐的在线替代节点 -4. 启动恢复任务 +1. **Graceful** 保留现有 authority,同时发出 enrollment offer。新 claim 完成前,当前 Agent 可继续运行。 +2. **Emergency** 立即吊销现有 authority、隔离旧连接,并在同一状态转换中发出 enrollment offer。 -恢复会保留原始服务器记录,并要求替代 Agent 重新绑定到原来的服务器身份,后续恢复流程会继续在这个基础上执行。 +每个 Server 同时最多有一个 outstanding offer。替换或吊销必须指定界面中可见的准确 offer ID,过期页面因此不能覆盖较新的 offer。吊销 Agent Authority 是独立的破坏性操作,它会使当前 run token 失效并断开 Agent,但不会顺手生成新 offer。 -这个恢复流程用于“同一台逻辑机器重装后重新接回”。它不适用于任意两条服务器记录的合并,也不适用于迁移到完全不同的硬件主机。 +所有 Agent Authority 状态转换都会记录不含密钥的事件。即使 Server 记录已删除,历史事件仍会保留。 ## GeoIP 设置 diff --git a/apps/ios/ServerBee/Localizable.xcstrings b/apps/ios/ServerBee/Localizable.xcstrings index 45b3e45a..d165e94f 100644 --- a/apps/ios/ServerBee/Localizable.xcstrings +++ b/apps/ios/ServerBee/Localizable.xcstrings @@ -4530,6 +4530,40 @@ } } }, + "Enrollment offer expired": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enrollment offer expired" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Enrollment Offer 已过期" + } + } + } + }, + "This offer is terminal and can no longer be replaced or revoked.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This offer is terminal and can no longer be replaced or revoked." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "此 Offer 已进入终态,不能再替换或吊销。" + } + } + } + }, "Enter the 6-digit code from your authenticator app": { "localizations": { "en": { @@ -5192,36 +5226,36 @@ } } }, - "Generate new code": { + "Graceful re-enrollment": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Generate new code" + "value": "Graceful re-enrollment" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "生成新注册码" + "value": "平滑重新接入" } } } }, - "Generates a new enrollment code for this server. Revoking immediately disconnects the current agent until it re-enrolls with the new code.": { + "Graceful mode preserves current Agent Authority until claim. Emergency mode revokes it and fences the connection immediately.": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Generates a new enrollment code for this server. Revoking immediately disconnects the current agent until it re-enrolls with the new code." + "value": "Graceful mode preserves current Agent Authority until claim. Emergency mode revokes it and fences the connection immediately." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "为此服务器生成新的注册码。吊销后当前 Agent 将立即断开连接,直到使用新注册码重新接入。" + "value": "平滑模式会保留当前 Agent Authority,直到新 Offer 被领取。紧急模式会立即吊销 Authority 并封锁连接。" } } } @@ -9135,19 +9169,19 @@ } } }, - "Pending enrollment code": { + "Outstanding enrollment offer": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Pending enrollment code" + "value": "Outstanding enrollment offer" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "待使用的注册码" + "value": "未完成的 Enrollment Offer" } } } @@ -9951,19 +9985,19 @@ } } }, - "Recover agent": { + "Agent re-enrollment": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Recover agent" + "value": "Agent re-enrollment" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "恢复 Agent" + "value": "Agent 重新接入" } } } @@ -10529,36 +10563,36 @@ } } }, - "Revoke pending code": { + "Revoke offer": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Revoke pending code" + "value": "Revoke offer" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "撤销待使用注册码" + "value": "吊销 Offer" } } } }, - "Revoke the pending code before generating a new one.": { + "Replace this exact offer if its plaintext code was lost, or revoke it without creating a successor.": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Revoke the pending code before generating a new one." + "value": "Replace this exact offer if its plaintext code was lost, or revoke it without creating a successor." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "生成新注册码前请先撤销待使用的注册码。" + "value": "若明文注册码丢失,请按精确 ID 替换此 Offer;也可以直接吊销且不创建后继。" } } } @@ -10580,19 +10614,189 @@ } } }, - "Revoke token & generate code": { + "Emergency re-enrollment": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Emergency re-enrollment" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "紧急重新接入" + } + } + } + }, + "Issue enrollment offer": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Issue enrollment offer" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "签发 Enrollment Offer" + } + } + } + }, + "No outstanding enrollment offer remains. Manage Agent Authority from the existing server.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No outstanding enrollment offer remains. Manage Agent Authority from the existing server." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当前没有未完成的 Enrollment Offer,请从已有服务器管理 Agent Authority。" + } + } + } + }, + "Replace offer": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Replace offer" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "替换 Offer" + } + } + } + }, + "Replace outstanding offer": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Replace outstanding offer" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "替换当前 Offer" + } + } + } + }, + "Revoke Agent Authority": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Revoke Agent Authority" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "吊销 Agent Authority" + } + } + } + }, + "Revoke Agent Authority?": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Revoke Agent Authority?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "吊销 Agent Authority?" + } + } + } + }, + "Server already created": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Server already created" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "服务器已创建" + } + } + } + }, + "Server or enrollment offer not found": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Server or enrollment offer not found" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未找到服务器或 Enrollment Offer" + } + } + } + }, + "The Agent is fenced immediately and the server becomes unclaimed. No Enrollment offer is created.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The Agent is fenced immediately and the server becomes unclaimed. No Enrollment offer is created." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Agent 将立即被封锁,服务器变为未认领状态,且不会创建 Enrollment Offer。" + } + } + } + }, + "This onboarding request already created the server. The original plaintext code cannot be recovered.": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Revoke token & generate code" + "value": "This onboarding request already created the server. The original plaintext code cannot be recovered." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "撤销令牌并生成注册码" + "value": "此接入请求已创建服务器,原始明文注册码无法找回。" } } } diff --git a/apps/ios/ServerBee/Models/EnrollmentModels.swift b/apps/ios/ServerBee/Models/EnrollmentModels.swift index 33057f76..56f4bbf3 100644 --- a/apps/ios/ServerBee/Models/EnrollmentModels.swift +++ b/apps/ios/ServerBee/Models/EnrollmentModels.swift @@ -1,7 +1,7 @@ import Foundation -/// A freshly-minted enrollment code. The plaintext `code` is returned ONLY at -/// mint time (create / recover / regenerate) and can never be fetched again. +/// A freshly issued enrollment code. The plaintext `code` is returned only at +/// issue time and can never be fetched again. struct EnrollmentIssue: Decodable, Sendable { let id: String let code: String @@ -18,48 +18,66 @@ struct EnrollmentIssue: Decodable, Sendable { /// Request body for `POST /api/servers` (create a pending server). Mobile keeps /// this minimal — a name; the agent reports the rest after it connects. struct CreateServerRequest: Encodable, Sendable { + let onboardingRequestId: String let name: String var groupId: String? enum CodingKeys: String, CodingKey { case name + case onboardingRequestId = "onboarding_request_id" case groupId = "group_id" } } -/// `POST /api/servers` response: the new (pending) server id + its first code. +/// `POST /api/servers` may replay an earlier request. Replay never recovers the +/// original plaintext code and only returns current offer metadata. struct CreateServerResponse: Decodable, Sendable { let serverId: String - let enrollment: EnrollmentIssue + let replayed: Bool + let enrollment: EnrollmentIssue? + let outstandingOffer: OutstandingOffer? enum CodingKeys: String, CodingKey { case serverId = "server_id" - case enrollment + case replayed, enrollment + case outstandingOffer = "outstanding_offer" } } -/// Body for `POST /api/servers/{id}/recover`. When `revokeImmediately` is true -/// the existing agent token is cleared and the connected agent kicked. -struct RecoverRequest: Encodable, Sendable { - let revokeImmediately: Bool +enum ReenrollmentMode: String, Encodable, Sendable { + case graceful + case emergency +} + +struct ReenrollmentRequest: Encodable, Sendable { + let mode: ReenrollmentMode +} + +struct IssueOfferRequest: Encodable, Sendable { + enum CodingKeys: CodingKey {} +} + +struct RevokeOfferResponse: Decodable, Sendable { + let offerId: String + let alreadyRevoked: Bool enum CodingKeys: String, CodingKey { - case revokeImmediately = "revoke_immediately" + case offerId = "offer_id" + case alreadyRevoked = "already_revoked" } } -/// Body for `POST /api/servers/{id}/regenerate-code`. Omit `expectedEnrollmentId` -/// for last-writer-wins (mobile default). -struct RegenerateCodeRequest: Encodable, Sendable { - var expectedEnrollmentId: String? +struct RevokeAuthorityResponse: Decodable, Sendable { + let serverId: String + let changed: Bool enum CodingKeys: String, CodingKey { - case expectedEnrollmentId = "expected_enrollment_id" + case serverId = "server_id" + case changed } } -/// Both recover and regenerate return `{ enrollment }`. -struct EnrollmentOnlyResponse: Decodable, Sendable { +struct EnrollmentOfferResponse: Decodable, Sendable { let enrollment: EnrollmentIssue } diff --git a/apps/ios/ServerBee/Models/ServerConfig.swift b/apps/ios/ServerBee/Models/ServerConfig.swift index 67118a37..e08905cc 100644 --- a/apps/ios/ServerBee/Models/ServerConfig.swift +++ b/apps/ios/ServerBee/Models/ServerConfig.swift @@ -24,10 +24,8 @@ struct UpdateGroupRequest: Encodable, Sendable { var weight: Int? } -/// A pending enrollment code summary attached to a server that has not yet been -/// claimed by an agent. The plaintext code is NEVER returned here — only on the -/// create/recover/regenerate calls. -struct OutstandingEnrollment: Decodable, Hashable, Sendable { +/// An outstanding Enrollment offer. Plaintext is never present in projections. +struct OutstandingOffer: Decodable, Hashable, Sendable { let id: String let codePrefix: String? let expiresAt: String? @@ -39,6 +37,28 @@ struct OutstandingEnrollment: Decodable, Hashable, Sendable { case expiresAt = "expires_at" case createdAt = "created_at" } + + func isExpired(at date: Date = Date()) -> Bool { + guard let expiresAt, let expiry = ISO8601DateFormatter.shared.date(from: expiresAt) else { + return false + } + return expiry <= date + } +} + +enum AgentAuthorityStatus: String, Decodable, Hashable, Sendable { + case claimed + case unclaimed +} + +struct AgentAuthorityState: Decodable, Hashable, Sendable { + let status: AgentAuthorityStatus + let outstandingOffer: OutstandingOffer? + + enum CodingKeys: String, CodingKey { + case status + case outstandingOffer = "outstanding_offer" + } } /// Full server *configuration* as returned by REST `/api/servers/{id}`. @@ -80,8 +100,9 @@ struct ServerConfig: Decodable, Identifiable, Hashable, Sendable { var effectiveCapabilities: Int? var protocolVersion: Int? var features: [String]? + var agentAuthority: AgentAuthorityState? var hasToken: Bool? - var outstandingEnrollment: OutstandingEnrollment? + var outstandingEnrollment: OutstandingOffer? var createdAt: String? var updatedAt: String? @@ -108,6 +129,7 @@ struct ServerConfig: Decodable, Identifiable, Hashable, Sendable { case agentLocalCapabilities = "agent_local_capabilities" case effectiveCapabilities = "effective_capabilities" case protocolVersion = "protocol_version" + case agentAuthority = "agent_authority" case hasToken = "has_token" case outstandingEnrollment = "outstanding_enrollment" case createdAt = "created_at" @@ -122,8 +144,14 @@ struct ServerConfig: Decodable, Identifiable, Hashable, Sendable { ) } - /// `false` => pending enrollment (agent never connected). - var isEnrolled: Bool { hasToken ?? true } + var isEnrolled: Bool { + if let agentAuthority { return agentAuthority.status == .claimed } + return hasToken ?? true + } + + var outstandingOffer: OutstandingOffer? { + agentAuthority?.outstandingOffer ?? outstandingEnrollment + } var expiredDate: Date? { guard let expiredAt else { return nil } diff --git a/apps/ios/ServerBee/Models/ServerStatus.swift b/apps/ios/ServerBee/Models/ServerStatus.swift index 2c64eb4a..2ccb5860 100644 --- a/apps/ios/ServerBee/Models/ServerStatus.swift +++ b/apps/ios/ServerBee/Models/ServerStatus.swift @@ -57,7 +57,8 @@ struct ServerStatus: Decodable, Identifiable, Hashable, Sendable { var capabilities: Int? var agentLocalCapabilities: Int? var effectiveCapabilities: Int? - /// `false` => pending enrollment (agent never connected). + var agentAuthority: AgentAuthorityState? + /// Decoder compatibility for older Server payloads. var hasToken: Bool? /// ISO-8601 string. The WS frame sends `last_active` as a Unix **epoch /// second integer**; the decoder normalises both forms to a string here so @@ -109,6 +110,7 @@ struct ServerStatus: Decodable, Identifiable, Hashable, Sendable { case capabilities case agentLocalCapabilities = "agent_local_capabilities" case effectiveCapabilities = "effective_capabilities" + case agentAuthority = "agent_authority" case hasToken = "has_token" case lastActiveAt = "last_active_at" case lastActive = "last_active" @@ -166,6 +168,7 @@ extension ServerStatus { capabilities = try container.decodeIfPresent(Int.self, forKey: .capabilities) agentLocalCapabilities = try container.decodeIfPresent(Int.self, forKey: .agentLocalCapabilities) effectiveCapabilities = try container.decodeIfPresent(Int.self, forKey: .effectiveCapabilities) + agentAuthority = try container.decodeIfPresent(AgentAuthorityState.self, forKey: .agentAuthority) hasToken = try container.decodeIfPresent(Bool.self, forKey: .hasToken) // `last_active` is a Unix epoch integer over the WS, an ISO string over @@ -214,6 +217,11 @@ extension ServerStatus { ) } + var hasAgentAuthority: Bool { + if let agentAuthority { return agentAuthority.status == .claimed } + return hasToken ?? true + } + /// Human-readable location derived from region + country. var location: String? { switch (region, country) { @@ -269,6 +277,7 @@ extension ServerStatus { if let v = other.capabilities { capabilities = v } if let v = other.agentLocalCapabilities { agentLocalCapabilities = v } if let v = other.effectiveCapabilities { effectiveCapabilities = v } + if let v = other.agentAuthority { agentAuthority = v } if let v = other.hasToken { hasToken = v } if let v = other.lastActiveAt { lastActiveAt = v } } diff --git a/apps/ios/ServerBee/Models/WebSocketModels.swift b/apps/ios/ServerBee/Models/WebSocketModels.swift index 2491bb80..d9ac735c 100644 --- a/apps/ios/ServerBee/Models/WebSocketModels.swift +++ b/apps/ios/ServerBee/Models/WebSocketModels.swift @@ -5,6 +5,7 @@ enum BrowserMessage: Sendable { case update(servers: [ServerStatus]) case serverOnline(serverId: String) case serverOffline(serverId: String) + case agentAuthorityChanged(serverId: String, agentAuthority: AgentAuthorityState) case capabilitiesChanged(serverId: String, capabilities: Int, agentLocal: Int?, effective: Int?) case agentInfoUpdated(serverId: String, protocolVersion: Int) case alertEvent(alertKey: String, status: AlertStatus) @@ -34,6 +35,7 @@ extension BrowserMessage: Decodable { case update case serverOnline = "server_online" case serverOffline = "server_offline" + case agentAuthorityChanged = "agent_authority_changed" case capabilitiesChanged = "capabilities_changed" case agentInfoUpdated = "agent_info_updated" case alertEvent = "alert_event" @@ -50,6 +52,7 @@ extension BrowserMessage: Decodable { case capabilities case agentLocalCapabilities = "agent_local_capabilities" case effectiveCapabilities = "effective_capabilities" + case agentAuthority = "agent_authority" case protocolVersion = "protocol_version" case alertKey = "alert_key" case status @@ -84,6 +87,11 @@ extension BrowserMessage: Decodable { case .serverOffline: let serverId = try container.decode(String.self, forKey: .serverId) self = .serverOffline(serverId: serverId) + case .agentAuthorityChanged: + self = .agentAuthorityChanged( + serverId: try container.decode(String.self, forKey: .serverId), + agentAuthority: try container.decode(AgentAuthorityState.self, forKey: .agentAuthority) + ) case .capabilitiesChanged: let serverId = try container.decode(String.self, forKey: .serverId) let capabilities = try container.decode(Int.self, forKey: .capabilities) diff --git a/apps/ios/ServerBee/Services/WebSocketRouter.swift b/apps/ios/ServerBee/Services/WebSocketRouter.swift index ca06a67a..e825ac4b 100644 --- a/apps/ios/ServerBee/Services/WebSocketRouter.swift +++ b/apps/ios/ServerBee/Services/WebSocketRouter.swift @@ -15,7 +15,7 @@ struct WebSocketRouter { // Full sync carries both the server metrics and the upgrade snapshot. servers(message) upgrades(message) - case .update, .serverOnline, .serverOffline, + case .update, .serverOnline, .serverOffline, .agentAuthorityChanged, .capabilitiesChanged, .agentInfoUpdated: servers(message) case .alertEvent: diff --git a/apps/ios/ServerBee/ViewModels/AgentLifecycleViewModel.swift b/apps/ios/ServerBee/ViewModels/AgentLifecycleViewModel.swift index a073b159..c3316dbd 100644 --- a/apps/ios/ServerBee/ViewModels/AgentLifecycleViewModel.swift +++ b/apps/ios/ServerBee/ViewModels/AgentLifecycleViewModel.swift @@ -1,8 +1,6 @@ import SwiftUI -/// Drives agent lifecycle actions (admin-only, server-enforced): create a -/// pending server, recover/regenerate its enrollment code, trigger an agent -/// upgrade, and delete a server. Each mint returns a one-time plaintext code. +/// Drives request-idempotent Server onboarding and Agent Authority actions. @MainActor @Observable final class AgentLifecycleViewModel { @@ -11,6 +9,9 @@ final class AgentLifecycleViewModel { /// The most recently minted code + its install command, for display. var issued: IssuedEnrollment? + var onboardingReplay: OnboardingReplay? + + private(set) var onboardingRequestId = UUID().uuidString /// Newest released agent version (for the upgrade affordance). `nil` until loaded. var latestVersion: String? @@ -22,6 +23,12 @@ final class AgentLifecycleViewModel { let installCommand: String } + struct OnboardingReplay: Identifiable { + var id: String { serverId } + let serverId: String + let outstandingOffer: OutstandingOffer? + } + // MARK: - Create /// Create a pending server and mint its first enrollment code. @@ -34,9 +41,21 @@ final class AgentLifecycleViewModel { do { let resp: CreateServerResponse = try await apiClient.post( "/api/servers", - body: CreateServerRequest(name: name, groupId: nil) + body: CreateServerRequest( + onboardingRequestId: onboardingRequestId, + name: name, + groupId: nil + ) ) - issued = makeIssued(resp.enrollment, serverUrl: serverUrl) + if let enrollment = resp.enrollment { + issued = makeIssued(enrollment, serverUrl: serverUrl) + onboardingReplay = nil + } else { + onboardingReplay = OnboardingReplay( + serverId: resp.serverId, + outstandingOffer: resp.outstandingOffer + ) + } return resp.serverId } catch { errorMessage = message(for: error) @@ -44,20 +63,50 @@ final class AgentLifecycleViewModel { } } - // MARK: - Regenerate (pending server: mint a fresh code) + func resetOnboarding() { + onboardingRequestId = UUID().uuidString + onboardingReplay = nil + issued = nil + errorMessage = nil + } + + // MARK: - Agent Authority - func regenerateCode(serverId: String, serverUrl: String?, apiClient: APIClient) async { - await mint(path: "/api/servers/\(serverId)/regenerate-code", - body: RegenerateCodeRequest(expectedEnrollmentId: nil), - serverUrl: serverUrl, apiClient: apiClient) + func issueOffer(serverId: String, serverUrl: String?, apiClient: APIClient) async { + await mint( + path: "/api/servers/\(serverId)/agent-authority/offers", + body: IssueOfferRequest(), + serverUrl: serverUrl, + apiClient: apiClient + ) } - // MARK: - Recover (enrolled server: mint a new code, optionally revoke token) + func replaceOffer( + serverId: String, + offerId: String, + serverUrl: String?, + apiClient: APIClient + ) async { + await mint( + path: "/api/servers/\(serverId)/agent-authority/offers/\(offerId)/replace", + body: IssueOfferRequest(), + serverUrl: serverUrl, + apiClient: apiClient + ) + } - func recover(serverId: String, revokeImmediately: Bool, serverUrl: String?, apiClient: APIClient) async { - await mint(path: "/api/servers/\(serverId)/recover", - body: RecoverRequest(revokeImmediately: revokeImmediately), - serverUrl: serverUrl, apiClient: apiClient) + func beginReenrollment( + serverId: String, + mode: ReenrollmentMode, + serverUrl: String?, + apiClient: APIClient + ) async { + await mint( + path: "/api/servers/\(serverId)/agent-authority/re-enrollment", + body: ReenrollmentRequest(mode: mode), + serverUrl: serverUrl, + apiClient: apiClient + ) } private func mint(path: String, body: any Encodable & Sendable, serverUrl: String?, apiClient: APIClient) async { @@ -65,7 +114,7 @@ final class AgentLifecycleViewModel { errorMessage = nil defer { isWorking = false } do { - let resp: EnrollmentOnlyResponse = try await apiClient.post(path, body: body) + let resp: EnrollmentOfferResponse = try await apiClient.post(path, body: body) issued = makeIssued(resp.enrollment, serverUrl: serverUrl) } catch { errorMessage = message(for: error) @@ -99,17 +148,29 @@ final class AgentLifecycleViewModel { } } - // MARK: - Revoke outstanding enrollment + func revokeOffer(serverId: String, offerId: String, apiClient: APIClient) async -> Bool { + isWorking = true + errorMessage = nil + defer { isWorking = false } + do { + let _: RevokeOfferResponse = try await apiClient.delete( + "/api/servers/\(serverId)/agent-authority/offers/\(offerId)" + ) + return true + } catch { + errorMessage = message(for: error) + return false + } + } - /// Revoke an outstanding (unconsumed) enrollment so a fresh recover can mint - /// a new code. The `enrollmentId` is `ServerConfig.outstandingEnrollment.id` - /// — NOT the server id. Returns true on success. - func revokeEnrollment(enrollmentId: String, apiClient: APIClient) async -> Bool { + func revokeAuthority(serverId: String, apiClient: APIClient) async -> Bool { isWorking = true errorMessage = nil defer { isWorking = false } do { - let _: String = try await apiClient.delete("/api/agent/enrollments/\(enrollmentId)") + let _: RevokeAuthorityResponse = try await apiClient.delete( + "/api/servers/\(serverId)/agent-authority" + ) return true } catch { errorMessage = message(for: error) @@ -156,7 +217,7 @@ final class AgentLifecycleViewModel { if let msg = AccountSecurityViewModel.errorMessage(from: data) { return msg } switch code { case 403: return String(localized: "Admin permission required") - case 404: return String(localized: "Agent not connected") + case 404: return String(localized: "Server or enrollment offer not found") case 409: return String(localized: "An upgrade or enrollment is already in progress") default: break } diff --git a/apps/ios/ServerBee/ViewModels/ServersViewModel.swift b/apps/ios/ServerBee/ViewModels/ServersViewModel.swift index 5d1fabae..79156621 100644 --- a/apps/ios/ServerBee/ViewModels/ServersViewModel.swift +++ b/apps/ios/ServerBee/ViewModels/ServersViewModel.swift @@ -173,6 +173,12 @@ final class ServersViewModel { servers[index].online = false } + case let .agentAuthorityChanged(serverId, agentAuthority): + if let index = servers.firstIndex(where: { $0.id == serverId }) { + servers[index].agentAuthority = agentAuthority + servers[index].hasToken = agentAuthority.status == .claimed + } + case let .capabilitiesChanged(serverId, capabilities, agentLocal, effective): if let index = servers.firstIndex(where: { $0.id == serverId }) { servers[index].capabilities = capabilities diff --git a/apps/ios/ServerBee/Views/Servers/Detail/ServerOverviewSection.swift b/apps/ios/ServerBee/Views/Servers/Detail/ServerOverviewSection.swift index 9efcd553..456daf22 100644 --- a/apps/ios/ServerBee/Views/Servers/Detail/ServerOverviewSection.swift +++ b/apps/ios/ServerBee/Views/Servers/Detail/ServerOverviewSection.swift @@ -79,7 +79,11 @@ struct ServerOverviewSection: View { // MARK: - Derived private var isOnline: Bool { live?.isOnline ?? false } - private var isPending: Bool { (config?.hasToken == false) || (live?.hasToken == false) } + private var isPending: Bool { + if let config { return !config.isEnrolled } + if let live { return !live.hasAgentAuthority } + return false + } private var hasAnyMetric: Bool { guard let s = live else { return false } diff --git a/apps/ios/ServerBee/Views/Servers/Enrollment/AddServerSheet.swift b/apps/ios/ServerBee/Views/Servers/Enrollment/AddServerSheet.swift index c94b0b96..aea91897 100644 --- a/apps/ios/ServerBee/Views/Servers/Enrollment/AddServerSheet.swift +++ b/apps/ios/ServerBee/Views/Servers/Enrollment/AddServerSheet.swift @@ -19,6 +19,27 @@ struct AddServerSheet: View { } header: { Text(String(localized: "Server created")) } + } else if let replay = viewModel.onboardingReplay { + Section { + Text(String(localized: "This onboarding request already created the server. The original plaintext code cannot be recovered.")) + if let offer = replay.outstandingOffer { + Button(String(localized: "Replace outstanding offer")) { + Task { + await viewModel.replaceOffer( + serverId: replay.serverId, + offerId: offer.id, + serverUrl: authManager.serverUrl, + apiClient: apiClient + ) + } + } + } else { + Text(String(localized: "No outstanding enrollment offer remains. Manage Agent Authority from the existing server.")) + .foregroundStyle(.secondary) + } + } header: { + Text(String(localized: "Server already created")) + } } else { Section { TextField(String(localized: "Server name"), text: $name) @@ -40,9 +61,12 @@ struct AddServerSheet: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { - Button(viewModel.issued == nil ? String(localized: "Cancel") : String(localized: "Done")) { dismiss() } + Button(viewModel.issued == nil ? String(localized: "Cancel") : String(localized: "Done")) { + viewModel.resetOnboarding() + dismiss() + } } - if viewModel.issued == nil { + if viewModel.issued == nil, viewModel.onboardingReplay == nil { ToolbarItem(placement: .confirmationAction) { if viewModel.isWorking { ProgressView() diff --git a/apps/ios/ServerBee/Views/Servers/Enrollment/EnrollmentResultView.swift b/apps/ios/ServerBee/Views/Servers/Enrollment/EnrollmentResultView.swift index 6c14539e..073f7fa9 100644 --- a/apps/ios/ServerBee/Views/Servers/Enrollment/EnrollmentResultView.swift +++ b/apps/ios/ServerBee/Views/Servers/Enrollment/EnrollmentResultView.swift @@ -1,7 +1,7 @@ import SwiftUI /// Shows a freshly-minted enrollment code and the agent install command, with -/// copy buttons and a one-time warning. Reused by create / recover / regenerate. +/// copy buttons and a one-time warning. Reused by onboarding, re-enrollment, and offer replacement. struct EnrollmentResultView: View { let issued: AgentLifecycleViewModel.IssuedEnrollment diff --git a/apps/ios/ServerBee/Views/Servers/Enrollment/ServerLifecycleCard.swift b/apps/ios/ServerBee/Views/Servers/Enrollment/ServerLifecycleCard.swift index 95f17468..795f8d51 100644 --- a/apps/ios/ServerBee/Views/Servers/Enrollment/ServerLifecycleCard.swift +++ b/apps/ios/ServerBee/Views/Servers/Enrollment/ServerLifecycleCard.swift @@ -2,9 +2,8 @@ import SwiftUI /// Admin-only agent lifecycle actions, shown on the server detail Overview tab. /// -/// - Pending servers: show the outstanding code summary + "Get install command" -/// (mints a fresh one-time code). -/// - Enrolled servers: Recover (re-mint, optionally revoking the live token), +/// - Unclaimed servers: issue or exactly replace an Enrollment offer. +/// - Claimed servers: begin Graceful or Emergency re-enrollment, /// Upgrade (gated on effective `upgrade` capability + online), and Delete. /// /// Every mint surfaces the plaintext code + install command once, via a sheet. @@ -14,7 +13,7 @@ struct ServerLifecycleCard: View { let capabilities: CapabilitySet let isOnline: Bool let isPending: Bool - /// Re-fetch the server config after an enrollment change (revoke/recover). + /// Re-fetch the server config after an Agent Authority change. var onConfigChanged: () -> Void = {} /// Called after a successful delete so the caller can pop the detail screen. let onDeleted: () -> Void @@ -24,10 +23,12 @@ struct ServerLifecycleCard: View { @Environment(UpgradeJobsStore.self) private var upgradeJobs @State private var viewModel = AgentLifecycleViewModel() - @State private var showRecover = false + @State private var showReenrollment = false + @State private var showRevokeAuthority = false @State private var showUpgrade = false @State private var showDelete = false @State private var upgradeQueued = false + @State private var offerClock = Date() var body: some View { SectionCard(String(localized: "Agent"), systemImage: "gearshape.2") { @@ -59,16 +60,50 @@ struct ServerLifecycleCard: View { await viewModel.loadLatestVersion(apiClient: apiClient) } } - .confirmationDialog(String(localized: "Recover agent"), isPresented: $showRecover, titleVisibility: .visible) { - Button(String(localized: "Generate new code")) { - Task { await viewModel.recover(serverId: serverId, revokeImmediately: false, serverUrl: authManager.serverUrl, apiClient: apiClient) } + .task(id: config?.outstandingOffer?.expiresAt) { + offerClock = Date() + guard + let expiresAt = config?.outstandingOffer?.expiresAt, + let expiry = ISO8601DateFormatter.shared.date(from: expiresAt) + else { return } + let remaining = expiry.timeIntervalSinceNow + guard remaining > 0 else { return } + try? await Task.sleep(for: .seconds(remaining)) + guard !Task.isCancelled else { return } + offerClock = Date() + } + .confirmationDialog(String(localized: "Agent re-enrollment"), isPresented: $showReenrollment, titleVisibility: .visible) { + Button(String(localized: "Graceful re-enrollment")) { + Task { + await viewModel.beginReenrollment( + serverId: serverId, + mode: .graceful, + serverUrl: authManager.serverUrl, + apiClient: apiClient + ) + } + } + Button(String(localized: "Emergency re-enrollment"), role: .destructive) { + Task { + await viewModel.beginReenrollment( + serverId: serverId, + mode: .emergency, + serverUrl: authManager.serverUrl, + apiClient: apiClient + ) + } } - Button(String(localized: "Revoke token & generate code"), role: .destructive) { - Task { await viewModel.recover(serverId: serverId, revokeImmediately: true, serverUrl: authManager.serverUrl, apiClient: apiClient) } + Button(String(localized: "Cancel"), role: .cancel) {} + } message: { + Text(String(localized: "Graceful mode preserves current Agent Authority until claim. Emergency mode revokes it and fences the connection immediately.")) + } + .confirmationDialog(String(localized: "Revoke Agent Authority?"), isPresented: $showRevokeAuthority, titleVisibility: .visible) { + Button(String(localized: "Revoke Agent Authority"), role: .destructive) { + Task { await runRevokeAuthority() } } Button(String(localized: "Cancel"), role: .cancel) {} } message: { - Text(String(localized: "Generates a new enrollment code for this server. Revoking immediately disconnects the current agent until it re-enrolls with the new code.")) + Text(String(localized: "The Agent is fenced immediately and the server becomes unclaimed. No Enrollment offer is created.")) } .confirmationDialog(String(localized: "Upgrade agent"), isPresented: $showUpgrade, titleVisibility: .visible) { if let target = viewModel.latestVersion { @@ -136,7 +171,11 @@ private extension ServerLifecycleCard { .font(.caption) .foregroundStyle(.secondary) - if let outstanding = config?.outstandingEnrollment { + if let expired = expiredOutstandingOffer { + expiredOfferNotice(expired) + } + + if let outstanding = activeOutstandingOffer { VStack(alignment: .leading, spacing: 4) { if let prefix = outstanding.codePrefix { DetailRow(label: String(localized: "Current code"), value: "\(prefix)…", monospaced: true) @@ -147,12 +186,28 @@ private extension ServerLifecycleCard { } } - actionButton( - title: String(localized: "Get install command"), - systemImage: "qrcode", - tint: .brandAccent - ) { - Task { await viewModel.regenerateCode(serverId: serverId, serverUrl: authManager.serverUrl, apiClient: apiClient) } + if let outstanding = activeOutstandingOffer { + actionButton( + title: String(localized: "Replace outstanding offer"), + systemImage: "arrow.triangle.2.circlepath", + tint: .brandAccent + ) { + Task { await runReplace(outstanding) } + } + } else { + actionButton( + title: String(localized: "Issue enrollment offer"), + systemImage: "qrcode", + tint: .brandAccent + ) { + Task { + await viewModel.issueOffer( + serverId: serverId, + serverUrl: authManager.serverUrl, + apiClient: apiClient + ) + } + } } } @@ -169,14 +224,17 @@ private extension ServerLifecycleCard { .foregroundStyle(Color.brandAccent) } - if let outstanding = config?.outstandingEnrollment { + if let outstanding = activeOutstandingOffer { outstandingNotice(outstanding) } else { + if let expired = expiredOutstandingOffer { + expiredOfferNotice(expired) + } actionButton( - title: String(localized: "Recover agent"), + title: String(localized: "Agent re-enrollment"), systemImage: "arrow.triangle.2.circlepath", tint: .brandAccent - ) { showRecover = true } + ) { showReenrollment = true } } if capabilities.isEnabled(.upgrade) { @@ -195,6 +253,12 @@ private extension ServerLifecycleCard { Divider() + actionButton( + title: String(localized: "Revoke Agent Authority"), + systemImage: "person.crop.circle.badge.xmark", + tint: .serverOffline + ) { showRevokeAuthority = true } + actionButton( title: String(localized: "Delete server"), systemImage: "trash", @@ -220,12 +284,41 @@ private extension ServerLifecycleCard { upgradeJobs.job(forServer: serverId)?.status == .running } - // MARK: - Outstanding enrollment (recover gate) + // MARK: - Outstanding Enrollment offer + + private var activeOutstandingOffer: OutstandingOffer? { + guard let offer = config?.outstandingOffer, !offer.isExpired(at: offerClock) else { return nil } + return offer + } + + private var expiredOutstandingOffer: OutstandingOffer? { + guard let offer = config?.outstandingOffer, offer.isExpired(at: offerClock) else { return nil } + return offer + } + + @ViewBuilder + func expiredOfferNotice(_ offer: OutstandingOffer) -> some View { + VStack(alignment: .leading, spacing: 8) { + Label(String(localized: "Enrollment offer expired"), systemImage: "clock.badge.xmark") + .font(.caption.bold()) + .foregroundStyle(.secondary) + if let prefix = offer.codePrefix { + DetailRow(label: String(localized: "Code"), value: "\(prefix)…", monospaced: true) + } + Text(String(localized: "This offer is terminal and can no longer be replaced or revoked.")) + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } @ViewBuilder - func outstandingNotice(_ outstanding: OutstandingEnrollment) -> some View { + func outstandingNotice(_ outstanding: OutstandingOffer) -> some View { VStack(alignment: .leading, spacing: 8) { - Label(String(localized: "Pending enrollment code"), systemImage: "clock.badge.exclamationmark") + Label(String(localized: "Outstanding enrollment offer"), systemImage: "clock.badge.exclamationmark") .font(.caption.bold()) .foregroundStyle(Color.warningAmber) if let prefix = outstanding.codePrefix { @@ -234,14 +327,19 @@ private extension ServerLifecycleCard { if let expiry = outstanding.expiresAt { DetailRow(label: String(localized: "Expires"), value: Formatters.formatRelativeTime(expiry)) } - Text(String(localized: "Revoke the pending code before generating a new one.")) + Text(String(localized: "Replace this exact offer if its plaintext code was lost, or revoke it without creating a successor.")) .font(.caption2) .foregroundStyle(.secondary) actionButton( - title: String(localized: "Revoke pending code"), + title: String(localized: "Revoke offer"), systemImage: "xmark.circle", tint: .serverOffline ) { Task { await runRevoke(outstanding) } } + actionButton( + title: String(localized: "Replace offer"), + systemImage: "arrow.triangle.2.circlepath", + tint: .brandAccent + ) { Task { await runReplace(outstanding) } } } .padding(10) .frame(maxWidth: .infinity, alignment: .leading) @@ -249,12 +347,21 @@ private extension ServerLifecycleCard { .clipShape(RoundedRectangle(cornerRadius: 10)) } - func runRevoke(_ outstanding: OutstandingEnrollment) async { - if await viewModel.revokeEnrollment(enrollmentId: outstanding.id, apiClient: apiClient) { + func runRevoke(_ outstanding: OutstandingOffer) async { + if await viewModel.revokeOffer(serverId: serverId, offerId: outstanding.id, apiClient: apiClient) { onConfigChanged() } } + func runReplace(_ outstanding: OutstandingOffer) async { + await viewModel.replaceOffer( + serverId: serverId, + offerId: outstanding.id, + serverUrl: authManager.serverUrl, + apiClient: apiClient + ) + } + // MARK: - Action row @ViewBuilder @@ -326,6 +433,12 @@ private extension ServerLifecycleCard { } } + private func runRevokeAuthority() async { + if await viewModel.revokeAuthority(serverId: serverId, apiClient: apiClient) { + onConfigChanged() + } + } + #if DEBUG /// Parses the visual-verification hook `upgrade-progress[:<stage>]` into a /// stage (defaults to `.installing`). Returns nil when the hook is absent. diff --git a/apps/ios/ServerBeeTests/BrowserMessageDecodingTests.swift b/apps/ios/ServerBeeTests/BrowserMessageDecodingTests.swift index d5973438..f100c8d7 100644 --- a/apps/ios/ServerBeeTests/BrowserMessageDecodingTests.swift +++ b/apps/ios/ServerBeeTests/BrowserMessageDecodingTests.swift @@ -228,6 +228,27 @@ final class BrowserMessageDecodingTests: XCTestCase { } } + func test_decode_agentAuthorityChanged() throws { + let json = """ + { + "type": "agent_authority_changed", + "server_id": "abc-123", + "agent_authority": { + "status": "unclaimed", + "outstanding_offer": null + } + } + """ + let msg = try decode(json) + if case let .agentAuthorityChanged(serverId, authority) = msg { + XCTAssertEqual(serverId, "abc-123") + XCTAssertEqual(authority.status, .unclaimed) + XCTAssertNil(authority.outstandingOffer) + } else { + XCTFail("Expected .agentAuthorityChanged, got \(msg)") + } + } + func test_decode_capabilitiesChanged() throws { let json = """ { diff --git a/apps/ios/ServerBeeTests/EnrollmentModelsDecodingTests.swift b/apps/ios/ServerBeeTests/EnrollmentModelsDecodingTests.swift index 1817ab8e..2d3ffb8e 100644 --- a/apps/ios/ServerBeeTests/EnrollmentModelsDecodingTests.swift +++ b/apps/ios/ServerBeeTests/EnrollmentModelsDecodingTests.swift @@ -2,7 +2,7 @@ import XCTest @testable import ServerBee /// Decoding / encoding coverage for M9 agent-lifecycle models, matching the live -/// `/api/servers` (create / recover / regenerate) and `/api/agent/latest-version` +/// `/api/servers`, Agent Authority, and `/api/agent/latest-version` /// payloads verified against the demo backend. final class EnrollmentModelsDecodingTests: XCTestCase { private func decode<T: Decodable>(_ type: T.Type, _ json: String) throws -> T { @@ -18,6 +18,8 @@ final class EnrollmentModelsDecodingTests: XCTestCase { func test_createServerResponse_decodes() throws { let json = """ { "server_id": "8455ce93-41ba-4c9c-8969-19037f7ba711", + "replayed": false, + "outstanding_offer": null, "enrollment": { "id": "76623365-7cae-4d1c-932d-198a91d7da8c", "code": "SBENROLL-9F2A-7C41-DE08", @@ -27,24 +29,26 @@ final class EnrollmentModelsDecodingTests: XCTestCase { """ let resp = try decode(CreateServerResponse.self, json) XCTAssertEqual(resp.serverId, "8455ce93-41ba-4c9c-8969-19037f7ba711") - XCTAssertEqual(resp.enrollment.code, "SBENROLL-9F2A-7C41-DE08") - XCTAssertEqual(resp.enrollment.codePrefix, "gs1QPJIl") - XCTAssertEqual(resp.enrollment.id, "76623365-7cae-4d1c-932d-198a91d7da8c") + XCTAssertFalse(resp.replayed) + XCTAssertEqual(resp.enrollment?.code, "SBENROLL-9F2A-7C41-DE08") + XCTAssertEqual(resp.enrollment?.codePrefix, "gs1QPJIl") + XCTAssertEqual(resp.enrollment?.id, "76623365-7cae-4d1c-932d-198a91d7da8c") } func test_createServerRequest_encodesGroupIdSnakeCase() throws { - let json = try encode(CreateServerRequest(name: "edge-01", groupId: "grp-1")) + let json = try encode(CreateServerRequest(onboardingRequestId: "request-1", name: "edge-01", groupId: "grp-1")) + XCTAssertTrue(json.contains("\"onboarding_request_id\":\"request-1\"")) XCTAssertTrue(json.contains("\"name\":\"edge-01\"")) XCTAssertTrue(json.contains("\"group_id\":\"grp-1\"")) } func test_createServerRequest_omitsNilGroupId() throws { - let json = try encode(CreateServerRequest(name: "edge-02", groupId: nil)) + let json = try encode(CreateServerRequest(onboardingRequestId: "request-2", name: "edge-02", groupId: nil)) XCTAssertTrue(json.contains("\"name\":\"edge-02\"")) XCTAssertFalse(json.contains("group_id")) } - // MARK: - Recover / Regenerate + // MARK: - Agent Authority func test_enrollmentOnlyResponse_decodes() throws { let json = """ @@ -55,24 +59,68 @@ final class EnrollmentModelsDecodingTests: XCTestCase { "expires_at": "2026-06-15T18:00:00Z" } } """ - let resp = try decode(EnrollmentOnlyResponse.self, json) + let resp = try decode(EnrollmentOfferResponse.self, json) XCTAssertEqual(resp.enrollment.code, "SBENROLL-AAAA-BBBB-CCCC") XCTAssertEqual(resp.enrollment.codePrefix, "KhAzBgBs") } - func test_recoverRequest_encodesRevokeFlag() throws { - XCTAssertTrue(try encode(RecoverRequest(revokeImmediately: true)).contains("\"revoke_immediately\":true")) - XCTAssertTrue(try encode(RecoverRequest(revokeImmediately: false)).contains("\"revoke_immediately\":false")) + func test_reenrollmentRequest_encodesGracefulMode() throws { + XCTAssertTrue(try encode(ReenrollmentRequest(mode: .graceful)).contains("\"mode\":\"graceful\"")) } - func test_regenerateRequest_omitsNilExpectedId() throws { - let json = try encode(RegenerateCodeRequest(expectedEnrollmentId: nil)) - XCTAssertFalse(json.contains("expected_enrollment_id")) + func test_reenrollmentRequest_encodesEmergencyMode() throws { + XCTAssertTrue(try encode(ReenrollmentRequest(mode: .emergency)).contains("\"mode\":\"emergency\"")) } - func test_regenerateRequest_encodesExpectedIdSnakeCase() throws { - let json = try encode(RegenerateCodeRequest(expectedEnrollmentId: "enr-9")) - XCTAssertTrue(json.contains("\"expected_enrollment_id\":\"enr-9\"")) + func test_createServerReplayDecodesOutstandingOfferWithoutPlaintext() throws { + let json = """ + { + "server_id": "srv-1", + "replayed": true, + "enrollment": null, + "outstanding_offer": { + "id": "offer-1", + "code_prefix": "abcdef", + "expires_at": "2026-07-13T00:10:00Z", + "created_at": "2026-07-13T00:00:00Z" + } + } + """ + let resp = try decode(CreateServerResponse.self, json) + XCTAssertTrue(resp.replayed) + XCTAssertNil(resp.enrollment) + XCTAssertEqual(resp.outstandingOffer?.id, "offer-1") + } + + func test_serverConfigUsesCanonicalAgentAuthorityProjection() throws { + let json = """ + { + "id": "srv-1", + "name": "edge-1", + "has_token": true, + "agent_authority": { + "status": "unclaimed", + "outstanding_offer": { + "id": "offer-1", + "code_prefix": "abcdef", + "expires_at": "2026-07-13T00:10:00Z", + "created_at": "2026-07-13T00:00:00Z" + } + } + } + """ + let config = try decode(ServerConfig.self, json) + XCTAssertFalse(config.isEnrolled) + XCTAssertEqual(config.outstandingOffer?.id, "offer-1") + } + + func test_outstandingOfferExpiryIsTerminalAtDeadline() throws { + let offer = try decode( + OutstandingOffer.self, + #"{"id":"offer-1","expires_at":"2026-07-13T00:10:00Z"}"# + ) + XCTAssertFalse(offer.isExpired(at: Date(timeIntervalSince1970: 1_783_901_399))) + XCTAssertTrue(offer.isExpired(at: Date(timeIntervalSince1970: 1_783_901_400))) } // MARK: - Upgrade @@ -115,4 +163,17 @@ final class EnrollmentModelsDecodingTests: XCTestCase { let cmd = AgentLifecycleViewModel.installCommand(code: "C1", serverUrl: " https://x.test ") XCTAssertTrue(cmd.contains("--server-url 'https://x.test'")) } + + @MainActor + func test_onboardingRequestIdStaysStableUntilReset() { + let viewModel = AgentLifecycleViewModel() + let first = viewModel.onboardingRequestId + + XCTAssertEqual(viewModel.onboardingRequestId, first) + viewModel.resetOnboarding() + + XCTAssertNotEqual(viewModel.onboardingRequestId, first) + XCTAssertNil(viewModel.issued) + XCTAssertNil(viewModel.onboardingReplay) + } } diff --git a/apps/web/openapi.json b/apps/web/openapi.json index 775fcfcc..da342233 100644 --- a/apps/web/openapi.json +++ b/apps/web/openapi.json @@ -95,21 +95,41 @@ ] } }, - "/api/agent/enrollments": { + "/api/agent-authority/events": { "get": { "tags": [ - "agent" + "servers" + ], + "operationId": "get_authority_history", + "parameters": [ + { + "name": "server_id", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } ], - "operationId": "list_enrollments", "responses": { "200": { - "description": "List enrollment codes", + "description": "Agent authority event history", "content": { "application/json": { "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/EnrollmentSummary" + "$ref": "#/components/schemas/AuthorityEventResponse" } } } @@ -122,38 +142,9 @@ }, { "api_key": [] - } - ] - } - }, - "/api/agent/enrollments/{id}": { - "delete": { - "tags": [ - "agent" - ], - "operationId": "delete_enrollment", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Enrollment id", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Deleted" - } - }, - "security": [ - { - "session_cookie": [] }, { - "api_key": [] + "bearer_token": [] } ] } @@ -202,11 +193,12 @@ "$ref": "#/components/schemas/RegisterRequest" } } - } + }, + "required": true }, "responses": { "200": { - "description": "Agent registered against the bound server", + "description": "Agent claimed the bound Server authority", "content": { "application/json": { "schema": { @@ -216,61 +208,13 @@ } }, "400": { - "description": "Invalid fingerprint format" + "description": "Missing or invalid Agent-proposed run token" }, "401": { - "description": "Invalid, expired, revoked, or already-used enrollment code" - } - }, - "security": [ - { - "bearer_token": [] - } - ] - } - }, - "/api/agent/{id}/rotate-token": { - "post": { - "tags": [ - "agent" - ], - "operationId": "rotate_token", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Server id", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Token rotated; old token revoked", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RotateTokenResponse" - } - } - } - }, - "400": { - "description": "Server is pending (no token to rotate); use recover instead" - }, - "404": { - "description": "Server not found" + "description": "Enrollment claim rejected" } }, "security": [ - { - "session_cookie": [] - }, - { - "api_key": [] - }, { "bearer_token": [] } @@ -1005,6 +949,9 @@ } } }, + "403": { + "description": "Admin role required" + }, "422": { "description": "Validation error" } @@ -5255,12 +5202,12 @@ ] } }, - "/api/servers/{id}/cost-insights": { + "/api/servers/{id}/agent-authority": { "get": { "tags": [ - "cost" + "servers" ], - "operationId": "get_server_cost_insights", + "operationId": "get_agent_authority", "parameters": [ { "name": "id", @@ -5274,11 +5221,11 @@ ], "responses": { "200": { - "description": "Server cost insights", + "description": "Agent authority state", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiResponse_ServerCostInsights" + "$ref": "#/components/schemas/AgentAuthorityStateSummary" } } } @@ -5298,14 +5245,12 @@ "bearer_token": [] } ] - } - }, - "/api/servers/{id}/gpu-records": { - "get": { + }, + "delete": { "tags": [ "servers" ], - "operationId": "get_gpu_records", + "operationId": "revoke_agent_authority", "parameters": [ { "name": "id", @@ -5315,39 +5260,76 @@ "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "Agent authority revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RevokeAuthorityResponse" + } + } + } }, + "404": { + "description": "Server not found" + } + }, + "security": [ { - "name": "from", - "in": "query", - "required": true, - "schema": { - "type": "string", - "format": "date-time" - } + "session_cookie": [] }, { - "name": "to", - "in": "query", + "api_key": [] + } + ] + } + }, + "/api/servers/{id}/agent-authority/offers": { + "post": { + "tags": [ + "servers" + ], + "operationId": "issue_offer_for_unclaimed", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Server ID", "required": true, "schema": { - "type": "string", - "format": "date-time" + "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IssueOfferRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "GPU metric records", + "description": "Enrollment offer issued", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/GpuRecord" - } + "$ref": "#/components/schemas/EnrollmentOfferResponse" } } } + }, + "404": { + "description": "Server not found" + }, + "409": { + "description": "Authority or offer state conflict" } }, "security": [ @@ -5356,19 +5338,16 @@ }, { "api_key": [] - }, - { - "bearer_token": [] } ] } }, - "/api/servers/{id}/records": { - "get": { + "/api/servers/{id}/agent-authority/offers/{offer_id}": { + "delete": { "tags": [ "servers" ], - "operationId": "get_server_records", + "operationId": "revoke_offer", "parameters": [ { "name": "id", @@ -5380,27 +5359,10 @@ } }, { - "name": "from", - "in": "query", - "required": true, - "schema": { - "type": "string", - "format": "date-time" - } - }, - { - "name": "to", - "in": "query", + "name": "offer_id", + "in": "path", + "description": "Offer ID", "required": true, - "schema": { - "type": "string", - "format": "date-time" - } - }, - { - "name": "interval", - "in": "query", - "required": false, "schema": { "type": "string" } @@ -5408,17 +5370,20 @@ ], "responses": { "200": { - "description": "Server metric records", + "description": "Enrollment offer revoked", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ServerRecord" - } + "$ref": "#/components/schemas/RevokeOfferResponse" } } } + }, + "404": { + "description": "Server or offer not found" + }, + "409": { + "description": "Offer has another terminal outcome" } }, "security": [ @@ -5427,21 +5392,16 @@ }, { "api_key": [] - }, - { - "bearer_token": [] } ] } }, - "/api/servers/{id}/recover": { + "/api/servers/{id}/agent-authority/offers/{offer_id}/replace": { "post": { "tags": [ "servers" ], - "summary": "Mint a fresh bound enrollment for an already-enrolled server so the operator\ncan reinstall the agent. The target server MUST already have a token\n(`token_hash IS NOT NULL`) — recover on a pending server is rejected with\n`400`, use `regenerate-code` for that path.", - "description": "Recover NEVER auto-supersedes an outstanding enrollment: if one is still\nactive, this returns `409` and the operator is expected to either wait for\nit to expire or revoke it first. Only `regenerate-code` auto-supersedes.\n\n`revoke_immediately`:\n- `true` — clear `token_hash`/`token_prefix` inside the same transaction\n and kick the currently connected agent WS after commit. The server\n returns to pending until the new code is consumed.\n- `false` — the existing token stays valid; the new code only becomes\n active once the agent registers with it (`verify_and_consume_tx` then\n rotates the token via `mint_token_for_server`).", - "operationId": "recover_server", + "operationId": "replace_offer", "parameters": [ { "name": "id", @@ -5451,37 +5411,33 @@ "schema": { "type": "string" } + }, + { + "name": "offer_id", + "in": "path", + "description": "Exact current offer ID", + "required": true, + "schema": { + "type": "string" + } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RecoverRequest" - } - } - }, - "required": true - }, "responses": { "200": { - "description": "Recover enrollment minted", + "description": "Enrollment offer replaced", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RecoverResponse" + "$ref": "#/components/schemas/EnrollmentOfferResponse" } } } }, - "400": { - "description": "Server is pending (use regenerate-code instead)" - }, "404": { - "description": "Server not found" + "description": "Server or offer not found" }, "409": { - "description": "Outstanding enrollment exists; revoke it first" + "description": "Offer is stale or terminal" } }, "security": [ @@ -5494,14 +5450,12 @@ ] } }, - "/api/servers/{id}/regenerate-code": { + "/api/servers/{id}/agent-authority/re-enrollment": { "post": { "tags": [ "servers" ], - "summary": "Mint a fresh bound enrollment for a pending server, auto-superseding the\nprevious outstanding enrollment (if any) inside one transaction. The target\nserver MUST be pending (`token_hash IS NULL`); use `recover` for an already-\nenrolled server.", - "description": "Optimistic concurrency: callers pass `expected_enrollment_id` to guard\nagainst stomping on a concurrent operator's regenerated code. Semantics:\n- `Some(id) && matches current outstanding` → proceed (CAS pass)\n- `Some(id) && does NOT match` (including: there is no outstanding row, or\n the row referenced has been revoked/consumed) → 409\n- `None && outstanding exists` → proceed (last-writer-wins)\n- `None && no outstanding` → proceed (fresh mint)", - "operationId": "regenerate_code", + "operationId": "begin_reenrollment", "parameters": [ { "name": "id", @@ -5517,7 +5471,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RegenerateCodeRequest" + "$ref": "#/components/schemas/ReenrollmentRequest" } } }, @@ -5525,23 +5479,20 @@ }, "responses": { "200": { - "description": "Regenerate enrollment minted", + "description": "Re-enrollment offer issued", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RegenerateCodeResponse" + "$ref": "#/components/schemas/EnrollmentOfferResponse" } } } }, - "400": { - "description": "Server is not pending; use recover instead" - }, "404": { "description": "Server not found" }, "409": { - "description": "expected_enrollment_id mismatch" + "description": "Authority or offer state conflict" } }, "security": [ @@ -5554,16 +5505,17 @@ ] } }, - "/api/servers/{id}/traceroute": { + "/api/servers/{id}/cost-insights": { "get": { "tags": [ - "traceroute" + "cost" ], - "operationId": "list_traceroute_records", + "operationId": "get_server_cost_insights", "parameters": [ { "name": "id", "in": "path", + "description": "Server ID", "required": true, "schema": { "type": "string" @@ -5572,17 +5524,17 @@ ], "responses": { "200": { - "description": "", + "description": "Server cost insights", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TracerouteRecordSummary" - } + "$ref": "#/components/schemas/ApiResponse_ServerCostInsights" } } } + }, + "404": { + "description": "Server not found" } }, "security": [ @@ -5596,10 +5548,188 @@ "bearer_token": [] } ] - }, - "post": { + } + }, + "/api/servers/{id}/gpu-records": { + "get": { "tags": [ - "traceroute" + "servers" + ], + "operationId": "get_gpu_records", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Server ID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "from", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "to", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date-time" + } + } + ], + "responses": { + "200": { + "description": "GPU metric records", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GpuRecord" + } + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + }, + { + "api_key": [] + }, + { + "bearer_token": [] + } + ] + } + }, + "/api/servers/{id}/records": { + "get": { + "tags": [ + "servers" + ], + "operationId": "get_server_records", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Server ID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "from", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "to", + "in": "query", + "required": true, + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "interval", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Server metric records", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ServerRecord" + } + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + }, + { + "api_key": [] + }, + { + "bearer_token": [] + } + ] + } + }, + "/api/servers/{id}/traceroute": { + "get": { + "tags": [ + "traceroute" + ], + "operationId": "list_traceroute_records", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TracerouteRecordSummary" + } + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + }, + { + "api_key": [] + }, + { + "bearer_token": [] + } + ] + }, + "post": { + "tags": [ + "traceroute" ], "operationId": "trigger_traceroute", "parameters": [ @@ -6186,6 +6316,9 @@ }, "404": { "description": "Not found" + }, + "409": { + "description": "A check for this monitor is already running" } }, "security": [ @@ -6356,88 +6489,6 @@ ] } }, - "/api/settings/active-theme": { - "get": { - "tags": [ - "themes" - ], - "operationId": "get_active_theme", - "responses": { - "200": { - "description": "Resolved active admin theme", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ActiveThemeResponse" - } - } - } - }, - "401": { - "description": "Unauthenticated" - } - }, - "security": [ - { - "session_cookie": [] - }, - { - "api_key": [] - }, - { - "bearer_token": [] - } - ] - }, - "put": { - "tags": [ - "themes" - ], - "operationId": "put_active_theme", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PutActiveThemeInput" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Resolved active admin theme", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ActiveThemeResponse" - } - } - } - }, - "401": { - "description": "Unauthenticated" - }, - "403": { - "description": "Forbidden (non-admin)" - }, - "422": { - "description": "Validation error or custom themes disabled" - } - }, - "security": [ - { - "session_cookie": [] - }, - { - "api_key": [] - }, - { - "bearer_token": [] - } - ] - } - }, "/api/settings/backup": { "post": { "tags": [ @@ -6509,436 +6560,7 @@ } }, "422": { - "description": "Validation error" - } - }, - "security": [ - { - "session_cookie": [] - }, - { - "api_key": [] - }, - { - "bearer_token": [] - } - ] - } - }, - "/api/settings/brand/favicon": { - "post": { - "tags": [ - "brand" - ], - "operationId": "upload_favicon", - "requestBody": { - "content": { - "multipart/form-data": { - "schema": { - "type": "string" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Favicon uploaded", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UploadResponse" - } - } - } - }, - "400": { - "description": "Invalid file" - }, - "422": { - "description": "Validation error" - } - }, - "security": [ - { - "session_cookie": [] - }, - { - "api_key": [] - }, - { - "bearer_token": [] - } - ] - } - }, - "/api/settings/brand/logo": { - "post": { - "tags": [ - "brand" - ], - "operationId": "upload_logo", - "requestBody": { - "content": { - "multipart/form-data": { - "schema": { - "type": "string" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Logo uploaded", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UploadResponse" - } - } - } - }, - "400": { - "description": "Invalid file" - }, - "422": { - "description": "Validation error" - } - }, - "security": [ - { - "session_cookie": [] - }, - { - "api_key": [] - }, - { - "bearer_token": [] - } - ] - } - }, - "/api/settings/restore": { - "post": { - "tags": [ - "settings" - ], - "summary": "Restore the database from an uploaded backup file.\nThe server should be restarted after restore.\nNote: request body is raw binary (application/octet-stream).", - "operationId": "restore_backup", - "requestBody": { - "description": "SQLite backup file", - "content": { - "application/octet-stream": { - "schema": { - "type": "string" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Database restored, restart required" - }, - "400": { - "description": "Invalid backup file" - } - }, - "security": [ - { - "session_cookie": [] - }, - { - "api_key": [] - }, - { - "bearer_token": [] - } - ] - } - }, - "/api/settings/themes": { - "get": { - "tags": [ - "themes" - ], - "operationId": "list_themes", - "responses": { - "200": { - "description": "List custom themes", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ThemeSummary" - } - } - } - } - }, - "401": { - "description": "Unauthenticated" - } - }, - "security": [ - { - "session_cookie": [] - }, - { - "api_key": [] - }, - { - "bearer_token": [] - } - ] - }, - "post": { - "tags": [ - "themes" - ], - "operationId": "create_theme", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateThemeInput" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Custom theme created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Theme" - } - } - } - }, - "401": { - "description": "Unauthenticated" - }, - "403": { - "description": "Forbidden (non-admin)" - }, - "422": { - "description": "Validation error or custom themes disabled" - } - }, - "security": [ - { - "session_cookie": [] - }, - { - "api_key": [] - }, - { - "bearer_token": [] - } - ] - } - }, - "/api/settings/themes/import": { - "post": { - "tags": [ - "themes" - ], - "operationId": "import_theme", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ExportPayload" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Custom theme imported", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Theme" - } - } - } - }, - "401": { - "description": "Unauthenticated" - }, - "403": { - "description": "Forbidden (non-admin)" - }, - "422": { - "description": "Validation error or custom themes disabled" - } - }, - "security": [ - { - "session_cookie": [] - }, - { - "api_key": [] - }, - { - "bearer_token": [] - } - ] - } - }, - "/api/settings/themes/{id}": { - "get": { - "tags": [ - "themes" - ], - "operationId": "get_theme", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Theme ID", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - } - ], - "responses": { - "200": { - "description": "Custom theme", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Theme" - } - } - } - }, - "401": { - "description": "Unauthenticated" - }, - "404": { - "description": "Theme not found" - } - }, - "security": [ - { - "session_cookie": [] - }, - { - "api_key": [] - }, - { - "bearer_token": [] - } - ] - }, - "put": { - "tags": [ - "themes" - ], - "operationId": "update_theme", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Theme ID", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateThemeInput" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Custom theme updated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Theme" - } - } - } - }, - "401": { - "description": "Unauthenticated" - }, - "403": { - "description": "Forbidden (non-admin)" - }, - "404": { - "description": "Theme not found" - }, - "422": { - "description": "Validation error or custom themes disabled" - } - }, - "security": [ - { - "session_cookie": [] - }, - { - "api_key": [] - }, - { - "bearer_token": [] - } - ] - }, - "delete": { - "tags": [ - "themes" - ], - "operationId": "delete_theme", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Theme ID", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - } - ], - "responses": { - "200": { - "description": "Custom theme deleted" - }, - "401": { - "description": "Unauthenticated" - }, - "403": { - "description": "Forbidden (non-admin)" - }, - "404": { - "description": "Theme not found" - }, - "409": { - "description": "Theme is referenced" - }, - "422": { - "description": "Custom themes disabled" + "description": "Validation error" } }, "security": [ @@ -6954,46 +6576,38 @@ ] } }, - "/api/settings/themes/{id}/duplicate": { + "/api/settings/brand/favicon": { "post": { "tags": [ - "themes" + "brand" ], - "operationId": "duplicate_theme", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Theme ID", - "required": true, - "schema": { - "type": "integer", - "format": "int32" + "operationId": "upload_favicon", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "string" + } } - } - ], + }, + "required": true + }, "responses": { "200": { - "description": "Custom theme duplicated", + "description": "Favicon uploaded", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Theme" + "$ref": "#/components/schemas/UploadResponse" } } } }, - "401": { - "description": "Unauthenticated" - }, - "403": { - "description": "Forbidden (non-admin)" - }, - "404": { - "description": "Theme not found" + "400": { + "description": "Invalid file" }, "422": { - "description": "Validation error or custom themes disabled" + "description": "Validation error" } }, "security": [ @@ -7009,40 +6623,38 @@ ] } }, - "/api/settings/themes/{id}/export": { - "get": { + "/api/settings/brand/logo": { + "post": { "tags": [ - "themes" + "brand" ], - "operationId": "export_theme", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Theme ID", - "required": true, - "schema": { - "type": "integer", - "format": "int32" + "operationId": "upload_logo", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "string" + } } - } - ], + }, + "required": true + }, "responses": { "200": { - "description": "Exportable custom theme payload", + "description": "Logo uploaded", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExportPayload" + "$ref": "#/components/schemas/UploadResponse" } } } }, - "401": { - "description": "Unauthenticated" + "400": { + "description": "Invalid file" }, - "404": { - "description": "Theme not found" + "422": { + "description": "Validation error" } }, "security": [ @@ -7058,46 +6670,30 @@ ] } }, - "/api/settings/themes/{id}/references": { - "get": { + "/api/settings/restore": { + "post": { "tags": [ - "themes" - ], - "operationId": "get_references", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Theme ID", - "required": true, - "schema": { - "type": "integer", - "format": "int32" - } - } + "settings" ], - "responses": { - "200": { - "description": "Theme references", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ThemeReferences" - } + "summary": "Restore the database from an uploaded backup file.\nThe server should be restarted after restore.\nNote: request body is raw binary (application/octet-stream).", + "operationId": "restore_backup", + "requestBody": { + "description": "SQLite backup file", + "content": { + "application/octet-stream": { + "schema": { + "type": "string" } } }, - "401": { - "description": "Unauthenticated" - }, - "403": { - "description": "Forbidden (non-admin)" - }, - "404": { - "description": "Theme not found" + "required": true + }, + "responses": { + "200": { + "description": "Database restored, restart required" }, - "422": { - "description": "Invalid theme ID" + "400": { + "description": "Invalid backup file" } }, "security": [ @@ -7495,6 +7091,9 @@ "404": { "description": "Server not in public scope" }, + "422": { + "description": "Invalid time range (`from` after `to`)" + }, "429": { "description": "Rate limit exceeded" } @@ -8160,11 +7759,170 @@ } } }, - "403": { - "description": "Forbidden — admin only" + "403": { + "description": "Forbidden — admin only" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "session_cookie": [] + }, + { + "api_key": [] + }, + { + "bearer_token": [] + } + ] + }, + "delete": { + "tags": [ + "users" + ], + "operationId": "delete_user", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "User ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "User deleted" + }, + "400": { + "description": "Cannot delete last admin" + }, + "403": { + "description": "Forbidden — admin only" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "session_cookie": [] + }, + { + "api_key": [] + }, + { + "bearer_token": [] + } + ] + } + }, + "/api/widget-modules": { + "get": { + "tags": [ + "widget-modules" + ], + "operationId": "list_modules", + "responses": { + "200": { + "description": "List installed widget modules", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WidgetModuleListEntry" + } + } + } + } + } + }, + "security": [ + { + "session_cookie": [] + }, + { + "api_key": [] + }, + { + "bearer_token": [] + } + ] + }, + "post": { + "tags": [ + "widget-modules" + ], + "operationId": "install_widget_module", + "parameters": [ + { + "name": "url", + "in": "query", + "description": "HTTPS URL to fetch the widget bundle from. Accepts either a single `.js` file or a `.zip` collection bundle.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Alternatively, upload the widget bundle in a `file` field. Accepts either a single `.js` file or a `.zip` collection bundle.", + "content": { + "multipart/form-data": {} + } + }, + "responses": { + "200": { + "description": "Installed (or upgraded) widget module(s). For a single `.js` file the response is `{ data: { id, version } }`. For a `.zip` collection it is `{ data: [{ id, version }, ...] }` — one entry per widget in the collection." + }, + "400": { + "description": "Bad URL, unsupported source, or invalid manifest" + }, + "409": { + "description": "Module id conflicts with an existing install of a different source type" + } + }, + "security": [ + { + "session_cookie": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/widget-modules/{id}": { + "delete": { + "tags": [ + "widget-modules" + ], + "operationId": "uninstall_module", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Module ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Module uninstalled" + }, + "400": { + "description": "Cannot uninstall builtin module" }, "404": { - "description": "Not found" + "description": "Module not found" } }, "security": [ @@ -8173,22 +7931,30 @@ }, { "api_key": [] - }, - { - "bearer_token": [] } ] - }, - "delete": { + } + }, + "/api/widget-modules/{id}/{asset_path}": { + "get": { "tags": [ - "users" + "widget-modules" ], - "operationId": "delete_user", + "operationId": "serve_asset", "parameters": [ { "name": "id", "in": "path", - "description": "User ID", + "description": "Module ID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "asset_path", + "in": "path", + "description": "Asset path within the package", "required": true, "schema": { "type": "string" @@ -8197,16 +7963,10 @@ ], "responses": { "200": { - "description": "User deleted" - }, - "400": { - "description": "Cannot delete last admin" - }, - "403": { - "description": "Forbidden — admin only" + "description": "Asset bytes" }, "404": { - "description": "Not found" + "description": "Module or asset not found" } }, "security": [ @@ -8236,21 +7996,34 @@ } } }, - "ActiveThemeResponse": { + "AgentAuthorityStateSummary": { "type": "object", "required": [ - "ref", - "theme" + "status" ], "properties": { - "ref": { - "type": "string" + "outstanding_offer": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/OutstandingEnrollmentSummary" + } + ] }, - "theme": { - "$ref": "#/components/schemas/ThemeResolved" + "status": { + "$ref": "#/components/schemas/AgentAuthorityStatus" } } }, + "AgentAuthorityStatus": { + "type": "string", + "enum": [ + "claimed", + "unclaimed" + ] + }, "AlertEventDetailResponse": { "type": "object", "required": [ @@ -8655,6 +8428,12 @@ "advisories" ], "properties": { + "advisories": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CostAdvisory" + } + }, "billing_cycle": { "type": [ "string", @@ -8781,12 +8560,6 @@ }, "server_id": { "type": "string" - }, - "advisories": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CostAdvisory" - } } } } @@ -9154,6 +8927,74 @@ } } }, + "AuthorityEventResponse": { + "type": "object", + "required": [ + "id", + "server_id", + "server_name", + "actor_kind", + "request_source", + "transition", + "authority_before", + "authority_after", + "created_at" + ], + "properties": { + "actor_id": { + "type": [ + "string", + "null" + ] + }, + "actor_kind": { + "type": "string" + }, + "authority_after": { + "type": "string" + }, + "authority_before": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string" + }, + "mode": { + "type": [ + "string", + "null" + ] + }, + "offer_id": { + "type": [ + "string", + "null" + ] + }, + "offer_outcome": { + "type": [ + "string", + "null" + ] + }, + "request_source": { + "type": "string" + }, + "server_id": { + "type": "string" + }, + "server_name": { + "type": "string" + }, + "transition": { + "type": "string" + } + } + }, "BatchDeleteRequest": { "type": "object", "required": [ @@ -9318,6 +9159,7 @@ }, "CostAdvisory": { "type": "string", + "description": "A cost advisory: an objective, per-server warning surfaced alongside the cost\nbreakdown. Unlike the former composite \"value score\", each advisory is a\nsingle actionable fact computed without any fleet comparison, so it is\nmeaningful even for a single-server deployment. The variants are declared in\ndisplay-priority order (most urgent first).", "enum": [ "expired_billing", "sleeping_money", @@ -9685,6 +9527,7 @@ "CreateServerRequest": { "type": "object", "required": [ + "onboarding_request_id", "name" ], "properties": { @@ -9733,6 +9576,9 @@ "name": { "type": "string" }, + "onboarding_request_id": { + "type": "string" + }, "price": { "type": [ "number", @@ -9785,11 +9631,31 @@ "type": "object", "required": [ "server_id", - "enrollment" + "replayed" ], "properties": { "enrollment": { - "$ref": "#/components/schemas/EnrollmentIssueResponse" + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/EnrollmentIssueResponse" + } + ] + }, + "outstanding_offer": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/OutstandingEnrollmentSummary" + } + ] + }, + "replayed": { + "type": "boolean" }, "server_id": { "type": "string" @@ -9885,7 +9751,7 @@ } }, "task_type": { - "type": "string", + "$ref": "#/components/schemas/TaskType", "description": "\"oneshot\" (default) or \"scheduled\"" }, "timeout": { @@ -9898,37 +9764,6 @@ } } }, - "CreateThemeInput": { - "type": "object", - "required": [ - "name", - "vars_light", - "vars_dark" - ], - "properties": { - "based_on": { - "type": [ - "string", - "null" - ] - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "vars_dark": { - "$ref": "#/components/schemas/HashMap" - }, - "vars_light": { - "$ref": "#/components/schemas/HashMap" - } - } - }, "CreateUserInput": { "type": "object", "required": [ @@ -10132,46 +9967,14 @@ } } }, - "EnrollmentSummary": { + "EnrollmentOfferResponse": { "type": "object", "required": [ - "id", - "target_server_id", - "code_prefix", - "created_by", - "expires_at", - "created_at" + "enrollment" ], "properties": { - "code_prefix": { - "type": "string" - }, - "consumed_at": { - "type": [ - "string", - "null" - ] - }, - "created_at": { - "type": "string" - }, - "created_by": { - "type": "string" - }, - "expires_at": { - "type": "string" - }, - "id": { - "type": "string" - }, - "revoked_at": { - "type": [ - "string", - "null" - ] - }, - "target_server_id": { - "type": "string" + "enrollment": { + "$ref": "#/components/schemas/EnrollmentIssueResponse" } } }, @@ -10196,48 +9999,12 @@ "code": { "type": "string" }, + "details": {}, "message": { "type": "string" } } }, - "ExportPayload": { - "type": "object", - "required": [ - "version", - "name", - "vars_light", - "vars_dark" - ], - "properties": { - "based_on": { - "type": [ - "string", - "null" - ] - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "vars_dark": { - "$ref": "#/components/schemas/HashMap" - }, - "vars_light": { - "$ref": "#/components/schemas/HashMap" - }, - "version": { - "type": "integer", - "format": "int32", - "minimum": 0 - } - } - }, "FileEntry": { "type": "object", "required": [ @@ -10389,15 +10156,6 @@ } } }, - "HashMap": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "propertyNames": { - "type": "string" - } - }, "HourlyTraffic": { "type": "object", "required": [ @@ -10604,6 +10362,18 @@ } } }, + "IssueOfferRequest": { + "type": "object", + "properties": { + "ttl_secs": { + "type": [ + "integer", + "null" + ], + "format": "int64" + } + } + }, "LatestAgentVersionResponse": { "type": "object", "properties": { @@ -11009,6 +10779,12 @@ "id": { "type": "string" }, + "module_id": { + "type": [ + "string", + "null" + ] + }, "sort_order": { "type": "integer", "format": "int32" @@ -12076,17 +11852,6 @@ } } }, - "PutActiveThemeInput": { - "type": "object", - "required": [ - "ref" - ], - "properties": { - "ref": { - "type": "string" - } - } - }, "RateLimitEntryDto": { "type": "object", "required": [ @@ -12254,59 +12019,41 @@ "icmp", "udp", "tcp", - "legacy" - ] - }, - "RecoverRequest": { - "type": "object", - "required": [ - "revoke_immediately" - ], - "properties": { - "revoke_immediately": { - "type": "boolean", - "description": "If `true`, clear the server's `token_hash`/`token_prefix` and kick the\ncurrently connected agent WebSocket as part of the same transaction.\nUse this when the operator suspects the existing agent token has been\ncompromised. If `false`, the existing token remains valid and only a\nnew bound enrollment is minted alongside it." - } - } + "legacy" + ] + }, + "ReenrollmentModeRequest": { + "type": "string", + "enum": [ + "graceful", + "emergency" + ] }, - "RecoverResponse": { + "ReenrollmentRequest": { "type": "object", "required": [ - "enrollment" + "mode" ], "properties": { - "enrollment": { - "$ref": "#/components/schemas/EnrollmentIssueResponse" - } - } - }, - "RegenerateCodeRequest": { - "type": "object", - "properties": { - "expected_enrollment_id": { + "mode": { + "$ref": "#/components/schemas/ReenrollmentModeRequest" + }, + "ttl_secs": { "type": [ - "string", + "integer", "null" ], - "description": "Optimistic concurrency token. If `Some`, must match the current\noutstanding enrollment id exactly; otherwise the server returns 409.\nIf `None`, last-writer-wins: any outstanding enrollment is revoked\nand a fresh one is minted." + "format": "int64" } } }, - "RegenerateCodeResponse": { + "RegisterRequest": { "type": "object", "required": [ - "enrollment" + "proposed_run_token" ], "properties": { - "enrollment": { - "$ref": "#/components/schemas/EnrollmentIssueResponse" - } - } - }, - "RegisterRequest": { - "type": "object", - "properties": { - "fingerprint": { + "proposed_run_token": { "type": "string" } } @@ -12314,15 +12061,11 @@ "RegisterResponse": { "type": "object", "required": [ - "server_id", - "token" + "server_id" ], "properties": { "server_id": { "type": "string" - }, - "token": { - "type": "string" } } }, @@ -12365,19 +12108,33 @@ } } }, - "RotateTokenResponse": { + "RevokeAuthorityResponse": { "type": "object", "required": [ "server_id", - "token" + "changed" ], "properties": { + "changed": { + "type": "boolean" + }, "server_id": { "type": "string" + } + } + }, + "RevokeOfferResponse": { + "type": "object", + "required": [ + "offer_id", + "already_revoked" + ], + "properties": { + "already_revoked": { + "type": "boolean" }, - "token": { - "type": "string", - "description": "New plaintext run token — shown once. The agent must be reconfigured\nwith this value (or it will need to re-enroll)." + "offer_id": { + "type": "string" } } }, @@ -12515,6 +12272,12 @@ "advisories" ], "properties": { + "advisories": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CostAdvisory" + } + }, "billing_cycle": { "type": [ "string", @@ -12641,12 +12404,6 @@ }, "server_id": { "type": "string" - }, - "advisories": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CostAdvisory" - } } } }, @@ -12659,6 +12416,12 @@ "advisories" ], "properties": { + "advisories": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CostAdvisory" + } + }, "billing_cycle": { "type": [ "string", @@ -12738,12 +12501,6 @@ }, "server_id": { "type": "string" - }, - "advisories": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CostAdvisory" - } } } }, @@ -13088,16 +12845,21 @@ "required": [ "id", "name", + "geo_manual", "weight", "hidden", "capabilities", "protocol_version", "features", + "agent_authority", "has_token", "created_at", "updated_at" ], "properties": { + "agent_authority": { + "$ref": "#/components/schemas/AgentAuthorityStateSummary" + }, "agent_local_capabilities": { "type": [ "integer", @@ -13190,6 +12952,10 @@ "type": "string" } }, + "geo_manual": { + "type": "boolean", + "description": "`true` when `country_code`/`region` were pinned manually by an operator\nand are no longer auto-updated from GeoIP. The UI uses this to show that\nthe flag is a manual override." + }, "group_id": { "type": [ "string", @@ -13291,7 +13057,8 @@ "type": "array", "items": { "$ref": "#/components/schemas/TemporaryGrantDto" - } + }, + "description": "Currently-active temporary capability grants reported by the agent, used\nby the UI to render countdowns. Empty when the agent is offline or has no\nactive grants." }, "traffic_limit": { "type": [ @@ -13899,8 +13666,16 @@ } } }, + "TaskType": { + "type": "string", + "enum": [ + "oneshot", + "scheduled" + ] + }, "TemporaryGrantDto": { "type": "object", + "description": "A capability that is temporarily enabled on the agent host until\n`expires_at`. Mirrors `serverbee_common::protocol::TemporaryGrant` but adds a\n`ToSchema` derive so the REST `ServerResponse` can advertise it; the UI uses\nit to render countdowns from a plain HTTP fetch.", "required": [ "cap", "granted_at", @@ -13910,156 +13685,13 @@ "cap": { "type": "string" }, - "granted_at": { - "type": "integer", - "format": "int64" - }, "expires_at": { "type": "integer", "format": "int64" - } - } - }, - "Theme": { - "type": "object", - "required": [ - "id", - "name", - "vars_light", - "vars_dark", - "created_at", - "updated_at" - ], - "properties": { - "based_on": { - "type": [ - "string", - "null" - ] - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "id": { - "type": "integer", - "format": "int32" - }, - "name": { - "type": "string" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "vars_dark": { - "$ref": "#/components/schemas/HashMap" - }, - "vars_light": { - "$ref": "#/components/schemas/HashMap" - } - } - }, - "ThemeReferences": { - "type": "object", - "description": "Where a custom theme is referenced.\n\nAfter R1 the public status page no longer carries its own `theme_ref` —\nonly the admin UI's active theme can pin a custom theme. The previous\n`status_pages: Vec<StatusPageRef>` collection has therefore been removed.", - "required": [ - "admin" - ], - "properties": { - "admin": { - "type": "boolean" - } - } - }, - "ThemeResolved": { - "oneOf": [ - { - "type": "object", - "required": [ - "id", - "kind" - ], - "properties": { - "id": { - "type": "string" - }, - "kind": { - "type": "string", - "enum": [ - "preset" - ] - } - } - }, - { - "type": "object", - "required": [ - "id", - "name", - "vars_light", - "vars_dark", - "updated_at", - "kind" - ], - "properties": { - "id": { - "type": "integer", - "format": "int32" - }, - "kind": { - "type": "string", - "enum": [ - "custom" - ] - }, - "name": { - "type": "string" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "vars_dark": { - "$ref": "#/components/schemas/HashMap" - }, - "vars_light": { - "$ref": "#/components/schemas/HashMap" - } - } - } - ] - }, - "ThemeSummary": { - "type": "object", - "required": [ - "id", - "name", - "updated_at" - ], - "properties": { - "based_on": { - "type": [ - "string", - "null" - ] }, - "id": { + "granted_at": { "type": "integer", - "format": "int32" - }, - "name": { - "type": "string" - }, - "updated_at": { - "type": "string", - "format": "date-time" + "format": "int64" } } }, @@ -14466,6 +14098,13 @@ "direction": { "type": "string" }, + "error": { + "type": [ + "string", + "null" + ], + "description": "Human-readable failure reason when `status == \"failed\"`; omitted otherwise." + }, "file_path": { "type": "string" }, @@ -14987,6 +14626,13 @@ ], "format": "int32" }, + "country_code": { + "type": [ + "string", + "null" + ], + "description": "Manual override for the GeoIP country flag. `Some(Some(\"us\"))` pins the\n2-letter ISO code and freezes it against auto-detection; `Some(None)`\n(explicit JSON null) clears the override and resumes GeoIP on the next\nagent report. Absent = unchanged." + }, "currency": { "type": [ "string", @@ -15331,37 +14977,6 @@ } } }, - "UpdateThemeInput": { - "type": "object", - "required": [ - "name", - "vars_light", - "vars_dark" - ], - "properties": { - "based_on": { - "type": [ - "string", - "null" - ] - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "name": { - "type": "string" - }, - "vars_dark": { - "$ref": "#/components/schemas/HashMap" - }, - "vars_light": { - "$ref": "#/components/schemas/HashMap" - } - } - }, "UpdateUserInput": { "type": "object", "properties": { @@ -15534,6 +15149,12 @@ "null" ] }, + "module_id": { + "type": [ + "string", + "null" + ] + }, "sort_order": { "type": "integer", "format": "int32" @@ -15549,6 +15170,41 @@ } } }, + "WidgetModuleListEntry": { + "type": "object", + "required": [ + "id", + "version", + "source_type", + "entry_path", + "code_sha256", + "manifest", + "enabled" + ], + "properties": { + "code_sha256": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "entry_path": { + "type": "string" + }, + "id": { + "type": "string" + }, + "manifest": { + "type": "object" + }, + "source_type": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, "WriteRequest": { "type": "object", "required": [ @@ -15631,10 +15287,6 @@ "name": "settings", "description": "System settings" }, - { - "name": "themes", - "description": "Custom theme management" - }, { "name": "notifications", "description": "Notification channels" @@ -15683,6 +15335,10 @@ "name": "dashboards", "description": "Custom dashboard management" }, + { + "name": "widget-modules", + "description": "Custom widget module registry & asset serving" + }, { "name": "service-monitors", "description": "Server-side service monitoring (SSL/DNS/HTTP/TCP/WHOIS)" @@ -15701,7 +15357,7 @@ }, { "name": "cost", - "description": "VPS cost insights and value scoring" + "description": "VPS cost insights and advisories" }, { "name": "uptime", @@ -15732,4 +15388,4 @@ "description": "ASN MMDB database management for traceroute enrichment (DB-IP Lite ASN)" } ] -} +} \ No newline at end of file diff --git a/apps/web/src/components/server/add-server-dialog.test.tsx b/apps/web/src/components/server/add-server-dialog.test.tsx index b1ab66e3..afc185e7 100644 --- a/apps/web/src/components/server/add-server-dialog.test.tsx +++ b/apps/web/src/components/server/add-server-dialog.test.tsx @@ -2,6 +2,8 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import type { ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' +const UUID_RE = /^[0-9a-f-]{36}$/ + const mockPost = vi.fn() const mockRefreshServerCatalog = vi.hoisted(() => vi.fn()) const mockQueryClient = {} @@ -122,6 +124,8 @@ describe('AddServerDialog', () => { it('POSTs to /api/servers with the form payload and transitions to the install-command view on success', async () => { mockPost.mockResolvedValueOnce({ server_id: 'srv-123', + replayed: false, + outstanding_offer: null, enrollment: { id: 'enr-1', code: 'plaintext-code-shown-once', @@ -143,9 +147,12 @@ describe('AddServerDialog', () => { const [path, body] = mockPost.mock.calls[0] expect(path).toBe('/api/servers') - expect(body).toMatchObject({ name: 'tokyo-vps-01' }) - expect((body as Record<string, unknown>).group_id).toBeUndefined() - expect((body as Record<string, unknown>).caps).toBeUndefined() + expect(body).toMatchObject({ + name: 'tokyo-vps-01', + onboarding_request_id: expect.stringMatching(UUID_RE) + }) + expect(body).toHaveProperty('group_id', undefined) + expect(body).toHaveProperty('caps', undefined) await waitFor(() => { expect(screen.getByText('plaintext-code-shown-once')).toBeInTheDocument() @@ -156,6 +163,67 @@ describe('AddServerDialog', () => { expect(screen.queryByRole('button', { name: 'add_server.generate' })).not.toBeInTheDocument() }) + it('reuses the onboarding request id after an ambiguous failure', async () => { + mockPost.mockRejectedValueOnce(new Error('connection closed')).mockResolvedValueOnce({ + enrollment: null, + outstanding_offer: null, + replayed: true, + server_id: 'srv-123' + }) + mockRefreshServerCatalog.mockResolvedValue(undefined) + + render(<AddServerDialog onClose={vi.fn()} open />) + fireEvent.change(screen.getByLabelText('add_server.name_label'), { target: { value: 'tokyo-vps-01' } }) + const submit = screen.getByRole('button', { name: 'add_server.generate' }) + + fireEvent.click(submit) + await waitFor(() => expect(mockPost).toHaveBeenCalledTimes(1)) + fireEvent.click(submit) + await waitFor(() => expect(mockPost).toHaveBeenCalledTimes(2)) + + const firstBody = mockPost.mock.calls[0]?.[1] + const secondBody = mockPost.mock.calls[1]?.[1] + expect(firstBody).toHaveProperty('onboarding_request_id') + expect(secondBody).toHaveProperty('onboarding_request_id') + expect(secondBody).toMatchObject({ onboarding_request_id: firstBody.onboarding_request_id }) + }) + + it('offers exact replacement when onboarding replay cannot recover plaintext', async () => { + mockPost + .mockResolvedValueOnce({ + enrollment: null, + outstanding_offer: { + code_prefix: 'oldpre', + created_at: '2026-07-13T00:00:00Z', + expires_at: '2026-07-13T00:10:00Z', + id: 'offer-current' + }, + replayed: true, + server_id: 'srv-123' + }) + .mockResolvedValueOnce({ + enrollment: { + code: 'replacement-code', + code_prefix: 'replac', + expires_at: '2026-07-13T00:20:00Z', + id: 'offer-next' + } + }) + mockRefreshServerCatalog.mockResolvedValue(undefined) + + render(<AddServerDialog onClose={vi.fn()} open />) + fireEvent.change(screen.getByLabelText('add_server.name_label'), { target: { value: 'tokyo-vps-01' } }) + fireEvent.click(screen.getByRole('button', { name: 'add_server.generate' })) + + const replace = await screen.findByRole('button', { name: 'add_server.replace_offer' }) + fireEvent.click(replace) + + await waitFor(() => { + expect(mockPost).toHaveBeenLastCalledWith('/api/servers/srv-123/agent-authority/offers/offer-current/replace', {}) + }) + expect(await screen.findByText('replacement-code')).toBeInTheDocument() + }) + it('disables the submit button when name is empty', () => { render(<AddServerDialog onClose={vi.fn()} open />) const submit = screen.getByRole('button', { name: 'add_server.generate' }) as HTMLButtonElement diff --git a/apps/web/src/components/server/add-server-dialog.tsx b/apps/web/src/components/server/add-server-dialog.tsx index b0eb08d5..0fb542f9 100644 --- a/apps/web/src/components/server/add-server-dialog.tsx +++ b/apps/web/src/components/server/add-server-dialog.tsx @@ -4,22 +4,29 @@ import { CalendarIcon, Copy, Plus } from 'lucide-react' import { type FormEvent, useReducer } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { + AgentCapabilityPicker, + ALL_AGENT_CAPABILITY_KEYS, + DEFAULT_AGENT_CAPABILITY_KEYS, + resolveAgentCapabilitySelection +} from '@/components/server/agent-capability-picker' import { Button } from '@/components/ui/button' import { Calendar } from '@/components/ui/calendar' -import { Checkbox } from '@/components/ui/checkbox' import { Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { api } from '@/lib/api-client' -import type { CreateServerRequest, CreateServerResponse, ServerGroup } from '@/lib/api-schema' -import { CAP_DEFAULT, CAPABILITIES, hasCap } from '@/lib/capabilities' +import type { + CreateServerRequest, + CreateServerResponse, + EnrollmentIssueResponse, + EnrollmentOfferResponse, + ServerGroup +} from '@/lib/api-schema' import { refreshServerCatalog } from '@/lib/server-catalog' import { cn } from '@/lib/utils' -const DEFAULT_CAP_KEYS = CAPABILITIES.flatMap((c) => (hasCap(CAP_DEFAULT, c.bit) ? [c.key] : [])) -const ALL_CAP_KEYS = CAPABILITIES.map((c) => c.key) - const TAG_SPLIT_RE = /[\s,]+/ const TAG_VALID_RE = /^[A-Za-z0-9_.-]+$/ @@ -79,41 +86,6 @@ function parseTagsInput(raw: string): { tags: string[]; error: string | null } { return { tags: deduped.sort(), error: null } } -interface CapGroupProps { - caps: readonly (typeof CAPABILITIES)[number][] - onToggle: (key: string) => void - selected: Set<string> - t: (key: string) => string - title: string - tone: 'high' | 'standard' -} - -function CapGroup({ caps, onToggle, selected, t, title, tone }: CapGroupProps) { - return ( - <div> - <p - className={cn( - 'mb-1.5 font-medium text-[11px] uppercase tracking-wide', - tone === 'high' ? 'text-amber-600 dark:text-amber-500' : 'text-muted-foreground' - )} - > - {title} - </p> - <div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2"> - {caps.map((cap) => { - const id = `add-server-cap-${cap.key}` - return ( - <label className="flex cursor-pointer items-center gap-2 text-sm" htmlFor={id} key={cap.key}> - <Checkbox checked={selected.has(cap.key)} id={id} onCheckedChange={() => onToggle(cap.key)} /> - <span className="truncate">{t(cap.labelKey)}</span> - </label> - ) - })} - </div> - </div> - ) -} - function Field({ label, children, htmlFor }: { children: React.ReactNode; htmlFor?: string; label: string }) { return ( <div className="space-y-1"> @@ -173,6 +145,7 @@ interface AddServerFormState { groupId: string issued: CreateServerResponse | null name: string + onboardingRequestId: string price: string publicRemark: string remark: string @@ -198,10 +171,11 @@ function initialAddServerFormState(): AddServerFormState { groupId: '', issued: null, name: '', + onboardingRequestId: crypto.randomUUID(), price: '', publicRemark: '', remark: '', - selectedCaps: new Set(DEFAULT_CAP_KEYS), + selectedCaps: new Set(DEFAULT_AGENT_CAPABILITY_KEYS), tagsInput: '', trafficLimit: '', trafficLimitType: 'sum' @@ -233,15 +207,15 @@ function addServerFormReducer(state: AddServerFormState, action: AddServerFormAc } function AddServerIssuedView({ + enrollment, installCommand, - issued, onAnother, onClose, onCopy, t }: { installCommand: string - issued: CreateServerResponse + enrollment: EnrollmentIssueResponse onAnother: () => void onClose: () => void onCopy: (value: string) => void @@ -259,11 +233,11 @@ function AddServerIssuedView({ <p className="mb-1 font-medium text-muted-foreground text-xs">{t('add_server.code_label')}</p> <div className="flex min-w-0 items-center gap-2"> <code className="min-w-0 flex-1 truncate rounded-md border bg-muted/50 px-3 py-2 font-mono text-sm"> - {issued.enrollment.code} + {enrollment.code} </code> <Button aria-label={t('add_server.copy')} - onClick={() => onCopy(issued.enrollment.code)} + onClick={() => onCopy(enrollment.code)} size="icon" type="button" variant="outline" @@ -539,68 +513,6 @@ function AddServerBillingFields({ ) } -function AddServerCapabilityFields({ - highRiskCaps, - onReset, - onSelectAll, - onSelectNone, - onToggle, - selectedCaps, - standardCaps, - t -}: { - highRiskCaps: (typeof CAPABILITIES)[number][] - onReset: () => void - onSelectAll: () => void - onSelectNone: () => void - onToggle: (key: string) => void - selectedCaps: Set<string> - standardCaps: (typeof CAPABILITIES)[number][] - t: TFunction -}) { - return ( - <fieldset className="space-y-2"> - <legend className="mb-1 flex w-full items-center justify-between gap-2"> - <span className="font-medium text-muted-foreground text-xs uppercase tracking-wider"> - {t('add_server.caps_label')} - </span> - <span className="flex gap-2 text-xs"> - <button className="text-muted-foreground hover:text-foreground" onClick={onReset} type="button"> - {t('add_server.caps_reset')} - </button> - <span className="text-muted-foreground/50">·</span> - <button className="text-muted-foreground hover:text-foreground" onClick={onSelectAll} type="button"> - {t('add_server.caps_select_all')} - </button> - <span className="text-muted-foreground/50">·</span> - <button className="text-muted-foreground hover:text-foreground" onClick={onSelectNone} type="button"> - {t('add_server.caps_select_none')} - </button> - </span> - </legend> - <p className="text-muted-foreground text-xs">{t('add_server.caps_hint')}</p> - <div className="mt-2 space-y-3 rounded-md border bg-muted/30 p-3"> - <CapGroup - caps={standardCaps} - onToggle={onToggle} - selected={selectedCaps} - t={t} - title={t('add_server.caps_low_risk')} - tone="standard" - /> - <CapGroup - caps={highRiskCaps} - onToggle={onToggle} - selected={selectedCaps} - t={t} - title={t('add_server.caps_high_risk')} - tone="high" - /> - </div> - </fieldset> - ) -} - export function AddServerDialog({ open, onClose }: { onClose: () => void; open: boolean }) { const { t } = useTranslation(['servers', 'common']) const queryClient = useQueryClient() @@ -628,37 +540,50 @@ export function AddServerDialog({ open, onClose }: { onClose: () => void; open: } }) + const replaceReplayOfferMutation = useMutation({ + mutationFn: ({ offerId, serverId }: { offerId: string; serverId: string }) => + api.post<EnrollmentOfferResponse>(`/api/servers/${serverId}/agent-authority/offers/${offerId}/replace`, {}), + onSuccess: (data) => { + const current = state.issued + if (!current) { + return + } + dispatch({ + type: 'setIssued', + value: { + ...current, + enrollment: data.enrollment, + outstanding_offer: { + id: data.enrollment.id, + code_prefix: data.enrollment.code_prefix, + created_at: new Date().toISOString(), + expires_at: data.enrollment.expires_at + } + } + }) + }, + onError: (err: unknown) => { + toast.error(err instanceof Error ? err.message : t('add_server.replace_failed')) + } + }) + const origin = typeof window !== 'undefined' ? window.location.origin : '' // Emit --caps only when the selection differs from the default set; an // omitted flag means "use install.sh's built-in defaults", which keeps the // copy/paste command short for the common case. - const orderedCapSelection = ALL_CAP_KEYS.filter((k) => state.selectedCaps.has(k)) - const capsIsDefault = - orderedCapSelection.length === DEFAULT_CAP_KEYS.length && DEFAULT_CAP_KEYS.every((k) => state.selectedCaps.has(k)) - const capsArg = (() => { - if (capsIsDefault) { - return '' - } - if (orderedCapSelection.length === 0) { - return " --caps ''" - } - return ` --caps ${orderedCapSelection.join(',')}` - })() + const capabilitySelection = resolveAgentCapabilitySelection(state.selectedCaps) const issued = state.issued - const installCommand = issued - ? `curl -fsSL https://raw.githubusercontent.com/ZingerLittleBee/ServerBee/main/deploy/install.sh | sudo bash -s -- agent --server-url '${origin}' --enrollment-code '${issued.enrollment.code}'${capsArg}` + const installCommand = issued?.enrollment + ? `curl -fsSL https://raw.githubusercontent.com/ZingerLittleBee/ServerBee/main/deploy/install.sh | sudo bash -s -- agent --server-url '${origin}' --enrollment-code '${issued.enrollment.code}'${capabilitySelection.installArgument}` : '' const toggleCap = (key: string) => { dispatch({ type: 'toggleCap', key }) } - const resetCapsToDefault = () => dispatch({ type: 'setCaps', value: new Set(DEFAULT_CAP_KEYS) }) - const selectAllCaps = () => dispatch({ type: 'setCaps', value: new Set(ALL_CAP_KEYS) }) + const resetCapsToDefault = () => dispatch({ type: 'setCaps', value: new Set(DEFAULT_AGENT_CAPABILITY_KEYS) }) + const selectAllCaps = () => dispatch({ type: 'setCaps', value: new Set(ALL_AGENT_CAPABILITY_KEYS) }) const selectNoCaps = () => dispatch({ type: 'setCaps', value: new Set() }) - const highRiskCaps = CAPABILITIES.filter((c) => c.risk === 'high') - const standardCaps = CAPABILITIES.filter((c) => c.risk !== 'high') - const copy = async (value: string) => { try { await navigator.clipboard.writeText(value) @@ -679,7 +604,9 @@ export function AddServerDialog({ open, onClose }: { onClose: () => void; open: const buildBody = (trimmedName: string, tags: string[]): CreateServerRequest => { const trafficLimitValue = numberOrUndefined(Math.round(parseFloatOrNaN(state.trafficLimit) * 1024 ** 3)) - const optionalFields: Partial<CreateServerRequest> = { + return { + onboarding_request_id: state.onboardingRequestId, + name: trimmedName, group_id: state.groupId || undefined, tags: tags.length > 0 ? tags : undefined, remark: nullIfBlank(state.remark), @@ -691,15 +618,8 @@ export function AddServerDialog({ open, onClose }: { onClose: () => void; open: expired_at: state.expiredAt ? `${state.expiredAt}T00:00:00Z` : undefined, traffic_limit: trafficLimitValue, traffic_limit_type: trafficLimitValue === undefined ? undefined : state.trafficLimitType, - caps: capsIsDefault ? undefined : orderedCapSelection - } - const body: CreateServerRequest = { name: trimmedName } - for (const [k, v] of Object.entries(optionalFields)) { - if (v !== undefined) { - ;(body as Record<string, unknown>)[k] = v - } + caps: capabilitySelection.isDefault ? undefined : capabilitySelection.keys } - return body } const handleSubmit = (e?: FormEvent) => { @@ -717,6 +637,7 @@ export function AddServerDialog({ open, onClose }: { onClose: () => void; open: } const submitDisabled = mutation.isPending || !state.name.trim() + const replayOffer = issued?.outstanding_offer ?? null return ( <Dialog @@ -732,30 +653,65 @@ export function AddServerDialog({ open, onClose }: { onClose: () => void; open: <DialogTitle>{t('add_server.title')}</DialogTitle> </DialogHeader> - {issued ? ( + {issued?.enrollment && ( <AddServerIssuedView + enrollment={issued.enrollment} installCommand={installCommand} - issued={issued} onAnother={reset} onClose={handleClose} onCopy={copy} t={t} /> - ) : ( + )} + {!issued?.enrollment && issued?.replayed && ( + <> + <DialogBody className="space-y-4"> + <p className="text-muted-foreground text-sm"> + {t('add_server.replayed', { serverId: issued.server_id })} + </p> + {replayOffer ? ( + <div className="space-y-3 rounded-md border border-amber-500/40 bg-amber-500/5 p-4"> + <p className="text-amber-700 text-sm dark:text-amber-400">{t('add_server.replay_code_lost')}</p> + <Button + disabled={replaceReplayOfferMutation.isPending} + onClick={() => + replaceReplayOfferMutation.mutate({ + offerId: replayOffer.id, + serverId: issued.server_id + }) + } + type="button" + > + {t('add_server.replace_offer')} + </Button> + </div> + ) : ( + <p className="text-muted-foreground text-sm">{t('add_server.replay_no_offer')}</p> + )} + </DialogBody> + <DialogFooter> + <Button onClick={handleClose} type="button"> + {t('add_server.done')} + </Button> + </DialogFooter> + </> + )} + {!(issued?.enrollment || issued?.replayed) && ( <form className="flex min-h-0 flex-1 flex-col gap-4" onSubmit={handleSubmit}> <DialogBody className="space-y-4"> <p className="text-muted-foreground text-sm">{t('add_server.description')}</p> <AddServerBasicFields dispatch={dispatch} groups={groups} state={state} t={t} /> <AddServerBillingFields dispatch={dispatch} state={state} t={t} /> - <AddServerCapabilityFields - highRiskCaps={highRiskCaps} + <AgentCapabilityPicker + hintKey="add_server.caps_hint" + idPrefix="add-server-cap" + labelKey="add_server.caps_label" onReset={resetCapsToDefault} onSelectAll={selectAllCaps} onSelectNone={selectNoCaps} onToggle={toggleCap} - selectedCaps={state.selectedCaps} - standardCaps={standardCaps} + selected={state.selectedCaps} t={t} /> diff --git a/apps/web/src/components/server/agent-capability-picker.tsx b/apps/web/src/components/server/agent-capability-picker.tsx new file mode 100644 index 00000000..07e4c130 --- /dev/null +++ b/apps/web/src/components/server/agent-capability-picker.tsx @@ -0,0 +1,145 @@ +import type { TFunction } from 'i18next' +import { Checkbox } from '@/components/ui/checkbox' +import { CAP_DEFAULT, CAPABILITIES, hasCap } from '@/lib/capabilities' +import { cn } from '@/lib/utils' + +export const DEFAULT_AGENT_CAPABILITY_KEYS = CAPABILITIES.flatMap((capability) => + hasCap(CAP_DEFAULT, capability.bit) ? [capability.key] : [] +) +export const ALL_AGENT_CAPABILITY_KEYS = CAPABILITIES.map((capability) => capability.key) + +export function agentCapabilityKeysForMask(mask: number | null | undefined): Set<string> { + return new Set( + CAPABILITIES.flatMap((capability) => (hasCap(mask ?? CAP_DEFAULT, capability.bit) ? [capability.key] : [])) + ) +} + +export function resolveAgentCapabilitySelection(selected: ReadonlySet<string>): { + installArgument: string + isDefault: boolean + keys: string[] +} { + const keys = ALL_AGENT_CAPABILITY_KEYS.filter((key) => selected.has(key)) + const isDefault = + keys.length === DEFAULT_AGENT_CAPABILITY_KEYS.length && + DEFAULT_AGENT_CAPABILITY_KEYS.every((key) => selected.has(key)) + let installArgument = ` --caps ${keys.join(',')}` + if (isDefault) { + installArgument = '' + } else if (keys.length === 0) { + installArgument = " --caps ''" + } + return { installArgument, isDefault, keys } +} + +interface AgentCapabilityPickerProps { + hintKey: string + idPrefix: string + labelKey: string + onReset: () => void + onSelectAll: () => void + onSelectNone: () => void + onToggle: (key: string) => void + selected: Set<string> + t: TFunction +} + +export function AgentCapabilityPicker({ + hintKey, + idPrefix, + labelKey, + onReset, + onSelectAll, + onSelectNone, + onToggle, + selected, + t +}: AgentCapabilityPickerProps) { + return ( + <fieldset className="space-y-2"> + <legend className="mb-1 flex w-full items-center justify-between gap-2"> + <span className="font-medium text-muted-foreground text-xs uppercase tracking-wider">{t(labelKey)}</span> + <span className="flex gap-2 text-xs"> + <button className="text-muted-foreground hover:text-foreground" onClick={onReset} type="button"> + {t('add_server.caps_reset')} + </button> + <span className="text-muted-foreground/50">·</span> + <button className="text-muted-foreground hover:text-foreground" onClick={onSelectAll} type="button"> + {t('add_server.caps_select_all')} + </button> + <span className="text-muted-foreground/50">·</span> + <button className="text-muted-foreground hover:text-foreground" onClick={onSelectNone} type="button"> + {t('add_server.caps_select_none')} + </button> + </span> + </legend> + <p className="text-muted-foreground text-xs">{t(hintKey)}</p> + <div className="mt-2 space-y-3 rounded-md border bg-muted/30 p-3"> + <CapabilityGroup + caps={CAPABILITIES.filter((capability) => capability.risk !== 'high')} + idPrefix={idPrefix} + onToggle={onToggle} + selected={selected} + t={t} + title={t('add_server.caps_low_risk')} + tone="standard" + /> + <CapabilityGroup + caps={CAPABILITIES.filter((capability) => capability.risk === 'high')} + idPrefix={idPrefix} + onToggle={onToggle} + selected={selected} + t={t} + title={t('add_server.caps_high_risk')} + tone="high" + /> + </div> + </fieldset> + ) +} + +function CapabilityGroup({ + caps, + idPrefix, + onToggle, + selected, + t, + title, + tone +}: { + caps: readonly (typeof CAPABILITIES)[number][] + idPrefix: string + onToggle: (key: string) => void + selected: Set<string> + t: TFunction + title: string + tone: 'high' | 'standard' +}) { + return ( + <div> + <p + className={cn( + 'mb-1.5 font-medium text-[11px] uppercase tracking-wide', + tone === 'high' ? 'text-amber-600 dark:text-amber-500' : 'text-muted-foreground' + )} + > + {title} + </p> + <div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2"> + {caps.map((capability) => { + const id = `${idPrefix}-${capability.key}` + return ( + <label className="flex cursor-pointer items-center gap-2 text-sm" htmlFor={id} key={capability.key}> + <Checkbox + checked={selected.has(capability.key)} + id={id} + onCheckedChange={() => onToggle(capability.key)} + /> + <span className="truncate">{t(capability.labelKey)}</span> + </label> + ) + })} + </div> + </div> + ) +} diff --git a/apps/web/src/components/server/agent-reenrollment-dialog.test.tsx b/apps/web/src/components/server/agent-reenrollment-dialog.test.tsx new file mode 100644 index 00000000..b7c731f7 --- /dev/null +++ b/apps/web/src/components/server/agent-reenrollment-dialog.test.tsx @@ -0,0 +1,240 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import type { ComponentProps, ReactNode } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentAuthorityStateSummary } from '@/lib/api-schema' +import { CAP_DEFAULT } from '@/lib/capabilities' + +const mockPost = vi.fn() +const mockDelete = vi.fn() +const mockProjectServerCatalog = vi.hoisted(() => vi.fn()) +const mockQueryClient = {} + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }) +})) + +vi.mock('@tanstack/react-query', () => ({ + useMutation: ({ + mutationFn, + onError, + onSuccess + }: { + mutationFn: (...args: unknown[]) => Promise<unknown> + onError?: (error: unknown) => void + onSuccess?: (data: unknown, variables: unknown) => void + }) => ({ + isPending: false, + mutate: async (variables?: unknown) => { + try { + const result = await mutationFn(variables) + onSuccess?.(result, variables) + } catch (error) { + onError?.(error) + } + } + }), + useQueryClient: () => mockQueryClient +})) + +vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) + +vi.mock('@/lib/api-client', () => ({ + ApiError: class ApiError extends Error {}, + api: { + delete: (path: string) => mockDelete(path), + post: (path: string, body: unknown) => mockPost(path, body) + } +})) + +vi.mock('@/lib/server-catalog', () => ({ projectServerCatalog: mockProjectServerCatalog })) + +vi.mock('@/components/ui/button', () => ({ + Button: ({ children, variant: _variant, ...props }: ComponentProps<'button'> & { variant?: string }) => ( + <button {...props}>{children}</button> + ) +})) + +vi.mock('@/components/ui/checkbox', () => ({ + Checkbox: ({ + checked, + id, + onCheckedChange + }: { + checked?: boolean + id?: string + onCheckedChange?: (checked: boolean) => void + }) => ( + <input checked={checked} id={id} onChange={(event) => onCheckedChange?.(event.target.checked)} type="checkbox" /> + ) +})) + +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ children, open }: { children?: ReactNode; open?: boolean }) => (open ? <div>{children}</div> : null), + DialogBody: ({ children }: { children?: ReactNode }) => <div>{children}</div>, + DialogContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>, + DialogFooter: ({ children }: { children?: ReactNode }) => <div>{children}</div>, + DialogHeader: ({ children }: { children?: ReactNode }) => <div>{children}</div>, + DialogTitle: ({ children }: { children?: ReactNode }) => <h2>{children}</h2> +})) + +vi.mock('@/components/ui/alert-dialog', () => ({ + AlertDialog: ({ children, open }: { children?: ReactNode; open?: boolean }) => (open ? <div>{children}</div> : null), + AlertDialogAction: ({ children, variant: _variant, ...props }: ComponentProps<'button'> & { variant?: string }) => ( + <button {...props}>{children}</button> + ), + AlertDialogCancel: ({ children, ...props }: ComponentProps<'button'>) => <button {...props}>{children}</button>, + AlertDialogContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>, + AlertDialogDescription: ({ children }: { children?: ReactNode }) => <p>{children}</p>, + AlertDialogFooter: ({ children }: { children?: ReactNode }) => <div>{children}</div>, + AlertDialogHeader: ({ children }: { children?: ReactNode }) => <div>{children}</div>, + AlertDialogTitle: ({ children }: { children?: ReactNode }) => <h3>{children}</h3> +})) + +const { AgentReenrollmentDialog } = await import('./agent-reenrollment-dialog') + +function makeServer(agentAuthority?: AgentAuthorityStateSummary) { + const authority: AgentAuthorityStateSummary = agentAuthority ?? { outstanding_offer: null, status: 'claimed' } + return { + agent_authority: authority, + capabilities: CAP_DEFAULT, + id: 'srv-42', + name: 'tokyo-vps-01' + } +} + +function offerResponse(id: string) { + return { + enrollment: { + code: `plaintext-${id}`, + code_prefix: 'plain', + expires_at: '2030-01-01T00:00:00Z', + id + } + } +} + +describe('AgentReenrollmentDialog', () => { + beforeEach(() => { + mockPost.mockReset() + mockDelete.mockReset() + mockProjectServerCatalog.mockReset() + }) + + it('begins emergency re-enrollment by default', async () => { + mockPost.mockResolvedValueOnce(offerResponse('offer-2')) + render(<AgentReenrollmentDialog onOpenChange={vi.fn()} open server={makeServer()} />) + + fireEvent.click(screen.getByRole('button', { name: 'agent_reenrollment.generate' })) + + await waitFor(() => { + expect(mockPost).toHaveBeenCalledWith('/api/servers/srv-42/agent-authority/re-enrollment', { + mode: 'emergency' + }) + }) + expect(await screen.findByText('plaintext-offer-2')).toBeInTheDocument() + }) + + it('submits graceful mode when emergency mode is disabled', async () => { + mockPost.mockResolvedValueOnce(offerResponse('offer-3')) + render(<AgentReenrollmentDialog onOpenChange={vi.fn()} open server={makeServer()} />) + + fireEvent.click(screen.getByLabelText('agent_reenrollment.emergency_mode')) + fireEvent.click(screen.getByRole('button', { name: 'agent_reenrollment.generate' })) + + await waitFor(() => { + expect(mockPost).toHaveBeenCalledWith('/api/servers/srv-42/agent-authority/re-enrollment', { + mode: 'graceful' + }) + }) + }) + + it('replaces the exact visible outstanding offer', async () => { + mockPost.mockResolvedValueOnce(offerResponse('offer-next')) + render( + <AgentReenrollmentDialog + onOpenChange={vi.fn()} + open + server={makeServer({ + outstanding_offer: { + code_prefix: 'abc123', + created_at: '2025-01-01T00:00:00Z', + expires_at: '2099-01-01T00:00:00Z', + id: 'offer-current' + }, + status: 'claimed' + })} + /> + ) + + fireEvent.click(screen.getByRole('button', { name: 'agent_reenrollment.replace_offer' })) + + await waitFor(() => { + expect(mockPost).toHaveBeenCalledWith('/api/servers/srv-42/agent-authority/offers/offer-current/replace', {}) + }) + }) + + it('revokes the exact visible outstanding offer', async () => { + mockDelete.mockResolvedValueOnce({ already_revoked: false, offer_id: 'offer-current' }) + render( + <AgentReenrollmentDialog + onOpenChange={vi.fn()} + open + server={makeServer({ + outstanding_offer: { + code_prefix: 'abc123', + created_at: '2025-01-01T00:00:00Z', + expires_at: '2099-01-01T00:00:00Z', + id: 'offer-current' + }, + status: 'claimed' + })} + /> + ) + + fireEvent.click(screen.getByRole('button', { name: 'agent_reenrollment.revoke_offer' })) + + await waitFor(() => { + expect(mockDelete).toHaveBeenCalledWith('/api/servers/srv-42/agent-authority/offers/offer-current') + }) + }) + + it('renders an expired offer as terminal and removes its mutation actions', () => { + render( + <AgentReenrollmentDialog + onOpenChange={vi.fn()} + open + server={makeServer({ + outstanding_offer: { + code_prefix: 'expired', + created_at: '2025-01-01T00:00:00Z', + expires_at: '2025-01-01T00:10:00Z', + id: 'offer-expired' + }, + status: 'claimed' + })} + /> + ) + + expect(screen.getByText('agent_reenrollment.expired_notice_title')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'agent_reenrollment.replace_offer' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'agent_reenrollment.revoke_offer' })).not.toBeInTheDocument() + }) + + it('requires destructive confirmation before revoking Agent authority', async () => { + mockDelete.mockResolvedValueOnce({ changed: true, server_id: 'srv-42' }) + render(<AgentReenrollmentDialog onOpenChange={vi.fn()} open server={makeServer()} />) + + fireEvent.click(screen.getByRole('button', { name: 'agent_reenrollment.revoke_authority' })) + const revokeButtons = screen.getAllByRole('button', { name: 'agent_reenrollment.revoke_authority' }) + fireEvent.click(revokeButtons.at(-1) ?? revokeButtons[0]) + + await waitFor(() => { + expect(mockDelete).toHaveBeenCalledWith('/api/servers/srv-42/agent-authority') + }) + expect(mockProjectServerCatalog).toHaveBeenCalledWith(mockQueryClient, { + authority: { outstanding_offer: null, status: 'unclaimed' }, + kind: 'agent_authority_changed', + serverId: 'srv-42' + }) + }) +}) diff --git a/apps/web/src/components/server/agent-reenrollment-dialog.tsx b/apps/web/src/components/server/agent-reenrollment-dialog.tsx new file mode 100644 index 00000000..28ec2bd4 --- /dev/null +++ b/apps/web/src/components/server/agent-reenrollment-dialog.tsx @@ -0,0 +1,458 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { Copy, RefreshCw } from 'lucide-react' +import { type FormEvent, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import { + AgentCapabilityPicker, + ALL_AGENT_CAPABILITY_KEYS, + agentCapabilityKeysForMask, + DEFAULT_AGENT_CAPABILITY_KEYS, + resolveAgentCapabilitySelection +} from '@/components/server/agent-capability-picker' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle +} from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { ApiError, api } from '@/lib/api-client' +import type { + AgentAuthorityStateSummary, + EnrollmentOfferResponse, + OutstandingEnrollmentSummary, + ReenrollmentRequest, + RevokeAuthorityResponse, + RevokeOfferResponse, + ServerResponse +} from '@/lib/api-schema' +import { projectServerCatalog } from '@/lib/server-catalog' +import { cn } from '@/lib/utils' + +function formatCountdown(remainingMs: number): string { + const totalSeconds = Math.max(0, Math.floor(remainingMs / 1000)) + const minutes = Math.floor(totalSeconds / 60) + const seconds = totalSeconds % 60 + return `${minutes}m ${seconds.toString().padStart(2, '0')}s` +} + +interface OutstandingNoticeProps { + authorityStatus: AgentAuthorityStateSummary['status'] + enrollment: OutstandingEnrollmentSummary + onClose: () => void + onReplaced: (response: EnrollmentOfferResponse) => void + serverId: string +} + +function OutstandingNotice({ authorityStatus, enrollment, onClose, onReplaced, serverId }: OutstandingNoticeProps) { + const { t } = useTranslation(['servers', 'common']) + const queryClient = useQueryClient() + const expiresAt = new Date(enrollment.expires_at).getTime() + const [now, setNow] = useState(() => Date.now()) + + useEffect(() => { + if (expiresAt <= Date.now()) { + return + } + const id = window.setInterval(() => setNow(Date.now()), 1000) + return () => window.clearInterval(id) + }, [expiresAt]) + + const revokeMutation = useMutation({ + mutationFn: () => + api.delete<RevokeOfferResponse>(`/api/servers/${serverId}/agent-authority/offers/${enrollment.id}`), + onSuccess: () => { + toast.success(t('agent_reenrollment.offer_revoked')) + projectServerCatalog(queryClient, { + authority: { outstanding_offer: null, status: authorityStatus }, + kind: 'agent_authority_changed', + serverId + }) + }, + onError: (err: unknown) => { + const message = + err instanceof ApiError || err instanceof Error ? err.message : t('agent_reenrollment.revoke_offer_failed') + toast.error(message) + } + }) + + const replaceMutation = useMutation({ + mutationFn: () => + api.post<EnrollmentOfferResponse>(`/api/servers/${serverId}/agent-authority/offers/${enrollment.id}/replace`, {}), + onSuccess: (response) => { + onReplaced(response) + projectServerCatalog(queryClient, { + authority: { + outstanding_offer: { + id: response.enrollment.id, + code_prefix: response.enrollment.code_prefix, + created_at: new Date().toISOString(), + expires_at: response.enrollment.expires_at + }, + status: authorityStatus + }, + kind: 'agent_authority_changed', + serverId + }) + }, + onError: (err: unknown) => { + const message = + err instanceof ApiError || err instanceof Error ? err.message : t('agent_reenrollment.replace_failed') + toast.error(message) + } + }) + + const countdownLabel = + expiresAt > now + ? t('card_pending.code_expires_in', { + prefix: enrollment.code_prefix, + countdown: formatCountdown(expiresAt - now) + }) + : t('card_pending.code_expired', { prefix: enrollment.code_prefix }) + const isExpired = expiresAt <= now + + return ( + <> + <DialogBody className="space-y-4"> + <div className="space-y-3 rounded-md border border-amber-500/40 bg-amber-500/5 p-4"> + <p className="font-medium text-amber-700 text-sm dark:text-amber-400"> + {t(isExpired ? 'agent_reenrollment.expired_notice_title' : 'agent_reenrollment.outstanding_notice_title')} + </p> + <p className="font-mono text-amber-700 text-xs tabular-nums dark:text-amber-400">{enrollment.code_prefix}…</p> + <p className="text-amber-700 text-xs tabular-nums dark:text-amber-400">{countdownLabel}</p> + <p className="text-muted-foreground text-xs"> + {t(isExpired ? 'agent_reenrollment.expired_notice_body' : 'agent_reenrollment.outstanding_notice_body')} + </p> + </div> + </DialogBody> + <DialogFooter> + <Button onClick={onClose} type="button" variant="outline"> + {t('common:close', { defaultValue: 'Close' })} + </Button> + {!isExpired && ( + <> + <Button + disabled={revokeMutation.isPending} + onClick={() => revokeMutation.mutate()} + type="button" + variant="destructive" + > + <RefreshCw aria-hidden="true" className="size-3.5" /> + {t('agent_reenrollment.revoke_offer')} + </Button> + <Button disabled={replaceMutation.isPending} onClick={() => replaceMutation.mutate()} type="button"> + <RefreshCw aria-hidden="true" className="size-3.5" /> + {t('agent_reenrollment.replace_offer')} + </Button> + </> + )} + </DialogFooter> + </> + ) +} + +interface AgentReenrollmentDialogProps { + onOpenChange: (open: boolean) => void + open: boolean + server: Pick<ServerResponse, 'id' | 'name' | 'capabilities' | 'agent_authority'> +} + +function initialCapsFor(caps: number | null | undefined): Set<string> { + return agentCapabilityKeysForMask(caps) +} + +export function AgentReenrollmentDialog({ open, onOpenChange, server }: AgentReenrollmentDialogProps) { + return ( + <Dialog onOpenChange={onOpenChange} open={open}> + {open && <AgentReenrollmentDialogContent key={server.id} onOpenChange={onOpenChange} server={server} />} + </Dialog> + ) +} + +function AgentReenrollmentDialogContent({ + onOpenChange, + server +}: { + onOpenChange: (open: boolean) => void + server: Pick<ServerResponse, 'id' | 'name' | 'capabilities' | 'agent_authority'> +}) { + const { t } = useTranslation(['servers', 'common']) + const queryClient = useQueryClient() + + const [selectedCaps, setSelectedCaps] = useState<Set<string>>(() => initialCapsFor(server.capabilities)) + const [emergencyMode, setEmergencyMode] = useState(true) + const [issued, setIssued] = useState<EnrollmentOfferResponse | null>(null) + const [confirmRevokeOpen, setConfirmRevokeOpen] = useState(false) + + const mutation = useMutation({ + mutationFn: (body: ReenrollmentRequest) => + api.post<EnrollmentOfferResponse>(`/api/servers/${server.id}/agent-authority/re-enrollment`, body), + onSuccess: (data, variables) => { + setIssued(data) + const revoked = variables.mode === 'emergency' + const newOutstanding = { + id: data.enrollment.id, + code_prefix: data.enrollment.code_prefix, + expires_at: data.enrollment.expires_at, + created_at: new Date().toISOString() + } + projectServerCatalog(queryClient, { + authority: { + outstanding_offer: newOutstanding, + status: revoked ? 'unclaimed' : 'claimed' + }, + kind: 'agent_authority_changed', + serverId: server.id + }) + }, + onError: (err: unknown) => { + const message = + err instanceof ApiError || err instanceof Error ? err.message : t('agent_reenrollment.generate_failed') + toast.error(message) + } + }) + + const revokeAuthorityMutation = useMutation({ + mutationFn: () => api.delete<RevokeAuthorityResponse>(`/api/servers/${server.id}/agent-authority`), + onSuccess: () => { + projectServerCatalog(queryClient, { + authority: { + outstanding_offer: null, + status: 'unclaimed' + }, + kind: 'agent_authority_changed', + serverId: server.id + }) + setConfirmRevokeOpen(false) + toast.success(t('agent_reenrollment.authority_revoked')) + }, + onError: (err: unknown) => { + const message = + err instanceof ApiError || err instanceof Error ? err.message : t('agent_reenrollment.revoke_authority_failed') + toast.error(message) + } + }) + + const origin = typeof window !== 'undefined' ? window.location.origin : '' + const capabilitySelection = resolveAgentCapabilitySelection(selectedCaps) + const installCommand = issued + ? `curl -fsSL https://raw.githubusercontent.com/ZingerLittleBee/ServerBee/main/deploy/install.sh | sudo bash -s -- agent --server-url '${origin}' --enrollment-code '${issued.enrollment.code}'${capabilitySelection.installArgument}` + : '' + + const toggleCap = (key: string) => { + setSelectedCaps((prev) => { + const next = new Set(prev) + if (next.has(key)) { + next.delete(key) + } else { + next.add(key) + } + return next + }) + } + const resetCapsToDefault = () => setSelectedCaps(new Set(DEFAULT_AGENT_CAPABILITY_KEYS)) + const selectAllCaps = () => setSelectedCaps(new Set(ALL_AGENT_CAPABILITY_KEYS)) + const selectNoCaps = () => setSelectedCaps(new Set()) + + const copy = async (value: string) => { + try { + await navigator.clipboard.writeText(value) + toast.success(t('add_server.copied')) + } catch { + // Clipboard access denied; ignore. + } + } + + const reset = () => { + setIssued(null) + setEmergencyMode(true) + setSelectedCaps(initialCapsFor(server.capabilities)) + } + + const handleClose = () => { + onOpenChange(false) + } + + const handleSubmit = (e?: FormEvent) => { + e?.preventDefault() + mutation.mutate({ mode: emergencyMode ? 'emergency' : 'graceful' }) + } + + const outstanding = server.agent_authority.outstanding_offer ?? null + + return ( + <> + <DialogContent className="sm:max-w-lg"> + <DialogHeader> + <DialogTitle> + {t('agent_reenrollment.title')} · <span className="font-mono">{server.name}</span> + </DialogTitle> + </DialogHeader> + + {issued && ( + <> + <DialogBody className="space-y-5"> + <p className="text-muted-foreground text-sm">{t('agent_reenrollment.description')}</p> + + <div className="space-y-4 rounded-md border border-amber-500/40 bg-amber-500/5 p-4"> + <p className="text-amber-600 text-sm dark:text-amber-500">{t('add_server.shown_once_warning')}</p> + + <div> + <p className="mb-1 font-medium text-muted-foreground text-xs">{t('add_server.code_label')}</p> + <div className="flex min-w-0 items-center gap-2"> + <code className="min-w-0 flex-1 truncate rounded-md border bg-muted/50 px-3 py-2 font-mono text-sm"> + {issued.enrollment.code} + </code> + <Button + aria-label={t('add_server.copy')} + onClick={() => copy(issued.enrollment.code)} + size="icon" + type="button" + variant="outline" + > + <Copy className="size-4" /> + </Button> + </div> + </div> + + <div> + <p className="mb-1 font-medium text-muted-foreground text-xs">{t('add_server.install_command')}</p> + <div className="flex min-w-0 items-start gap-2"> + <code className="min-w-0 flex-1 break-all rounded-md border bg-muted/50 px-3 py-2 font-mono text-xs"> + {installCommand} + </code> + <Button + aria-label={t('add_server.copy')} + onClick={() => copy(installCommand)} + size="icon" + type="button" + variant="outline" + > + <Copy className="size-4" /> + </Button> + </div> + </div> + + <div> + <p className="mb-1 font-medium text-muted-foreground text-xs">{t('add_server.steps_title')}</p> + <ol className="list-decimal space-y-1 pl-5 text-muted-foreground text-sm"> + <li>{t('add_server.step1')}</li> + <li>{t('add_server.step2')}</li> + <li>{t('add_server.step3')}</li> + </ol> + </div> + </div> + </DialogBody> + + <DialogFooter> + <Button onClick={reset} type="button" variant="outline"> + {t('add_server.another')} + </Button> + <Button onClick={handleClose} type="button"> + {t('add_server.done')} + </Button> + </DialogFooter> + </> + )} + {!issued && outstanding && ( + <OutstandingNotice + authorityStatus={server.agent_authority.status} + enrollment={outstanding} + onClose={handleClose} + onReplaced={setIssued} + serverId={server.id} + /> + )} + {!(outstanding || issued) && ( + <form className="flex min-h-0 flex-1 flex-col gap-4" onSubmit={handleSubmit}> + <DialogBody className="space-y-4"> + <p className="text-muted-foreground text-sm">{t('agent_reenrollment.description')}</p> + + <AgentCapabilityPicker + hintKey="agent_reenrollment.caps_hint" + idPrefix="agent-reenrollment-cap" + labelKey="agent_reenrollment.caps_label" + onReset={resetCapsToDefault} + onSelectAll={selectAllCaps} + onSelectNone={selectNoCaps} + onToggle={toggleCap} + selected={selectedCaps} + t={t} + /> + + <fieldset className="space-y-2"> + <label + className="flex cursor-pointer items-center gap-2 text-sm" + htmlFor="agent-reenrollment-emergency" + > + <Checkbox + checked={emergencyMode} + id="agent-reenrollment-emergency" + onCheckedChange={(checked) => setEmergencyMode(Boolean(checked))} + /> + <span>{t('agent_reenrollment.emergency_mode')}</span> + </label> + {emergencyMode ? ( + <p className="pl-6 text-amber-600 text-xs dark:text-amber-500"> + {t('agent_reenrollment.emergency_description')} + </p> + ) : ( + <p className="pl-6 text-muted-foreground text-xs">{t('agent_reenrollment.graceful_description')}</p> + )} + </fieldset> + + <p className="text-muted-foreground text-xs">{t('agent_reenrollment.ttl_tip')}</p> + </DialogBody> + + <DialogFooter> + <Button onClick={handleClose} type="button" variant="outline"> + {t('common:cancel')} + </Button> + {server.agent_authority.status === 'claimed' && ( + <Button onClick={() => setConfirmRevokeOpen(true)} type="button" variant="destructive"> + {t('agent_reenrollment.revoke_authority')} + </Button> + )} + <Button + className={cn(mutation.isPending && 'pointer-events-none opacity-70')} + disabled={mutation.isPending} + type="submit" + > + <RefreshCw aria-hidden="true" className="size-4" /> + {mutation.isPending ? t('agent_reenrollment.generating') : t('agent_reenrollment.generate')} + </Button> + </DialogFooter> + </form> + )} + </DialogContent> + + <AlertDialog onOpenChange={setConfirmRevokeOpen} open={confirmRevokeOpen}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>{t('agent_reenrollment.revoke_authority_title')}</AlertDialogTitle> + <AlertDialogDescription> + {t('agent_reenrollment.revoke_authority_description', { name: server.name })} + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel disabled={revokeAuthorityMutation.isPending}>{t('common:cancel')}</AlertDialogCancel> + <AlertDialogAction + disabled={revokeAuthorityMutation.isPending} + onClick={() => revokeAuthorityMutation.mutate()} + variant="destructive" + > + {t('agent_reenrollment.revoke_authority')} + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </> + ) +} diff --git a/apps/web/src/components/server/regenerate-code-dialog.tsx b/apps/web/src/components/server/enrollment-offer-dialog.tsx similarity index 65% rename from apps/web/src/components/server/regenerate-code-dialog.tsx rename to apps/web/src/components/server/enrollment-offer-dialog.tsx index 0d240cac..aeada4b7 100644 --- a/apps/web/src/components/server/regenerate-code-dialog.tsx +++ b/apps/web/src/components/server/enrollment-offer-dialog.tsx @@ -6,71 +6,89 @@ import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { ApiError, api } from '@/lib/api-client' -import type { RegenerateCodeRequest, RegenerateCodeResponse } from '@/lib/api-schema' +import type { EnrollmentOfferResponse, OutstandingEnrollmentSummary } from '@/lib/api-schema' import { projectServerCatalog } from '@/lib/server-catalog' -interface RegenerateCodeDialogProps { +interface EnrollmentOfferDialogProps { onOpenChange: (open: boolean) => void open: boolean + outstandingOffer: OutstandingEnrollmentSummary | null serverId: string } -export function RegenerateCodeDialog({ open, onOpenChange, serverId }: RegenerateCodeDialogProps) { +export function EnrollmentOfferDialog({ open, onOpenChange, outstandingOffer, serverId }: EnrollmentOfferDialogProps) { return ( <Dialog onOpenChange={onOpenChange} open={open}> - {open && <RegenerateCodeDialogContent key={serverId} onOpenChange={onOpenChange} serverId={serverId} />} + {open && ( + <EnrollmentOfferDialogContent + key={`${serverId}:${outstandingOffer?.id ?? 'none'}`} + onOpenChange={onOpenChange} + outstandingOffer={outstandingOffer} + serverId={serverId} + /> + )} </Dialog> ) } -function RegenerateCodeDialogContent({ +function EnrollmentOfferDialogContent({ onOpenChange, + outstandingOffer, serverId }: { onOpenChange: (open: boolean) => void + outstandingOffer: OutstandingEnrollmentSummary | null serverId: string }) { const { t } = useTranslation(['servers', 'common']) const queryClient = useQueryClient() - const [issued, setIssued] = useState<RegenerateCodeResponse | null>(null) + const [issued, setIssued] = useState<EnrollmentOfferResponse | null>(null) const [errorMessage, setErrorMessage] = useState<string | null>(null) const autoFiredRef = useRef(false) const mutation = useMutation({ - mutationFn: (body: RegenerateCodeRequest) => - api.post<RegenerateCodeResponse>(`/api/servers/${serverId}/regenerate-code`, body), + mutationFn: () => + outstandingOffer + ? api.post<EnrollmentOfferResponse>( + `/api/servers/${serverId}/agent-authority/offers/${outstandingOffer.id}/replace`, + {} + ) + : api.post<EnrollmentOfferResponse>(`/api/servers/${serverId}/agent-authority/offers`, {}), onSuccess: (data) => { setIssued(data) setErrorMessage(null) - toast.success(t('servers:card_pending.regenerated')) + toast.success(t('servers:card_pending.offer_issued')) projectServerCatalog(queryClient, { - kind: 'enrollment_changed', - serverId, - outstandingEnrollment: { - id: data.enrollment.id, - code_prefix: data.enrollment.code_prefix, - expires_at: data.enrollment.expires_at, - created_at: new Date().toISOString() + authority: { + outstanding_offer: { + id: data.enrollment.id, + code_prefix: data.enrollment.code_prefix, + expires_at: data.enrollment.expires_at, + created_at: new Date().toISOString() + }, + status: 'unclaimed' }, - tokenRevoked: false + kind: 'agent_authority_changed', + serverId }) }, onError: (err: unknown) => { const message = - err instanceof ApiError || err instanceof Error ? err.message : t('servers:card_pending.regenerate_failed') + err instanceof ApiError || err instanceof Error ? err.message : t('servers:card_pending.offer_failed') setErrorMessage(message) - toast.error(t('servers:card_pending.regenerate_failed')) + toast.error(t('servers:card_pending.offer_failed')) } }) const mutateRef = useRef(mutation.mutate) mutateRef.current = mutation.mutate - // Auto-fire the regenerate request after this open-state content mounts. + // The menu click is the operator's explicit request. Issue or replace once + // after this open-state content mounts. useEffect(() => { if (!autoFiredRef.current) { autoFiredRef.current = true - mutateRef.current({}) + mutateRef.current() } }, []) @@ -85,16 +103,16 @@ function RegenerateCodeDialogContent({ const retry = () => { setErrorMessage(null) - mutation.mutate({}) + mutation.mutate() } return ( <DialogContent className="sm:max-w-md"> <DialogHeader> - <DialogTitle>{t('servers:card_pending.regenerate_title')}</DialogTitle> + <DialogTitle>{t('servers:card_pending.offer_title')}</DialogTitle> </DialogHeader> <DialogBody className="space-y-4"> - <p className="text-muted-foreground text-sm">{t('servers:card_pending.regenerate_description')}</p> + <p className="text-muted-foreground text-sm">{t('servers:card_pending.offer_description')}</p> {issued && ( <div className="space-y-3 rounded-md border border-amber-500/40 bg-amber-500/5 p-3"> @@ -121,7 +139,7 @@ function RegenerateCodeDialogContent({ <p>{errorMessage}</p> <Button disabled={mutation.isPending} onClick={retry} size="sm" type="button" variant="outline"> <RefreshCw aria-hidden="true" className="size-3.5" /> - {t('servers:card_pending.regenerate_code')} + {t('servers:card_pending.issue_offer')} </Button> </div> )} diff --git a/apps/web/src/components/server/pending-action-menu.tsx b/apps/web/src/components/server/pending-action-menu.tsx index c268d394..36d182b7 100644 --- a/apps/web/src/components/server/pending-action-menu.tsx +++ b/apps/web/src/components/server/pending-action-menu.tsx @@ -16,18 +16,20 @@ import { import { Button } from '@/components/ui/button' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { api } from '@/lib/api-client' +import type { OutstandingEnrollmentSummary } from '@/lib/api-schema' import { projectServerCatalog } from '@/lib/server-catalog' -import { RegenerateCodeDialog } from './regenerate-code-dialog' +import { EnrollmentOfferDialog } from './enrollment-offer-dialog' interface PendingActionMenuProps { + outstandingOffer: OutstandingEnrollmentSummary | null serverId: string serverName: string } -export function PendingActionMenu({ serverId, serverName }: PendingActionMenuProps) { +export function PendingActionMenu({ outstandingOffer, serverId, serverName }: PendingActionMenuProps) { const { t } = useTranslation(['servers', 'common']) const queryClient = useQueryClient() - const [regenerateOpen, setRegenerateOpen] = useState(false) + const [offerDialogOpen, setOfferDialogOpen] = useState(false) const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false) const deleteMutation = useMutation({ @@ -48,7 +50,7 @@ export function PendingActionMenu({ serverId, serverName }: PendingActionMenuPro <DropdownMenuTrigger render={ <Button - aria-label={`${t('servers:card_pending.regenerate_code')} / ${t('servers:card_pending.delete_server')}`} + aria-label={`${t('servers:card_pending.issue_offer')} / ${t('servers:card_pending.delete_server')}`} onClick={(e) => e.stopPropagation()} size="icon-sm" variant="ghost" @@ -61,11 +63,11 @@ export function PendingActionMenu({ serverId, serverName }: PendingActionMenuPro <DropdownMenuItem onClick={(e) => { e.stopPropagation() - setRegenerateOpen(true) + setOfferDialogOpen(true) }} > <RefreshCw aria-hidden="true" className="size-3.5" /> - {t('servers:card_pending.regenerate_code')} + {t('servers:card_pending.issue_offer')} </DropdownMenuItem> <DropdownMenuItem onClick={(e) => { @@ -79,7 +81,12 @@ export function PendingActionMenu({ serverId, serverName }: PendingActionMenuPro </DropdownMenuContent> </DropdownMenu> - <RegenerateCodeDialog onOpenChange={setRegenerateOpen} open={regenerateOpen} serverId={serverId} /> + <EnrollmentOfferDialog + onOpenChange={setOfferDialogOpen} + open={offerDialogOpen} + outstandingOffer={outstandingOffer} + serverId={serverId} + /> <AlertDialog onOpenChange={setConfirmDeleteOpen} open={confirmDeleteOpen}> <AlertDialogContent> diff --git a/apps/web/src/components/server/recover-agent-dialog.test.tsx b/apps/web/src/components/server/recover-agent-dialog.test.tsx deleted file mode 100644 index 9e2d2b66..00000000 --- a/apps/web/src/components/server/recover-agent-dialog.test.tsx +++ /dev/null @@ -1,254 +0,0 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import type { ReactNode } from 'react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { OutstandingEnrollmentSummary, ServerResponse } from '@/lib/api-schema' -import { CAP_DEFAULT } from '@/lib/capabilities' - -const mockPost = vi.fn() -const mockDelete = vi.fn() -const mockProjectServerCatalog = vi.hoisted(() => vi.fn()) -const mockQueryClient = {} - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: { defaultValue?: string }) => options?.defaultValue ?? key - }) -})) - -vi.mock('@tanstack/react-query', () => ({ - useMutation: ({ - mutationFn, - onSuccess, - onError - }: { - mutationFn: (...args: unknown[]) => Promise<unknown> - onError?: (err: unknown) => void - onSuccess?: (data: unknown, variables: unknown) => void - }) => ({ - error: null, - isPending: false, - mutate: async (vars: unknown) => { - try { - const result = await mutationFn(vars) - onSuccess?.(result, vars) - } catch (err) { - onError?.(err) - } - } - }), - useQueryClient: () => mockQueryClient -})) - -vi.mock('sonner', () => ({ - toast: { - error: vi.fn(), - success: vi.fn() - } -})) - -vi.mock('@/lib/api-client', () => ({ - ApiError: class ApiError extends Error { - status: number - code?: string - constructor(message: string, status: number, code?: string) { - super(message) - this.status = status - this.code = code - } - }, - api: { - get: vi.fn(), - post: (path: string, body: unknown) => mockPost(path, body), - delete: (path: string) => mockDelete(path) - } -})) - -vi.mock('@/lib/server-catalog', () => ({ - projectServerCatalog: mockProjectServerCatalog -})) - -vi.mock('@/components/ui/button', () => ({ - Button: ({ - children, - type, - ...props - }: { children?: ReactNode; type?: 'button' | 'submit' | 'reset' } & Record<string, unknown>) => ( - <button type={type ?? 'button'} {...props}> - {children} - </button> - ) -})) - -vi.mock('@/components/ui/checkbox', () => ({ - Checkbox: ({ - checked, - onCheckedChange, - id - }: { - checked?: boolean - id?: string - onCheckedChange?: (checked: boolean) => void - }) => ( - <input checked={checked} id={id} onChange={(event) => onCheckedChange?.(event.target.checked)} type="checkbox" /> - ) -})) - -const TITLE_HEADING_RE = /recover_agent\.title/ -const CODE_PREFIX_RE = /abc123/ - -vi.mock('@/components/ui/dialog', () => ({ - Dialog: ({ children, open }: { children?: ReactNode; open?: boolean }) => (open ? <div>{children}</div> : null), - DialogBody: ({ children, className }: { children?: ReactNode; className?: string }) => ( - <div className={className} data-slot="dialog-body"> - {children} - </div> - ), - DialogContent: ({ children, className }: { children?: ReactNode; className?: string }) => ( - <div className={className} data-testid="dialog-content"> - {children} - </div> - ), - DialogFooter: ({ children }: { children?: ReactNode }) => <div data-testid="dialog-footer">{children}</div>, - DialogHeader: ({ children }: { children?: ReactNode }) => <div>{children}</div>, - DialogTitle: ({ children }: { children?: ReactNode }) => <h2>{children}</h2> -})) - -const { RecoverAgentDialog } = await import('./recover-agent-dialog') - -function makeServer(overrides: Partial<ServerResponse> = {}): ServerResponse { - return { - id: 'srv-42', - name: 'tokyo-vps-01', - capabilities: CAP_DEFAULT, - has_token: true, - hidden: false, - features: [], - weight: 0, - protocol_version: 1, - created_at: '2025-01-01T00:00:00Z', - updated_at: '2025-01-01T00:00:00Z', - outstanding_enrollment: null, - ...overrides - } as ServerResponse -} - -describe('RecoverAgentDialog', () => { - beforeEach(() => { - mockPost.mockReset() - mockDelete.mockReset() - mockProjectServerCatalog.mockReset() - }) - - it('renders the server name in a read-only header (not as an input)', () => { - render(<RecoverAgentDialog onOpenChange={vi.fn()} open server={makeServer()} />) - expect(screen.getByRole('heading', { name: TITLE_HEADING_RE })).toBeInTheDocument() - // Server name displayed somewhere as read-only text - expect(screen.getByText('tokyo-vps-01')).toBeInTheDocument() - // No input for the name - expect(screen.queryByLabelText('recover_agent.server_name_label')).not.toBeInTheDocument() - }) - - it('shows the TTL tip but no TTL selector', () => { - render(<RecoverAgentDialog onOpenChange={vi.fn()} open server={makeServer()} />) - expect(screen.getByText('recover_agent.ttl_tip')).toBeInTheDocument() - }) - - it('defaults revoke_immediately to checked and shows the warning text', () => { - render(<RecoverAgentDialog onOpenChange={vi.fn()} open server={makeServer()} />) - const checkbox = screen.getByLabelText('recover_agent.revoke_immediately') as HTMLInputElement - expect(checkbox.checked).toBe(true) - expect(screen.getByText('recover_agent.revoke_warning')).toBeInTheDocument() - }) - - it('POSTs revoke_immediately=true by default and transitions to install-command view on 200', async () => { - mockPost.mockResolvedValueOnce({ - enrollment: { - id: 'enr-9', - code: 'plaintext-recover-code', - code_prefix: 'plaint', - expires_at: '2030-01-01T00:00:00Z' - } - }) - - render(<RecoverAgentDialog onOpenChange={vi.fn()} open server={makeServer()} />) - - const submit = screen.getByRole('button', { name: 'recover_agent.generate' }) - fireEvent.click(submit) - - await waitFor(() => expect(mockPost).toHaveBeenCalledTimes(1)) - const [path, body] = mockPost.mock.calls[0] - expect(path).toBe('/api/servers/srv-42/recover') - expect(body).toEqual({ revoke_immediately: true }) - - await waitFor(() => { - expect(screen.getByText('plaintext-recover-code')).toBeInTheDocument() - }) - expect(screen.getByText('add_server.shown_once_warning')).toBeInTheDocument() - expect(mockProjectServerCatalog).toHaveBeenCalledWith(mockQueryClient, { - kind: 'enrollment_changed', - serverId: 'srv-42', - outstandingEnrollment: expect.objectContaining({ id: 'enr-9', code_prefix: 'plaint' }), - tokenRevoked: true - }) - }) - - it('toggling revoke_immediately off changes the submitted body to false', async () => { - mockPost.mockResolvedValueOnce({ - enrollment: { - id: 'enr-10', - code: 'another-code', - code_prefix: 'anothe', - expires_at: '2030-01-01T00:00:00Z' - } - }) - - render(<RecoverAgentDialog onOpenChange={vi.fn()} open server={makeServer()} />) - const checkbox = screen.getByLabelText('recover_agent.revoke_immediately') as HTMLInputElement - fireEvent.click(checkbox) - expect(checkbox.checked).toBe(false) - - const submit = screen.getByRole('button', { name: 'recover_agent.generate' }) - fireEvent.click(submit) - - await waitFor(() => expect(mockPost).toHaveBeenCalledTimes(1)) - const [, body] = mockPost.mock.calls[0] - expect(body).toEqual({ revoke_immediately: false }) - }) - - it('renders the outstanding-enrollment notice + Revoke button when outstanding_enrollment is set', async () => { - mockDelete.mockResolvedValueOnce(undefined) - - const outstanding: OutstandingEnrollmentSummary = { - id: 'enr-out-1', - code_prefix: 'abc123', - created_at: '2025-01-01T00:00:00Z', - expires_at: '2099-01-01T00:00:00Z' - } - - render( - <RecoverAgentDialog onOpenChange={vi.fn()} open server={makeServer({ outstanding_enrollment: outstanding })} /> - ) - - // The form is NOT rendered: no Generate button, no revoke_immediately checkbox. - expect(screen.queryByRole('button', { name: 'recover_agent.generate' })).not.toBeInTheDocument() - expect(screen.queryByLabelText('recover_agent.revoke_immediately')).not.toBeInTheDocument() - - // Notice block is rendered with the prefix. - expect(screen.getByText('recover_agent.outstanding_notice_title')).toBeInTheDocument() - expect(screen.getByText(CODE_PREFIX_RE)).toBeInTheDocument() - - const revoke = screen.getByRole('button', { name: 'recover_agent.revoke' }) - fireEvent.click(revoke) - - await waitFor(() => expect(mockDelete).toHaveBeenCalledTimes(1)) - expect(mockDelete).toHaveBeenCalledWith('/api/agent/enrollments/enr-out-1') - await waitFor(() => { - expect(mockProjectServerCatalog).toHaveBeenCalledWith(mockQueryClient, { - kind: 'enrollment_changed', - serverId: 'srv-42', - outstandingEnrollment: null, - tokenRevoked: false - }) - }) - }) -}) diff --git a/apps/web/src/components/server/recover-agent-dialog.tsx b/apps/web/src/components/server/recover-agent-dialog.tsx deleted file mode 100644 index 37840ebe..00000000 --- a/apps/web/src/components/server/recover-agent-dialog.tsx +++ /dev/null @@ -1,412 +0,0 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query' -import { Copy, RefreshCw } from 'lucide-react' -import { type FormEvent, useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { toast } from 'sonner' -import { Button } from '@/components/ui/button' -import { Checkbox } from '@/components/ui/checkbox' -import { Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { ApiError, api } from '@/lib/api-client' -import type { OutstandingEnrollmentSummary, RecoverRequest, RecoverResponse, ServerResponse } from '@/lib/api-schema' -import { CAP_DEFAULT, CAPABILITIES, hasCap } from '@/lib/capabilities' -import { projectServerCatalog } from '@/lib/server-catalog' -import { cn } from '@/lib/utils' - -const DEFAULT_CAP_KEYS = CAPABILITIES.flatMap((c) => (hasCap(CAP_DEFAULT, c.bit) ? [c.key] : [])) -const ALL_CAP_KEYS = CAPABILITIES.map((c) => c.key) - -interface CapGroupProps { - caps: readonly (typeof CAPABILITIES)[number][] - onToggle: (key: string) => void - selected: Set<string> - t: (key: string) => string - title: string - tone: 'high' | 'standard' -} - -function CapGroup({ caps, onToggle, selected, t, title, tone }: CapGroupProps) { - return ( - <div> - <p - className={cn( - 'mb-1.5 font-medium text-[11px] uppercase tracking-wide', - tone === 'high' ? 'text-amber-600 dark:text-amber-500' : 'text-muted-foreground' - )} - > - {title} - </p> - <div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2"> - {caps.map((cap) => { - const id = `recover-agent-cap-${cap.key}` - return ( - <label className="flex cursor-pointer items-center gap-2 text-sm" htmlFor={id} key={cap.key}> - <Checkbox checked={selected.has(cap.key)} id={id} onCheckedChange={() => onToggle(cap.key)} /> - <span className="truncate">{t(cap.labelKey)}</span> - </label> - ) - })} - </div> - </div> - ) -} - -function formatCountdown(remainingMs: number): string { - const totalSeconds = Math.max(0, Math.floor(remainingMs / 1000)) - const minutes = Math.floor(totalSeconds / 60) - const seconds = totalSeconds % 60 - return `${minutes}m ${seconds.toString().padStart(2, '0')}s` -} - -interface OutstandingNoticeProps { - enrollment: OutstandingEnrollmentSummary - onClose: () => void - serverId: string -} - -function OutstandingNotice({ enrollment, onClose, serverId }: OutstandingNoticeProps) { - const { t } = useTranslation(['servers', 'common']) - const queryClient = useQueryClient() - const expiresAt = new Date(enrollment.expires_at).getTime() - const [now, setNow] = useState(() => Date.now()) - - useEffect(() => { - if (expiresAt <= Date.now()) { - return - } - const id = window.setInterval(() => setNow(Date.now()), 1000) - return () => window.clearInterval(id) - }, [expiresAt]) - - const revokeMutation = useMutation({ - mutationFn: () => api.delete<void>(`/api/agent/enrollments/${enrollment.id}`), - onSuccess: () => { - toast.success(t('recover_agent.revoked')) - projectServerCatalog(queryClient, { - kind: 'enrollment_changed', - serverId, - outstandingEnrollment: null, - tokenRevoked: false - }) - }, - onError: (err: unknown) => { - const message = err instanceof ApiError || err instanceof Error ? err.message : t('recover_agent.revoke_failed') - toast.error(message) - } - }) - - const countdownLabel = - expiresAt > now - ? t('card_pending.code_expires_in', { - prefix: enrollment.code_prefix, - countdown: formatCountdown(expiresAt - now) - }) - : t('card_pending.code_expired', { prefix: enrollment.code_prefix }) - - return ( - <> - <DialogBody className="space-y-4"> - <div className="space-y-3 rounded-md border border-amber-500/40 bg-amber-500/5 p-4"> - <p className="font-medium text-amber-700 text-sm dark:text-amber-400"> - {t('recover_agent.outstanding_notice_title')} - </p> - <p className="font-mono text-amber-700 text-xs tabular-nums dark:text-amber-400">{enrollment.code_prefix}…</p> - <p className="text-amber-700 text-xs tabular-nums dark:text-amber-400">{countdownLabel}</p> - <p className="text-muted-foreground text-xs">{t('recover_agent.outstanding_notice_body')}</p> - </div> - </DialogBody> - <DialogFooter> - <Button onClick={onClose} type="button" variant="outline"> - {t('common:close', { defaultValue: 'Close' })} - </Button> - <Button - disabled={revokeMutation.isPending} - onClick={() => revokeMutation.mutate()} - type="button" - variant="destructive" - > - <RefreshCw aria-hidden="true" className="size-3.5" /> - {t('recover_agent.revoke')} - </Button> - </DialogFooter> - </> - ) -} - -interface RecoverAgentDialogProps { - onOpenChange: (open: boolean) => void - open: boolean - server: Pick<ServerResponse, 'id' | 'name' | 'capabilities' | 'outstanding_enrollment'> -} - -function initialCapsFor(caps: number | null | undefined): Set<string> { - return new Set(CAPABILITIES.flatMap((c) => (hasCap(caps ?? CAP_DEFAULT, c.bit) ? [c.key] : []))) -} - -export function RecoverAgentDialog({ open, onOpenChange, server }: RecoverAgentDialogProps) { - return ( - <Dialog onOpenChange={onOpenChange} open={open}> - {open && <RecoverAgentDialogContent key={server.id} onOpenChange={onOpenChange} server={server} />} - </Dialog> - ) -} - -function RecoverAgentDialogContent({ - onOpenChange, - server -}: { - onOpenChange: (open: boolean) => void - server: Pick<ServerResponse, 'id' | 'name' | 'capabilities' | 'outstanding_enrollment'> -}) { - const { t } = useTranslation(['servers', 'common']) - const queryClient = useQueryClient() - - const [selectedCaps, setSelectedCaps] = useState<Set<string>>(() => initialCapsFor(server.capabilities)) - const [revokeImmediately, setRevokeImmediately] = useState(true) - const [issued, setIssued] = useState<RecoverResponse | null>(null) - - const mutation = useMutation({ - mutationFn: (body: RecoverRequest) => api.post<RecoverResponse>(`/api/servers/${server.id}/recover`, body), - onSuccess: (data, variables) => { - setIssued(data) - const revoked = variables.revoke_immediately - const newOutstanding = { - id: data.enrollment.id, - code_prefix: data.enrollment.code_prefix, - expires_at: data.enrollment.expires_at, - created_at: new Date().toISOString() - } - projectServerCatalog(queryClient, { - kind: 'enrollment_changed', - serverId: server.id, - outstandingEnrollment: newOutstanding, - tokenRevoked: revoked - }) - }, - onError: (err: unknown) => { - const message = err instanceof ApiError || err instanceof Error ? err.message : t('recover_agent.generate_failed') - toast.error(message) - } - }) - - const origin = typeof window !== 'undefined' ? window.location.origin : '' - const orderedCapSelection = ALL_CAP_KEYS.filter((k) => selectedCaps.has(k)) - const capsIsDefault = - orderedCapSelection.length === DEFAULT_CAP_KEYS.length && DEFAULT_CAP_KEYS.every((k) => selectedCaps.has(k)) - const capsArg = (() => { - if (capsIsDefault) { - return '' - } - if (orderedCapSelection.length === 0) { - return " --caps ''" - } - return ` --caps ${orderedCapSelection.join(',')}` - })() - const installCommand = issued - ? `curl -fsSL https://raw.githubusercontent.com/ZingerLittleBee/ServerBee/main/deploy/install.sh | sudo bash -s -- agent --server-url '${origin}' --enrollment-code '${issued.enrollment.code}'${capsArg}` - : '' - - const toggleCap = (key: string) => { - setSelectedCaps((prev) => { - const next = new Set(prev) - if (next.has(key)) { - next.delete(key) - } else { - next.add(key) - } - return next - }) - } - const resetCapsToDefault = () => setSelectedCaps(new Set(DEFAULT_CAP_KEYS)) - const selectAllCaps = () => setSelectedCaps(new Set(ALL_CAP_KEYS)) - const selectNoCaps = () => setSelectedCaps(new Set()) - - const highRiskCaps = CAPABILITIES.filter((c) => c.risk === 'high') - const standardCaps = CAPABILITIES.filter((c) => c.risk !== 'high') - - const copy = async (value: string) => { - try { - await navigator.clipboard.writeText(value) - toast.success(t('add_server.copied')) - } catch { - // Clipboard access denied; ignore. - } - } - - const reset = () => { - setIssued(null) - setRevokeImmediately(true) - setSelectedCaps(initialCapsFor(server.capabilities)) - } - - const handleClose = () => { - onOpenChange(false) - } - - const handleSubmit = (e?: FormEvent) => { - e?.preventDefault() - mutation.mutate({ revoke_immediately: revokeImmediately }) - } - - const outstanding = server.outstanding_enrollment ?? null - - return ( - <DialogContent className="sm:max-w-lg"> - <DialogHeader> - <DialogTitle> - {t('recover_agent.title')} · <span className="font-mono">{server.name}</span> - </DialogTitle> - </DialogHeader> - - {outstanding && <OutstandingNotice enrollment={outstanding} onClose={handleClose} serverId={server.id} />} - {!outstanding && issued && ( - <> - <DialogBody className="space-y-5"> - <p className="text-muted-foreground text-sm">{t('recover_agent.description')}</p> - - <div className="space-y-4 rounded-md border border-amber-500/40 bg-amber-500/5 p-4"> - <p className="text-amber-600 text-sm dark:text-amber-500">{t('add_server.shown_once_warning')}</p> - - <div> - <p className="mb-1 font-medium text-muted-foreground text-xs">{t('add_server.code_label')}</p> - <div className="flex min-w-0 items-center gap-2"> - <code className="min-w-0 flex-1 truncate rounded-md border bg-muted/50 px-3 py-2 font-mono text-sm"> - {issued.enrollment.code} - </code> - <Button - aria-label={t('add_server.copy')} - onClick={() => copy(issued.enrollment.code)} - size="icon" - type="button" - variant="outline" - > - <Copy className="size-4" /> - </Button> - </div> - </div> - - <div> - <p className="mb-1 font-medium text-muted-foreground text-xs">{t('add_server.install_command')}</p> - <div className="flex min-w-0 items-start gap-2"> - <code className="min-w-0 flex-1 break-all rounded-md border bg-muted/50 px-3 py-2 font-mono text-xs"> - {installCommand} - </code> - <Button - aria-label={t('add_server.copy')} - onClick={() => copy(installCommand)} - size="icon" - type="button" - variant="outline" - > - <Copy className="size-4" /> - </Button> - </div> - </div> - - <div> - <p className="mb-1 font-medium text-muted-foreground text-xs">{t('add_server.steps_title')}</p> - <ol className="list-decimal space-y-1 pl-5 text-muted-foreground text-sm"> - <li>{t('add_server.step1')}</li> - <li>{t('add_server.step2')}</li> - <li>{t('add_server.step3')}</li> - </ol> - </div> - </div> - </DialogBody> - - <DialogFooter> - <Button onClick={reset} type="button" variant="outline"> - {t('add_server.another')} - </Button> - <Button onClick={handleClose} type="button"> - {t('add_server.done')} - </Button> - </DialogFooter> - </> - )} - {!(outstanding || issued) && ( - <form className="flex min-h-0 flex-1 flex-col gap-4" onSubmit={handleSubmit}> - <DialogBody className="space-y-4"> - <p className="text-muted-foreground text-sm">{t('recover_agent.description')}</p> - - <fieldset className="space-y-2"> - <legend className="mb-1 flex w-full items-center justify-between gap-2"> - <span className="font-medium text-muted-foreground text-xs uppercase tracking-wider"> - {t('recover_agent.caps_label')} - </span> - <span className="flex gap-2 text-xs"> - <button - className="text-muted-foreground hover:text-foreground" - onClick={resetCapsToDefault} - type="button" - > - {t('add_server.caps_reset')} - </button> - <span className="text-muted-foreground/50">·</span> - <button className="text-muted-foreground hover:text-foreground" onClick={selectAllCaps} type="button"> - {t('add_server.caps_select_all')} - </button> - <span className="text-muted-foreground/50">·</span> - <button className="text-muted-foreground hover:text-foreground" onClick={selectNoCaps} type="button"> - {t('add_server.caps_select_none')} - </button> - </span> - </legend> - <p className="text-muted-foreground text-xs">{t('recover_agent.caps_hint')}</p> - <div className="mt-2 space-y-3 rounded-md border bg-muted/30 p-3"> - <CapGroup - caps={standardCaps} - onToggle={toggleCap} - selected={selectedCaps} - t={t} - title={t('add_server.caps_low_risk')} - tone="standard" - /> - <CapGroup - caps={highRiskCaps} - onToggle={toggleCap} - selected={selectedCaps} - t={t} - title={t('add_server.caps_high_risk')} - tone="high" - /> - </div> - </fieldset> - - <fieldset className="space-y-2"> - <label - className="flex cursor-pointer items-center gap-2 text-sm" - htmlFor="recover-agent-revoke-immediately" - > - <Checkbox - checked={revokeImmediately} - id="recover-agent-revoke-immediately" - onCheckedChange={(checked) => setRevokeImmediately(Boolean(checked))} - /> - <span>{t('recover_agent.revoke_immediately')}</span> - </label> - {revokeImmediately && ( - <p className="pl-6 text-amber-600 text-xs dark:text-amber-500">{t('recover_agent.revoke_warning')}</p> - )} - </fieldset> - - <p className="text-muted-foreground text-xs">{t('recover_agent.ttl_tip')}</p> - </DialogBody> - - <DialogFooter> - <Button onClick={handleClose} type="button" variant="outline"> - {t('common:cancel')} - </Button> - <Button - className={cn(mutation.isPending && 'pointer-events-none opacity-70')} - disabled={mutation.isPending} - type="submit" - > - <RefreshCw aria-hidden="true" className="size-4" /> - {mutation.isPending ? t('recover_agent.generating') : t('recover_agent.generate')} - </Button> - </DialogFooter> - </form> - )} - </DialogContent> - ) -} diff --git a/apps/web/src/components/server/server-card-action-menu.tsx b/apps/web/src/components/server/server-card-action-menu.tsx index b6760222..e0e5888f 100644 --- a/apps/web/src/components/server/server-card-action-menu.tsx +++ b/apps/web/src/components/server/server-card-action-menu.tsx @@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { CAP_DEFAULT } from '@/lib/capabilities' import type { ServerMetrics } from '@/lib/server-catalog' -import { RecoverAgentDialog } from './recover-agent-dialog' +import { AgentReenrollmentDialog } from './agent-reenrollment-dialog' import { ServerCardEditDialog } from './server-card-edit-dialog' interface ServerCardActionMenuProps { @@ -14,7 +14,7 @@ interface ServerCardActionMenuProps { export function ServerCardActionMenu({ server }: ServerCardActionMenuProps) { const { t } = useTranslation(['servers']) - const [recoverOpen, setRecoverOpen] = useState(false) + const [reenrollmentOpen, setReenrollmentOpen] = useState(false) const [editOpen, setEditOpen] = useState(false) return ( @@ -42,29 +42,30 @@ export function ServerCardActionMenu({ server }: ServerCardActionMenuProps) { <Pencil aria-hidden="true" className="size-3.5" /> {t('servers:detail_edit')} </DropdownMenuItem> - {!server.online && ( - <DropdownMenuItem - onClick={(e) => { - e.stopPropagation() - setRecoverOpen(true) - }} - > - <RotateCcw aria-hidden="true" className="size-3.5" /> - {t('servers:recover_agent.title')} - </DropdownMenuItem> - )} + <DropdownMenuItem + onClick={(e) => { + e.stopPropagation() + setReenrollmentOpen(true) + }} + > + <RotateCcw aria-hidden="true" className="size-3.5" /> + {t('servers:agent_reenrollment.title')} + </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> - {recoverOpen && ( - <RecoverAgentDialog - onOpenChange={setRecoverOpen} - open={recoverOpen} + {reenrollmentOpen && ( + <AgentReenrollmentDialog + onOpenChange={setReenrollmentOpen} + open={reenrollmentOpen} server={{ id: server.id, name: server.name, capabilities: server.capabilities ?? CAP_DEFAULT, - outstanding_enrollment: server.outstanding_enrollment ?? null + agent_authority: server.agent_authority ?? { + outstanding_offer: server.outstanding_enrollment ?? null, + status: server.has_token === false ? 'unclaimed' : 'claimed' + } }} /> )} diff --git a/apps/web/src/components/server/server-card.tsx b/apps/web/src/components/server/server-card.tsx index c6c6b149..3854dd86 100644 --- a/apps/web/src/components/server/server-card.tsx +++ b/apps/web/src/components/server/server-card.tsx @@ -171,7 +171,11 @@ const ServerCardInner = ({ tone as the one bright "needs attention" cue on a muted card. */} <StatusBadge className={isPending ? 'relative z-20' : undefined} status={status} /> {isPending ? ( - <PendingActionMenu serverId={server.id} serverName={server.name} /> + <PendingActionMenu + outstandingOffer={server.agent_authority?.outstanding_offer ?? null} + serverId={server.id} + serverName={server.name} + /> ) : ( <ServerCardActionMenu server={server} /> )} @@ -181,7 +185,7 @@ const ServerCardInner = ({ {isPending ? ( <div className="flex min-h-24 flex-1 flex-col items-center justify-center gap-1 rounded-md bg-muted/40 px-3 py-3 text-center"> <p className="font-medium text-foreground text-sm">{t('card_pending.waiting')}</p> - <PendingEnrollmentSummary enrollment={server.outstanding_enrollment} /> + <PendingEnrollmentSummary enrollment={server.agent_authority?.outstanding_offer} /> </div> ) : ( <> @@ -383,9 +387,9 @@ export const ServerCard = memo(ServerCardInner, (prev, next) => { a.mem_total === b.mem_total && a.disk_total === b.disk_total && a.swap_total === b.swap_total && - a.has_token === b.has_token && - a.outstanding_enrollment?.id === b.outstanding_enrollment?.id && - a.outstanding_enrollment?.expires_at === b.outstanding_enrollment?.expires_at && + a.agent_authority?.status === b.agent_authority?.status && + a.agent_authority?.outstanding_offer?.id === b.agent_authority?.outstanding_offer?.id && + a.agent_authority?.outstanding_offer?.expires_at === b.agent_authority?.outstanding_offer?.expires_at && tagsEqual(a.tags, b.tags) ) }) diff --git a/apps/web/src/components/server/server-edit-dialog.test.tsx b/apps/web/src/components/server/server-edit-dialog.test.tsx index 41f7d590..f5032fd9 100644 --- a/apps/web/src/components/server/server-edit-dialog.test.tsx +++ b/apps/web/src/components/server/server-edit-dialog.test.tsx @@ -107,6 +107,7 @@ vi.mock('@/components/ui/select', () => ({ const { ServerEditDialog } = await import('./server-edit-dialog') const server: ServerResponse = { + agent_authority: { outstanding_offer: null, status: 'claimed' }, billing_cycle: null, billing_start_day: null, capabilities: 56, diff --git a/apps/web/src/components/server/status-dot-utils.ts b/apps/web/src/components/server/status-dot-utils.ts index f2b70536..8d9a4401 100644 --- a/apps/web/src/components/server/status-dot-utils.ts +++ b/apps/web/src/components/server/status-dot-utils.ts @@ -1,7 +1,14 @@ +import type { AgentAuthorityStateSummary } from '@/lib/api-schema' + export type StatusKind = 'online' | 'offline' | 'pending' -export function deriveServerStatus(s: { has_token?: boolean; online: boolean }): StatusKind { - if (s.has_token === false) { +export function deriveServerStatus(s: { + agent_authority?: AgentAuthorityStateSummary + has_token?: boolean + online: boolean +}): StatusKind { + const unclaimed = s.agent_authority ? s.agent_authority.status === 'unclaimed' : s.has_token === false + if (unclaimed) { return 'pending' } return s.online ? 'online' : 'offline' diff --git a/apps/web/src/components/server/status-dot.test.tsx b/apps/web/src/components/server/status-dot.test.tsx index 2faa8d6a..0139f850 100644 --- a/apps/web/src/components/server/status-dot.test.tsx +++ b/apps/web/src/components/server/status-dot.test.tsx @@ -38,24 +38,32 @@ describe('StatusDot', () => { }) describe('deriveServerStatus', () => { - it('returns online when online and has_token is true', () => { - expect(deriveServerStatus({ online: true, has_token: true })).toBe('online') + it('returns online when authority is claimed and the agent is online', () => { + expect(deriveServerStatus({ agent_authority: { outstanding_offer: null, status: 'claimed' }, online: true })).toBe( + 'online' + ) }) - it('returns offline when not online and has_token is true', () => { - expect(deriveServerStatus({ online: false, has_token: true })).toBe('offline') + it('returns offline when authority is claimed and the agent is offline', () => { + expect(deriveServerStatus({ agent_authority: { outstanding_offer: null, status: 'claimed' }, online: false })).toBe( + 'offline' + ) }) - it('returns pending when has_token is false and offline', () => { - expect(deriveServerStatus({ online: false, has_token: false })).toBe('pending') + it('returns pending when authority is unclaimed', () => { + expect( + deriveServerStatus({ agent_authority: { outstanding_offer: null, status: 'unclaimed' }, online: false }) + ).toBe('pending') }) - it('returns pending when has_token is false even if online (has_token wins)', () => { - expect(deriveServerStatus({ online: true, has_token: false })).toBe('pending') + it('lets unclaimed authority win over a stale online fact', () => { + expect( + deriveServerStatus({ agent_authority: { outstanding_offer: null, status: 'unclaimed' }, online: true }) + ).toBe('pending') }) - it('treats undefined has_token as "has token, just old payload" (defensive default)', () => { - expect(deriveServerStatus({ online: true })).toBe('online') - expect(deriveServerStatus({ online: false })).toBe('offline') + it('falls back to has_token only for legacy payloads', () => { + expect(deriveServerStatus({ has_token: false, online: false })).toBe('pending') + expect(deriveServerStatus({ has_token: true, online: true })).toBe('online') }) }) diff --git a/apps/web/src/hooks/use-server-tags.test.tsx b/apps/web/src/hooks/use-server-tags.test.tsx index f029d955..bd13ee43 100644 --- a/apps/web/src/hooks/use-server-tags.test.tsx +++ b/apps/web/src/hooks/use-server-tags.test.tsx @@ -48,6 +48,7 @@ describe('useUpdateServerTags', () => { kind: 'rest_snapshot', servers: [ { + agent_authority: { outstanding_offer: null, status: 'claimed' }, capabilities: 0, created_at: '2026-01-01T00:00:00Z', features: [], diff --git a/apps/web/src/hooks/use-servers-ws.test.ts b/apps/web/src/hooks/use-servers-ws.test.ts index d7aa7e62..08e0b4c4 100644 --- a/apps/web/src/hooks/use-servers-ws.test.ts +++ b/apps/web/src/hooks/use-servers-ws.test.ts @@ -125,3 +125,34 @@ describe('handleWsMessage network probe updates', () => { expect(received).toHaveLength(0) }) }) + +describe('handleWsMessage Agent Authority updates', () => { + it('projects a canonical Agent Authority change into the live catalog', () => { + const queryClient = new QueryClient() + handleWsMessage( + { + type: 'full_sync', + servers: [{ id: 'server-1', name: 'edge-1', online: true }] + }, + queryClient + ) + + handleWsMessage( + { + type: 'agent_authority_changed', + server_id: 'server-1', + agent_authority: { outstanding_offer: null, status: 'unclaimed' } + }, + queryClient + ) + + const servers = queryClient.getQueryData< + Array<{ agent_authority?: { outstanding_offer: unknown; status: string }; has_token?: boolean; online: boolean }> + >(['server-catalog', 'live']) + expect(servers?.[0]).toMatchObject({ + agent_authority: { outstanding_offer: null, status: 'unclaimed' }, + has_token: false, + online: false + }) + }) +}) diff --git a/apps/web/src/hooks/use-servers-ws.ts b/apps/web/src/hooks/use-servers-ws.ts index 85f945fd..dedcdaea 100644 --- a/apps/web/src/hooks/use-servers-ws.ts +++ b/apps/web/src/hooks/use-servers-ws.ts @@ -23,6 +23,11 @@ type WsMessage = | { type: 'update'; servers: LiveMetrics[] } | { type: 'server_online'; server_id: string } | { type: 'server_offline'; server_id: string } + | { + type: 'agent_authority_changed' + server_id: string + agent_authority: NonNullable<ServerMetrics['agent_authority']> + } | { type: 'capabilities_changed' server_id: string @@ -127,6 +132,18 @@ function handleServerMetricsMessage(raw: { type: string } & Record<string, unkno const online = raw.type === 'server_online' const server_id = raw.server_id as string projectServerCatalog(queryClient, { kind: 'online_changed', serverId: server_id, online }) + return + } + if (raw.type === 'agent_authority_changed') { + if (typeof raw.server_id !== 'string' || typeof raw.agent_authority !== 'object' || raw.agent_authority === null) { + return + } + const msg = raw as WsMessage & { type: 'agent_authority_changed' } + projectServerCatalog(queryClient, { + authority: msg.agent_authority, + kind: 'agent_authority_changed', + serverId: msg.server_id + }) } } @@ -392,6 +409,7 @@ export function handleWsMessage(raw: unknown, queryClient: QueryClient): void { case 'update': case 'server_online': case 'server_offline': + case 'agent_authority_changed': handleServerMetricsMessage(raw, queryClient) break case 'capabilities_changed': diff --git a/apps/web/src/lib/api-schema.ts b/apps/web/src/lib/api-schema.ts index 692ecc4e..d30dc7b1 100644 --- a/apps/web/src/lib/api-schema.ts +++ b/apps/web/src/lib/api-schema.ts @@ -32,10 +32,13 @@ export type CreateServerRequest = S['CreateServerRequest'] export type CreateServerResponse = S['CreateServerResponse'] export type EnrollmentIssueResponse = S['EnrollmentIssueResponse'] export type OutstandingEnrollmentSummary = S['OutstandingEnrollmentSummary'] -export type RecoverRequest = S['RecoverRequest'] -export type RecoverResponse = S['RecoverResponse'] -export type RegenerateCodeRequest = S['RegenerateCodeRequest'] -export type RegenerateCodeResponse = S['RegenerateCodeResponse'] +export type AgentAuthorityStateSummary = S['AgentAuthorityStateSummary'] +export type AgentAuthorityStatus = S['AgentAuthorityStatus'] +export type ReenrollmentModeRequest = S['ReenrollmentModeRequest'] +export type ReenrollmentRequest = S['ReenrollmentRequest'] +export type EnrollmentOfferResponse = S['EnrollmentOfferResponse'] +export type RevokeOfferResponse = S['RevokeOfferResponse'] +export type RevokeAuthorityResponse = S['RevokeAuthorityResponse'] // Cost export type CostOverviewResponse = S['CostOverviewResponse'] @@ -121,8 +124,6 @@ export type SystemSettings = S['SystemSettings'] // Agent export type RegisterResponse = S['RegisterResponse'] export type UpgradeRequest = S['UpgradeRequest'] -export type EnrollmentSummary = S['EnrollmentSummary'] -export type RotateTokenResponse = S['RotateTokenResponse'] // Traffic (manually typed until OpenAPI types are regenerated) export interface TrafficResponse { @@ -153,8 +154,6 @@ export interface UptimeDailyEntry { total_minutes: number } -export type ThemeResolved = S['ThemeResolved'] - // --------------------------------------------------------------------------- // Public status page DTOs (singleton, mirror of `crates/server/src/service/public_status.rs`). // Field names match the Rust JSON shape exactly (snake_case); no IP-level diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index 0475d3a1..04e0df64 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -52,14 +52,14 @@ export interface paths { patch?: never; trace?: never; }; - "/api/agent/enrollments": { + "/api/agent-authority/events": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["list_enrollments"]; + get: operations["get_authority_history"]; put?: never; post?: never; delete?: never; @@ -68,22 +68,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/agent/enrollments/{id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post?: never; - delete: operations["delete_enrollment"]; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/agent/latest-version": { parameters: { query?: never; @@ -116,22 +100,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/agent/{id}/rotate-token": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["rotate_token"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/alert-events": { parameters: { query?: never; @@ -1523,46 +1491,94 @@ export interface paths { patch?: never; trace?: never; }; - "/api/servers/{id}/cost-insights": { + "/api/servers/{id}/agent-authority": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_server_cost_insights"]; + get: operations["get_agent_authority"]; put?: never; post?: never; + delete: operations["revoke_agent_authority"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/servers/{id}/agent-authority/offers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["issue_offer_for_unclaimed"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/servers/{id}/gpu-records": { + "/api/servers/{id}/agent-authority/offers/{offer_id}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_gpu_records"]; + get?: never; put?: never; post?: never; + delete: operations["revoke_offer"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/servers/{id}/agent-authority/offers/{offer_id}/replace": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["replace_offer"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/servers/{id}/records": { + "/api/servers/{id}/agent-authority/re-enrollment": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["get_server_records"]; + get?: never; + put?: never; + post: operations["begin_reenrollment"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/servers/{id}/cost-insights": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["get_server_cost_insights"]; put?: never; post?: never; delete?: never; @@ -1571,62 +1587,32 @@ export interface paths { patch?: never; trace?: never; }; - "/api/servers/{id}/recover": { + "/api/servers/{id}/gpu-records": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + get: operations["get_gpu_records"]; put?: never; - /** - * Mint a fresh bound enrollment for an already-enrolled server so the operator - * can reinstall the agent. The target server MUST already have a token - * (`token_hash IS NOT NULL`) — recover on a pending server is rejected with - * `400`, use `regenerate-code` for that path. - * @description Recover NEVER auto-supersedes an outstanding enrollment: if one is still - * active, this returns `409` and the operator is expected to either wait for - * it to expire or revoke it first. Only `regenerate-code` auto-supersedes. - * - * `revoke_immediately`: - * - `true` — clear `token_hash`/`token_prefix` inside the same transaction - * and kick the currently connected agent WS after commit. The server - * returns to pending until the new code is consumed. - * - `false` — the existing token stays valid; the new code only becomes - * active once the agent registers with it (`verify_and_consume_tx` then - * rotates the token via `mint_token_for_server`). - */ - post: operations["recover_server"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/servers/{id}/regenerate-code": { + "/api/servers/{id}/records": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + get: operations["get_server_records"]; put?: never; - /** - * Mint a fresh bound enrollment for a pending server, auto-superseding the - * previous outstanding enrollment (if any) inside one transaction. The target - * server MUST be pending (`token_hash IS NULL`); use `recover` for an already- - * enrolled server. - * @description Optimistic concurrency: callers pass `expected_enrollment_id` to guard - * against stomping on a concurrent operator's regenerated code. Semantics: - * - `Some(id) && matches current outstanding` → proceed (CAS pass) - * - `Some(id) && does NOT match` (including: there is no outstanding row, or - * the row referenced has been revoked/consumed) → 409 - * - `None && outstanding exists` → proceed (last-writer-wins) - * - `None && no outstanding` → proceed (fresh mint) - */ - post: operations["regenerate_code"]; + post?: never; delete?: never; options?: never; head?: never; @@ -1793,22 +1779,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/settings/active-theme": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["get_active_theme"]; - put: operations["put_active_theme"]; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/settings/backup": { parameters: { query?: never; @@ -1895,102 +1865,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/settings/themes": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["list_themes"]; - put?: never; - post: operations["create_theme"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/settings/themes/import": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["import_theme"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/settings/themes/{id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["get_theme"]; - put: operations["update_theme"]; - post?: never; - delete: operations["delete_theme"]; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/settings/themes/{id}/duplicate": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["duplicate_theme"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/settings/themes/{id}/export": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["export_theme"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/settings/themes/{id}/references": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["get_references"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/status": { parameters: { query?: never; @@ -2311,6 +2185,54 @@ export interface paths { patch?: never; trace?: never; }; + "/api/widget-modules": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["list_modules"]; + put?: never; + post: operations["install_widget_module"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/widget-modules/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["uninstall_module"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/widget-modules/{id}/{asset_path}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["serve_asset"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record<string, never>; export interface components { @@ -2318,10 +2240,12 @@ export interface components { AboutInfo: { version: string; }; - ActiveThemeResponse: { - ref: string; - theme: components["schemas"]["ThemeResolved"]; + AgentAuthorityStateSummary: { + outstanding_offer?: null | components["schemas"]["OutstandingEnrollmentSummary"]; + status: components["schemas"]["AgentAuthorityStatus"]; }; + /** @enum {string} */ + AgentAuthorityStatus: "claimed" | "unclaimed"; AlertEventDetailResponse: { alert_key: string; first_triggered_at: string; @@ -2427,6 +2351,7 @@ export interface components { }; ApiResponse_ServerCostInsights: { data: { + advisories: components["schemas"]["CostAdvisory"][]; billing_cycle?: string | null; configured: boolean; /** Format: double */ @@ -2457,7 +2382,6 @@ export interface components { price?: number | null; resource_value?: null | components["schemas"]["ResourceValue"]; server_id: string; - advisories: components["schemas"]["CostAdvisory"][]; }; }; ApiResponse_TrafficResponse: { @@ -2555,6 +2479,22 @@ export interface components { id: string; label: string; }; + AuthorityEventResponse: { + actor_id?: string | null; + actor_kind: string; + authority_after: string; + authority_before: string; + /** Format: date-time */ + created_at: string; + id: string; + mode?: string | null; + offer_id?: string | null; + offer_outcome?: string | null; + request_source: string; + server_id: string; + server_name: string; + transition: string; + }; BatchDeleteRequest: { ids: string[]; }; @@ -2594,7 +2534,14 @@ export interface components { /** Format: int64 */ deleted: number; }; - /** @enum {string} */ + /** + * @description A cost advisory: an objective, per-server warning surfaced alongside the cost + * breakdown. Unlike the former composite "value score", each advisory is a + * single actionable fact computed without any fleet comparison, so it is + * meaningful even for a single-server deployment. The variants are declared in + * display-priority order (most urgent first). + * @enum {string} + */ CostAdvisory: "expired_billing" | "sleeping_money" | "idle_burn" | "low_uptime"; /** @enum {string} */ CostInvalidReason: "missing_price" | "missing_billing_cycle" | "invalid_billing_cycle" | "invalid_price"; @@ -2704,6 +2651,7 @@ export interface components { expired_at?: string | null; group_id?: string | null; name: string; + onboarding_request_id: string; /** Format: double */ price?: number | null; public_remark?: string | null; @@ -2719,7 +2667,9 @@ export interface components { ttl_secs?: number | null; }; CreateServerResponse: { - enrollment: components["schemas"]["EnrollmentIssueResponse"]; + enrollment?: null | components["schemas"]["EnrollmentIssueResponse"]; + outstanding_offer?: null | components["schemas"]["OutstandingEnrollmentSummary"]; + replayed: boolean; server_id: string; }; CreateServiceMonitor: { @@ -2745,17 +2695,10 @@ export interface components { retry_interval?: number | null; server_ids: string[]; /** @description "oneshot" (default) or "scheduled" */ - task_type?: string; + task_type?: components["schemas"]["TaskType"]; /** Format: int32 */ timeout?: number | null; }; - CreateThemeInput: { - based_on?: string | null; - description?: string | null; - name: string; - vars_dark: components["schemas"]["HashMap"]; - vars_light: components["schemas"]["HashMap"]; - }; CreateUserInput: { password: string; role?: string; @@ -2816,32 +2759,17 @@ export interface components { expires_at: string; id: string; }; - EnrollmentSummary: { - code_prefix: string; - consumed_at?: string | null; - created_at: string; - created_by: string; - expires_at: string; - id: string; - revoked_at?: string | null; - target_server_id: string; + EnrollmentOfferResponse: { + enrollment: components["schemas"]["EnrollmentIssueResponse"]; }; ErrorBody: { error: components["schemas"]["ErrorDetail"]; }; ErrorDetail: { code: string; + details?: unknown; message: string; }; - ExportPayload: { - based_on?: string | null; - description?: string | null; - name: string; - vars_dark: components["schemas"]["HashMap"]; - vars_light: components["schemas"]["HashMap"]; - /** Format: int32 */ - version: number; - }; FileEntry: { file_type: components["schemas"]["FileType"]; group?: string | null; @@ -2885,9 +2813,6 @@ export interface components { /** Format: double */ utilization: number; }; - HashMap: { - [key: string]: string; - }; HourlyTraffic: { /** Format: int64 */ bytes_in: number; @@ -2944,6 +2869,10 @@ export interface components { /** Format: int32 */ risk_score?: number | null; }; + IssueOfferRequest: { + /** Format: int64 */ + ttl_secs?: number | null; + }; LatestAgentVersionResponse: { error?: string | null; /** Format: date-time */ @@ -3054,6 +2983,7 @@ export interface components { /** Format: int32 */ grid_y: number; id: string; + module_id?: string | null; /** Format: int32 */ sort_order: number; title?: string | null; @@ -3354,9 +3284,6 @@ export interface components { /** @description The APNs device token obtained from the iOS device. */ device_token: string; }; - PutActiveThemeInput: { - ref: string; - }; RateLimitEntryDto: { /** @description True if `count >= max` and the window is still open. */ blocked: boolean; @@ -3428,37 +3355,18 @@ export interface components { * @enum {string} */ RecordedProtocol: "icmp" | "udp" | "tcp" | "legacy"; - RecoverRequest: { - /** - * @description If `true`, clear the server's `token_hash`/`token_prefix` and kick the - * currently connected agent WebSocket as part of the same transaction. - * Use this when the operator suspects the existing agent token has been - * compromised. If `false`, the existing token remains valid and only a - * new bound enrollment is minted alongside it. - */ - revoke_immediately: boolean; - }; - RecoverResponse: { - enrollment: components["schemas"]["EnrollmentIssueResponse"]; - }; - RegenerateCodeRequest: { - /** - * @description Optimistic concurrency token. If `Some`, must match the current - * outstanding enrollment id exactly; otherwise the server returns 409. - * If `None`, last-writer-wins: any outstanding enrollment is revoked - * and a fresh one is minted. - */ - expected_enrollment_id?: string | null; - }; - RegenerateCodeResponse: { - enrollment: components["schemas"]["EnrollmentIssueResponse"]; + /** @enum {string} */ + ReenrollmentModeRequest: "graceful" | "emergency"; + ReenrollmentRequest: { + mode: components["schemas"]["ReenrollmentModeRequest"]; + /** Format: int64 */ + ttl_secs?: number | null; }; RegisterRequest: { - fingerprint?: string; + proposed_run_token: string; }; RegisterResponse: { server_id: string; - token: string; }; ResourceValue: { /** Format: double */ @@ -3471,13 +3379,13 @@ export interface components { cost_per_tb_traffic_limit?: number | null; traffic_limit_type?: string | null; }; - RotateTokenResponse: { + RevokeAuthorityResponse: { + changed: boolean; server_id: string; - /** - * @description New plaintext run token — shown once. The agent must be reconfigured - * with this value (or it will need to re-enroll). - */ - token: string; + }; + RevokeOfferResponse: { + already_revoked: boolean; + offer_id: string; }; SecurityEventDto: { created_at: string; @@ -3522,6 +3430,7 @@ export interface components { min_failed_count?: number | null; }; ServerCostInsights: { + advisories: components["schemas"]["CostAdvisory"][]; billing_cycle?: string | null; configured: boolean; /** Format: double */ @@ -3552,9 +3461,9 @@ export interface components { price?: number | null; resource_value?: null | components["schemas"]["ResourceValue"]; server_id: string; - advisories: components["schemas"]["CostAdvisory"][]; }; ServerCostOverview: { + advisories: components["schemas"]["CostAdvisory"][]; billing_cycle?: string | null; configured: boolean; /** Format: double */ @@ -3575,7 +3484,6 @@ export interface components { invalid_reason?: null | components["schemas"]["CostInvalidReason"]; name: string; server_id: string; - advisories: components["schemas"]["CostAdvisory"][]; }; ServerGroup: { /** Format: date-time */ @@ -3688,6 +3596,7 @@ export interface components { }; /** @description Server response DTO — excludes sensitive fields (token_hash, token_prefix). */ ServerResponse: { + agent_authority: components["schemas"]["AgentAuthorityStateSummary"]; /** Format: int32 */ agent_local_capabilities?: number | null; agent_version?: string | null; @@ -3743,6 +3652,11 @@ export interface components { remark?: string | null; /** Format: int64 */ swap_total?: number | null; + /** + * @description Currently-active temporary capability grants reported by the agent, used + * by the UI to render countdowns. Empty when the agent is offline or has no + * active grants. + */ temporary?: components["schemas"]["TemporaryGrantDto"][]; /** Format: int64 */ traffic_limit?: number | null; @@ -3918,58 +3832,20 @@ export interface components { started_at?: string | null; task_id: string; }; + /** @enum {string} */ + TaskType: "oneshot" | "scheduled"; + /** + * @description A capability that is temporarily enabled on the agent host until + * `expires_at`. Mirrors `serverbee_common::protocol::TemporaryGrant` but adds a + * `ToSchema` derive so the REST `ServerResponse` can advertise it; the UI uses + * it to render countdowns from a plain HTTP fetch. + */ TemporaryGrantDto: { cap: string; /** Format: int64 */ - granted_at: number; - /** Format: int64 */ expires_at: number; - }; - Theme: { - based_on?: string | null; - /** Format: date-time */ - created_at: string; - description?: string | null; - /** Format: int32 */ - id: number; - name: string; - /** Format: date-time */ - updated_at: string; - vars_dark: components["schemas"]["HashMap"]; - vars_light: components["schemas"]["HashMap"]; - }; - /** - * @description Where a custom theme is referenced. - * - * After R1 the public status page no longer carries its own `theme_ref` — - * only the admin UI's active theme can pin a custom theme. The previous - * `status_pages: Vec<StatusPageRef>` collection has therefore been removed. - */ - ThemeReferences: { - admin: boolean; - }; - ThemeResolved: { - id: string; - /** @enum {string} */ - kind: "preset"; - } | { - /** Format: int32 */ - id: number; - /** @enum {string} */ - kind: "custom"; - name: string; - /** Format: date-time */ - updated_at: string; - vars_dark: components["schemas"]["HashMap"]; - vars_light: components["schemas"]["HashMap"]; - }; - ThemeSummary: { - based_on?: string | null; - /** Format: int32 */ - id: number; - name: string; - /** Format: date-time */ - updated_at: string; + /** Format: int64 */ + granted_at: number; }; TotpDisableRequest: { password: string; @@ -4092,6 +3968,8 @@ export interface components { /** Format: int64 */ created_at_secs_ago: number; direction: string; + /** @description Human-readable failure reason when `status == "failed"`; omitted otherwise. */ + error?: string | null; file_path: string; /** Format: int64 */ file_size?: number | null; @@ -4212,9 +4090,10 @@ export interface components { /** Format: int32 */ billing_start_day?: number | null; /** - * @description Manual override for the GeoIP country flag. A 2-letter ISO code pins it - * and freezes it against auto-detection; explicit null clears the override - * and resumes GeoIP on the next agent report. Absent = unchanged. + * @description Manual override for the GeoIP country flag. `Some(Some("us"))` pins the + * 2-letter ISO code and freezes it against auto-detection; `Some(None)` + * (explicit JSON null) clears the override and resumes GeoIP on the next + * agent report. Absent = unchanged. */ country_code?: string | null; currency?: string | null; @@ -4309,13 +4188,6 @@ export interface components { /** Format: int32 */ timeout?: number | null; }; - UpdateThemeInput: { - based_on?: string | null; - description?: string | null; - name: string; - vars_dark: components["schemas"]["HashMap"]; - vars_light: components["schemas"]["HashMap"]; - }; UpdateUserInput: { password?: string | null; role?: string | null; @@ -4372,11 +4244,21 @@ export interface components { /** Format: int32 */ grid_y: number; id?: string | null; + module_id?: string | null; /** Format: int32 */ sort_order: number; title?: string | null; widget_type: string; }; + WidgetModuleListEntry: { + code_sha256: string; + enabled: boolean; + entry_path: string; + id: string; + manifest: Record<string, never>; + source_type: string; + version: string; + }; WriteRequest: { content: string; path: string; @@ -4454,44 +4336,26 @@ export interface operations { }; }; }; - list_enrollments: { + get_authority_history: { parameters: { - query?: never; + query: { + server_id: string; + limit?: number; + }; header?: never; path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description List enrollment codes */ + /** @description Agent authority event history */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["EnrollmentSummary"][]; - }; - }; - }; - }; - delete_enrollment: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Enrollment id */ - id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Deleted */ - 200: { - headers: { - [name: string]: unknown; + "application/json": components["schemas"]["AuthorityEventResponse"][]; }; - content?: never; }; }; }; @@ -4522,13 +4386,13 @@ export interface operations { path?: never; cookie?: never; }; - requestBody?: { + requestBody: { content: { "application/json": components["schemas"]["RegisterRequest"]; }; }; responses: { - /** @description Agent registered against the bound server */ + /** @description Agent claimed the bound Server authority */ 200: { headers: { [name: string]: unknown; @@ -4537,14 +4401,14 @@ export interface operations { "application/json": components["schemas"]["RegisterResponse"]; }; }; - /** @description Invalid fingerprint format */ + /** @description Missing or invalid Agent-proposed run token */ 400: { headers: { [name: string]: unknown; }; content?: never; }; - /** @description Invalid, expired, revoked, or already-used enrollment code */ + /** @description Enrollment claim rejected */ 401: { headers: { [name: string]: unknown; @@ -4553,43 +4417,6 @@ export interface operations { }; }; }; - rotate_token: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Server id */ - id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Token rotated; old token revoked */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["RotateTokenResponse"]; - }; - }; - /** @description Server is pending (no token to rotate); use recover instead */ - 400: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Server not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; list_alert_events: { parameters: { query?: { @@ -5081,6 +4908,13 @@ export interface operations { "application/json": components["schemas"]["ApiKeyResponse"]; }; }; + /** @description Admin role required */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Validation error */ 422: { headers: { @@ -8105,7 +7939,7 @@ export interface operations { }; }; }; - get_server_cost_insights: { + get_agent_authority: { parameters: { query?: never; header?: never; @@ -8117,13 +7951,13 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Server cost insights */ + /** @description Agent authority state */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ApiResponse_ServerCostInsights"]; + "application/json": components["schemas"]["AgentAuthorityStateSummary"]; }; }; /** @description Server not found */ @@ -8135,12 +7969,9 @@ export interface operations { }; }; }; - get_gpu_records: { + revoke_agent_authority: { parameters: { - query: { - from: string; - to: string; - }; + query?: never; header?: never; path: { /** @description Server ID */ @@ -8150,45 +7981,25 @@ export interface operations { }; requestBody?: never; responses: { - /** @description GPU metric records */ + /** @description Agent authority revoked */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["GpuRecord"][]; + "application/json": components["schemas"]["RevokeAuthorityResponse"]; }; }; - }; - }; - get_server_records: { - parameters: { - query: { - from: string; - to: string; - interval?: string; - }; - header?: never; - path: { - /** @description Server ID */ - id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Server metric records */ - 200: { + /** @description Server not found */ + 404: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["ServerRecord"][]; - }; + content?: never; }; }; }; - recover_server: { + issue_offer_for_unclaimed: { parameters: { query?: never; header?: never; @@ -8200,25 +8011,18 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["RecoverRequest"]; + "application/json": components["schemas"]["IssueOfferRequest"]; }; }; responses: { - /** @description Recover enrollment minted */ + /** @description Enrollment offer issued */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["RecoverResponse"]; - }; - }; - /** @description Server is pending (use regenerate-code instead) */ - 400: { - headers: { - [name: string]: unknown; + "application/json": components["schemas"]["EnrollmentOfferResponse"]; }; - content?: never; }; /** @description Server not found */ 404: { @@ -8227,7 +8031,7 @@ export interface operations { }; content?: never; }; - /** @description Outstanding enrollment exists; revoke it first */ + /** @description Authority or offer state conflict */ 409: { headers: { [name: string]: unknown; @@ -8236,46 +8040,37 @@ export interface operations { }; }; }; - regenerate_code: { + revoke_offer: { parameters: { query?: never; header?: never; path: { /** @description Server ID */ id: string; + /** @description Offer ID */ + offer_id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["RegenerateCodeRequest"]; - }; - }; + requestBody?: never; responses: { - /** @description Regenerate enrollment minted */ + /** @description Enrollment offer revoked */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["RegenerateCodeResponse"]; + "application/json": components["schemas"]["RevokeOfferResponse"]; }; }; - /** @description Server is not pending; use recover instead */ - 400: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Server not found */ + /** @description Server or offer not found */ 404: { headers: { [name: string]: unknown; }; content?: never; }; - /** @description expected_enrollment_id mismatch */ + /** @description Offer has another terminal outcome */ 409: { headers: { [name: string]: unknown; @@ -8284,28 +8079,46 @@ export interface operations { }; }; }; - list_traceroute_records: { + replace_offer: { parameters: { query?: never; header?: never; path: { + /** @description Server ID */ id: string; + /** @description Exact current offer ID */ + offer_id: string; }; cookie?: never; }; requestBody?: never; responses: { + /** @description Enrollment offer replaced */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TracerouteRecordSummary"][]; + "application/json": components["schemas"]["EnrollmentOfferResponse"]; + }; + }; + /** @description Server or offer not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Offer is stale or terminal */ + 409: { + headers: { + [name: string]: unknown; }; + content?: never; }; }; }; - trigger_traceroute: { + begin_reenrollment: { parameters: { query?: never; header?: never; @@ -8317,25 +8130,28 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["TriggerTracerouteRequest"]; + "application/json": components["schemas"]["ReenrollmentRequest"]; }; }; responses: { + /** @description Re-enrollment offer issued */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TriggerTracerouteResponse"]; + "application/json": components["schemas"]["EnrollmentOfferResponse"]; }; }; + /** @description Server not found */ 404: { headers: { [name: string]: unknown; }; content?: never; }; - 422: { + /** @description Authority or offer state conflict */ + 409: { headers: { [name: string]: unknown; }; @@ -8343,112 +8159,111 @@ export interface operations { }; }; }; - clear_traceroute_history: { + get_server_cost_insights: { parameters: { query?: never; header?: never; path: { + /** @description Server ID */ id: string; }; cookie?: never; }; requestBody?: never; responses: { + /** @description Server cost insights */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ClearedResponse"]; + "application/json": components["schemas"]["ApiResponse_ServerCostInsights"]; + }; + }; + /** @description Server not found */ + 404: { + headers: { + [name: string]: unknown; }; + content?: never; }; }; }; - get_traceroute_snapshot: { + get_gpu_records: { parameters: { - query?: never; + query: { + from: string; + to: string; + }; header?: never; path: { + /** @description Server ID */ id: string; - request_id: string; }; cookie?: never; }; requestBody?: never; responses: { + /** @description GPU metric records */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TracerouteSnapshotResponse"]; - }; - }; - 404: { - headers: { - [name: string]: unknown; + "application/json": components["schemas"]["GpuRecord"][]; }; - content?: never; }; }; }; - delete_traceroute_record: { + get_server_records: { parameters: { - query?: never; + query: { + from: string; + to: string; + interval?: string; + }; header?: never; path: { + /** @description Server ID */ id: string; - request_id: string; }; cookie?: never; }; requestBody?: never; responses: { - 204: { + /** @description Server metric records */ + 200: { headers: { [name: string]: unknown; }; - content?: never; - }; - 404: { - headers: { - [name: string]: unknown; + content: { + "application/json": components["schemas"]["ServerRecord"][]; }; - content?: never; }; }; }; - get_traffic: { + list_traceroute_records: { parameters: { query?: never; header?: never; path: { - /** @description Server ID */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Traffic statistics */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ApiResponse_TrafficResponse"]; - }; - }; - /** @description Server not found */ - 404: { - headers: { - [name: string]: unknown; + "application/json": components["schemas"]["TracerouteRecordSummary"][]; }; - content?: never; }; }; }; - trigger_upgrade: { + trigger_traceroute: { parameters: { query?: never; header?: never; @@ -8460,112 +8275,24 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["UpgradeRequest"]; - }; - }; - responses: { - /** @description Upgrade command sent to agent */ - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Server not found or not online */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - get_uptime_daily: { - parameters: { - query?: { - /** @description Number of days to include (default: 90, min: 1, max: 365). */ - days?: number | null; - }; - header?: never; - path: { - /** @description Server ID */ - server_id: string; + "application/json": components["schemas"]["TriggerTracerouteRequest"]; }; - cookie?: never; }; - requestBody?: never; responses: { - /** @description Daily uptime entries */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ApiResponse_Vec_UptimeDailyEntry"]; - }; - }; - /** @description Invalid days parameter */ - 400: { - headers: { - [name: string]: unknown; + "application/json": components["schemas"]["TriggerTracerouteResponse"]; }; - content?: never; }; - /** @description Server not found */ 404: { headers: { [name: string]: unknown; }; content?: never; }; - }; - }; - list_monitors: { - parameters: { - query?: { - /** @description Filter by monitor type (ssl, dns, http_keyword, tcp, whois). */ - type?: string | null; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description List all service monitors */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ServiceMonitor"][]; - }; - }; - }; - }; - create_service_monitor: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateServiceMonitor"]; - }; - }; - responses: { - /** @description Service monitor created */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ServiceMonitor"]; - }; - }; - /** @description Validation error */ 422: { headers: { [name: string]: unknown; @@ -8574,62 +8301,47 @@ export interface operations { }; }; }; - get_service_monitor: { + clear_traceroute_history: { parameters: { query?: never; header?: never; path: { - /** @description Service monitor ID */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Service monitor with latest record */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["MonitorWithRecord"]; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; + "application/json": components["schemas"]["ClearedResponse"]; }; - content?: never; }; }; }; - update_service_monitor: { + get_traceroute_snapshot: { parameters: { query?: never; header?: never; path: { - /** @description Service monitor ID */ id: string; + request_id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdateServiceMonitor"]; - }; - }; + requestBody?: never; responses: { - /** @description Service monitor updated */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ServiceMonitor"]; + "application/json": components["schemas"]["TracerouteSnapshotResponse"]; }; }; - /** @description Not found */ 404: { headers: { [name: string]: unknown; @@ -8638,26 +8350,24 @@ export interface operations { }; }; }; - delete_service_monitor: { + delete_traceroute_record: { parameters: { query?: never; header?: never; path: { - /** @description Service monitor ID */ id: string; + request_id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Service monitor deleted */ - 200: { + 204: { headers: { [name: string]: unknown; }; content?: never; }; - /** @description Not found */ 404: { headers: { [name: string]: unknown; @@ -8666,28 +8376,28 @@ export interface operations { }; }; }; - trigger_service_monitor_check: { + get_traffic: { parameters: { query?: never; header?: never; path: { - /** @description Service monitor ID */ + /** @description Server ID */ id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Check result */ + /** @description Traffic statistics */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ServiceMonitorRecord"]; + "application/json": components["schemas"]["ApiResponse_TrafficResponse"]; }; }; - /** @description Not found */ + /** @description Server not found */ 404: { headers: { [name: string]: unknown; @@ -8696,100 +8406,31 @@ export interface operations { }; }; }; - get_service_monitor_records: { + trigger_upgrade: { parameters: { - query?: { - /** @description Start of time range (inclusive). */ - from?: string | null; - /** @description End of time range (inclusive). */ - to?: string | null; - /** @description Maximum number of records to return. */ - limit?: number | null; - }; + query?: never; header?: never; path: { - /** @description Service monitor ID */ + /** @description Server ID */ id: string; }; cookie?: never; }; - requestBody?: never; - responses: { - /** @description Service monitor records */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ServiceMonitorRecord"][]; - }; - }; - }; - }; - get_settings: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description System settings */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["SystemSettings"]; - }; - }; - }; - }; - update_settings: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; requestBody: { content: { - "application/json": components["schemas"]["SystemSettings"]; - }; - }; - responses: { - /** @description Settings updated */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["SystemSettings"]; - }; + "application/json": components["schemas"]["UpgradeRequest"]; }; }; - }; - get_active_theme: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; responses: { - /** @description Resolved active admin theme */ + /** @description Upgrade command sent to agent */ 200: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["ActiveThemeResponse"]; - }; + content?: never; }; - /** @description Unauthenticated */ - 401: { + /** @description Server not found or not online */ + 404: { headers: { [name: string]: unknown; }; @@ -8797,44 +8438,39 @@ export interface operations { }; }; }; - put_active_theme: { + get_uptime_daily: { parameters: { - query?: never; + query?: { + /** @description Number of days to include (default: 90, min: 1, max: 365). */ + days?: number | null; + }; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["PutActiveThemeInput"]; + path: { + /** @description Server ID */ + server_id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Resolved active admin theme */ + /** @description Daily uptime entries */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ActiveThemeResponse"]; + "application/json": components["schemas"]["ApiResponse_Vec_UptimeDailyEntry"]; }; }; - /** @description Unauthenticated */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Forbidden (non-admin) */ - 403: { + /** @description Invalid days parameter */ + 400: { headers: { [name: string]: unknown; }; content?: never; }; - /** @description Validation error or custom themes disabled */ - 422: { + /** @description Server not found */ + 404: { headers: { [name: string]: unknown; }; @@ -8842,45 +8478,30 @@ export interface operations { }; }; }; - create_backup: { + list_monitors: { parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Database backup file (SQLite) */ - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; + query?: { + /** @description Filter by monitor type (ssl, dns, http_keyword, tcp, whois). */ + type?: string | null; }; - }; - }; - get_brand_config: { - parameters: { - query?: never; header?: never; path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Brand config */ + /** @description List all service monitors */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["BrandConfig"]; + "application/json": components["schemas"]["ServiceMonitor"][]; }; }; }; }; - update_brand_config: { + create_service_monitor: { parameters: { query?: never; header?: never; @@ -8889,17 +8510,17 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["BrandConfig"]; + "application/json": components["schemas"]["CreateServiceMonitor"]; }; }; responses: { - /** @description Brand config updated */ + /** @description Service monitor created */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["BrandConfig"]; + "application/json": components["schemas"]["ServiceMonitor"]; }; }; /** @description Validation error */ @@ -8911,37 +8532,29 @@ export interface operations { }; }; }; - upload_favicon: { + get_service_monitor: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "multipart/form-data": string; + path: { + /** @description Service monitor ID */ + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Favicon uploaded */ + /** @description Service monitor with latest record */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["UploadResponse"]; - }; - }; - /** @description Invalid file */ - 400: { - headers: { - [name: string]: unknown; + "application/json": components["schemas"]["MonitorWithRecord"]; }; - content?: never; }; - /** @description Validation error */ - 422: { + /** @description Not found */ + 404: { headers: { [name: string]: unknown; }; @@ -8949,37 +8562,33 @@ export interface operations { }; }; }; - upload_logo: { + update_service_monitor: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description Service monitor ID */ + id: string; + }; cookie?: never; }; requestBody: { content: { - "multipart/form-data": string; + "application/json": components["schemas"]["UpdateServiceMonitor"]; }; }; responses: { - /** @description Logo uploaded */ + /** @description Service monitor updated */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["UploadResponse"]; - }; - }; - /** @description Invalid file */ - 400: { - headers: { - [name: string]: unknown; + "application/json": components["schemas"]["ServiceMonitor"]; }; - content?: never; }; - /** @description Validation error */ - 422: { + /** @description Not found */ + 404: { headers: { [name: string]: unknown; }; @@ -8987,101 +8596,27 @@ export interface operations { }; }; }; - restore_backup: { + delete_service_monitor: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - /** @description SQLite backup file */ - requestBody: { - content: { - "application/octet-stream": string; - }; - }; - responses: { - /** @description Database restored, restart required */ - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Invalid backup file */ - 400: { - headers: { - [name: string]: unknown; - }; - content?: never; + path: { + /** @description Service monitor ID */ + id: string; }; - }; - }; - list_themes: { - parameters: { - query?: never; - header?: never; - path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description List custom themes */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ThemeSummary"][]; - }; - }; - /** @description Unauthenticated */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - create_theme: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateThemeInput"]; - }; - }; - responses: { - /** @description Custom theme created */ + /** @description Service monitor deleted */ 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Theme"]; - }; - }; - /** @description Unauthenticated */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Forbidden (non-admin) */ - 403: { headers: { [name: string]: unknown; }; content?: never; }; - /** @description Validation error or custom themes disabled */ - 422: { + /** @description Not found */ + 404: { headers: { [name: string]: unknown; }; @@ -9089,44 +8624,36 @@ export interface operations { }; }; }; - import_theme: { + trigger_service_monitor_check: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["ExportPayload"]; + path: { + /** @description Service monitor ID */ + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Custom theme imported */ + /** @description Check result */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Theme"]; - }; - }; - /** @description Unauthenticated */ - 401: { - headers: { - [name: string]: unknown; + "application/json": components["schemas"]["ServiceMonitorRecord"]; }; - content?: never; }; - /** @description Forbidden (non-admin) */ - 403: { + /** @description Not found */ + 404: { headers: { [name: string]: unknown; }; content?: never; }; - /** @description Validation error or custom themes disabled */ - 422: { + /** @description A check for this monitor is already running */ + 409: { headers: { [name: string]: unknown; }; @@ -9134,147 +8661,91 @@ export interface operations { }; }; }; - get_theme: { + get_service_monitor_records: { parameters: { - query?: never; + query?: { + /** @description Start of time range (inclusive). */ + from?: string | null; + /** @description End of time range (inclusive). */ + to?: string | null; + /** @description Maximum number of records to return. */ + limit?: number | null; + }; header?: never; path: { - /** @description Theme ID */ - id: number; + /** @description Service monitor ID */ + id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Custom theme */ + /** @description Service monitor records */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Theme"]; - }; - }; - /** @description Unauthenticated */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Theme not found */ - 404: { - headers: { - [name: string]: unknown; + "application/json": components["schemas"]["ServiceMonitorRecord"][]; }; - content?: never; }; }; }; - update_theme: { + get_settings: { parameters: { query?: never; header?: never; - path: { - /** @description Theme ID */ - id: number; - }; + path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdateThemeInput"]; - }; - }; + requestBody?: never; responses: { - /** @description Custom theme updated */ + /** @description System settings */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Theme"]; - }; - }; - /** @description Unauthenticated */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Forbidden (non-admin) */ - 403: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Theme not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Validation error or custom themes disabled */ - 422: { - headers: { - [name: string]: unknown; + "application/json": components["schemas"]["SystemSettings"]; }; - content?: never; }; }; }; - delete_theme: { + update_settings: { parameters: { query?: never; header?: never; - path: { - /** @description Theme ID */ - id: number; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["SystemSettings"]; + }; + }; responses: { - /** @description Custom theme deleted */ + /** @description Settings updated */ 200: { headers: { [name: string]: unknown; }; - content?: never; - }; - /** @description Unauthenticated */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Forbidden (non-admin) */ - 403: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Theme not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Theme is referenced */ - 409: { - headers: { - [name: string]: unknown; + content: { + "application/json": components["schemas"]["SystemSettings"]; }; - content?: never; }; - /** @description Custom themes disabled */ - 422: { + }; + }; + create_backup: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Database backup file (SQLite) */ + 200: { headers: { [name: string]: unknown; }; @@ -9282,49 +8753,49 @@ export interface operations { }; }; }; - duplicate_theme: { + get_brand_config: { parameters: { query?: never; header?: never; - path: { - /** @description Theme ID */ - id: number; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Custom theme duplicated */ + /** @description Brand config */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["Theme"]; + "application/json": components["schemas"]["BrandConfig"]; }; }; - /** @description Unauthenticated */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; + }; + }; + update_brand_config: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BrandConfig"]; }; - /** @description Forbidden (non-admin) */ - 403: { + }; + responses: { + /** @description Brand config updated */ + 200: { headers: { [name: string]: unknown; }; - content?: never; - }; - /** @description Theme not found */ - 404: { - headers: { - [name: string]: unknown; + content: { + "application/json": components["schemas"]["BrandConfig"]; }; - content?: never; }; - /** @description Validation error or custom themes disabled */ + /** @description Validation error */ 422: { headers: { [name: string]: unknown; @@ -9333,36 +8804,37 @@ export interface operations { }; }; }; - export_theme: { + upload_favicon: { parameters: { query?: never; header?: never; - path: { - /** @description Theme ID */ - id: number; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "multipart/form-data": string; + }; + }; responses: { - /** @description Exportable custom theme payload */ + /** @description Favicon uploaded */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ExportPayload"]; + "application/json": components["schemas"]["UploadResponse"]; }; }; - /** @description Unauthenticated */ - 401: { + /** @description Invalid file */ + 400: { headers: { [name: string]: unknown; }; content?: never; }; - /** @description Theme not found */ - 404: { + /** @description Validation error */ + 422: { headers: { [name: string]: unknown; }; @@ -9370,50 +8842,67 @@ export interface operations { }; }; }; - get_references: { + upload_logo: { parameters: { query?: never; header?: never; - path: { - /** @description Theme ID */ - id: number; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "multipart/form-data": string; + }; + }; responses: { - /** @description Theme references */ + /** @description Logo uploaded */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ThemeReferences"]; + "application/json": components["schemas"]["UploadResponse"]; }; }; - /** @description Unauthenticated */ - 401: { + /** @description Invalid file */ + 400: { headers: { [name: string]: unknown; }; content?: never; }; - /** @description Forbidden (non-admin) */ - 403: { + /** @description Validation error */ + 422: { headers: { [name: string]: unknown; }; content?: never; }; - /** @description Theme not found */ - 404: { + }; + }; + restore_backup: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description SQLite backup file */ + requestBody: { + content: { + "application/octet-stream": string; + }; + }; + responses: { + /** @description Database restored, restart required */ + 200: { headers: { [name: string]: unknown; }; content?: never; }; - /** @description Invalid theme ID */ - 422: { + /** @description Invalid backup file */ + 400: { headers: { [name: string]: unknown; }; @@ -9813,6 +9302,13 @@ export interface operations { }; content?: never; }; + /** @description Invalid time range (`from` after `to`) */ + 422: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Rate limit exceeded */ 429: { headers: { @@ -10325,4 +9821,129 @@ export interface operations { }; }; }; + list_modules: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List installed widget modules */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WidgetModuleListEntry"][]; + }; + }; + }; + }; + install_widget_module: { + parameters: { + query?: { + /** @description HTTPS URL to fetch the widget bundle from. Accepts either a single `.js` file or a `.zip` collection bundle. */ + url?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Alternatively, upload the widget bundle in a `file` field. Accepts either a single `.js` file or a `.zip` collection bundle. */ + requestBody?: { + content: { + "multipart/form-data": unknown; + }; + }; + responses: { + /** @description Installed (or upgraded) widget module(s). For a single `.js` file the response is `{ data: { id, version } }`. For a `.zip` collection it is `{ data: [{ id, version }, ...] }` — one entry per widget in the collection. */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad URL, unsupported source, or invalid manifest */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Module id conflicts with an existing install of a different source type */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + uninstall_module: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Module ID */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Module uninstalled */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Cannot uninstall builtin module */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Module not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + serve_asset: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Module ID */ + id: string; + /** @description Asset path within the package */ + asset_path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Asset bytes */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Module or asset not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; } diff --git a/apps/web/src/lib/server-catalog.test.ts b/apps/web/src/lib/server-catalog.test.ts index d453b1f9..8d06f612 100644 --- a/apps/web/src/lib/server-catalog.test.ts +++ b/apps/web/src/lib/server-catalog.test.ts @@ -73,6 +73,7 @@ function readServerDetail(queryClient: QueryClient, serverId: string): ServerRes function makeRestServer(overrides: Partial<ServerResponse> = {}): ServerResponse { return { + agent_authority: { outstanding_offer: null, status: 'claimed' }, capabilities: 1852, country_code: 'US', cpu_cores: 8, @@ -401,10 +402,9 @@ describe('server catalog projection', () => { projectServerCatalog(queryClient, { kind: 'server_saved', server: saved }) projectServerCatalog(queryClient, { kind: 'tags_changed', serverId: first.id, tags: ['edited'] }) projectServerCatalog(queryClient, { - kind: 'enrollment_changed', - outstandingEnrollment: OUTSTANDING_ENROLLMENT, - serverId: first.id, - tokenRevoked: true + authority: { outstanding_offer: OUTSTANDING_ENROLLMENT, status: 'unclaimed' }, + kind: 'agent_authority_changed', + serverId: first.id }) expect(readLiveServers(queryClient)?.[0]).toMatchObject({ diff --git a/apps/web/src/lib/server-catalog.ts b/apps/web/src/lib/server-catalog.ts index ef10c85d..4ff01f93 100644 --- a/apps/web/src/lib/server-catalog.ts +++ b/apps/web/src/lib/server-catalog.ts @@ -1,6 +1,6 @@ import { type QueryClient, useQuery, useQueryClient } from '@tanstack/react-query' import { api } from '@/lib/api-client' -import type { OutstandingEnrollmentSummary, ServerResponse } from '@/lib/api-schema' +import type { AgentAuthorityStateSummary, OutstandingEnrollmentSummary, ServerResponse } from '@/lib/api-schema' const LIVE_SERVERS_KEY = ['server-catalog', 'live'] as const const SERVER_LIST_KEY = ['server-catalog', 'list'] as const @@ -45,6 +45,7 @@ export interface LiveMetrics { } export interface ServerMetrics extends LiveMetrics { + agent_authority?: AgentAuthorityStateSummary agent_local_capabilities?: number | null agent_version?: string | null capabilities?: number @@ -70,12 +71,7 @@ export type ServerCatalogEvent = | { kind: 'rest_snapshot'; servers: ServerResponse[] } | { kind: 'servers_removed'; serverIds: readonly string[] } | { kind: 'server_saved'; server: ServerResponse } - | { - kind: 'enrollment_changed' - outstandingEnrollment: OutstandingEnrollmentSummary | null - serverId: string - tokenRevoked: boolean - } + | { authority: AgentAuthorityStateSummary; kind: 'agent_authority_changed'; serverId: string } | { kind: 'tags_changed'; serverId: string; tags: string[] } | { kind: 'ws_full_sync'; servers: ServerMetrics[] } | { kind: 'ws_update'; servers: LiveMetrics[] } @@ -102,6 +98,7 @@ function serverDetailKey(serverId: string): readonly ['server-catalog', 'detail' function blankServerMetrics(id: string): ServerMetrics { return { + agent_authority: { outstanding_offer: null, status: 'unclaimed' }, agent_local_capabilities: null, agent_version: null, country_code: null, @@ -146,6 +143,7 @@ function blankServerMetrics(id: string): ServerMetrics { function projectRestServer(current: ServerMetrics, server: ServerResponse): ServerMetrics { return { ...current, + agent_authority: server.agent_authority, agent_local_capabilities: server.agent_local_capabilities === undefined ? current.agent_local_capabilities @@ -181,6 +179,7 @@ function projectRestSnapshot(current: ServerMetrics[] | undefined, servers: Serv function projectFullSyncServerToRest(current: ServerResponse, server: ServerMetrics): ServerResponse { return { ...current, + agent_authority: server.agent_authority ?? current.agent_authority, country_code: server.country_code, cpu_cores: server.cpu_cores, cpu_name: server.cpu_name, @@ -223,6 +222,7 @@ function mergeWsFullSync(current: ServerMetrics[] | undefined, incoming: ServerM ? existing.agent_local_capabilities : server.agent_local_capabilities, agent_version: server.agent_version === undefined ? existing.agent_version : server.agent_version, + agent_authority: server.agent_authority === undefined ? existing.agent_authority : server.agent_authority, capabilities: server.capabilities === undefined ? existing.capabilities : server.capabilities, effective_capabilities: server.effective_capabilities === undefined ? existing.effective_capabilities : server.effective_capabilities, @@ -364,26 +364,31 @@ export function projectServerCatalog(queryClient: QueryClient, event: ServerCata } return } - case 'enrollment_changed': { + case 'agent_authority_changed': { + const hasToken = event.authority.status === 'claimed' + const outstandingEnrollment = event.authority.outstanding_offer queryClient.setQueryData<ServerMetrics[]>(LIVE_SERVERS_KEY, (current) => updateById(current, event.serverId, (server) => ({ ...server, - has_token: event.tokenRevoked ? false : server.has_token, - online: event.tokenRevoked ? false : server.online, - outstanding_enrollment: event.outstandingEnrollment + agent_authority: event.authority, + has_token: hasToken, + online: hasToken ? server.online : false, + outstanding_enrollment: outstandingEnrollment })) ) queryClient.setQueryData<ServerResponse[]>(SERVER_LIST_KEY, (current) => updateById(current, event.serverId, (server) => ({ ...server, - has_token: event.tokenRevoked ? false : server.has_token, - outstanding_enrollment: event.outstandingEnrollment + agent_authority: event.authority, + has_token: hasToken, + outstanding_enrollment: outstandingEnrollment })) ) updateExistingDetail(queryClient, event.serverId, (server) => ({ ...server, - has_token: event.tokenRevoked ? false : server.has_token, - outstanding_enrollment: event.outstandingEnrollment + agent_authority: event.authority, + has_token: hasToken, + outstanding_enrollment: outstandingEnrollment })) return } diff --git a/apps/web/src/locales/en/servers.json b/apps/web/src/locales/en/servers.json index 4664e332..0144b881 100644 --- a/apps/web/src/locales/en/servers.json +++ b/apps/web/src/locales/en/servers.json @@ -32,6 +32,11 @@ "add_server.copied": "Copied", "add_server.done": "Done", "add_server.another": "Add another", + "add_server.replayed": "This onboarding request already created server {{serverId}}. The original plaintext code cannot be recovered.", + "add_server.replay_code_lost": "Replace the exact outstanding offer to issue a new one-time code.", + "add_server.replay_no_offer": "No outstanding offer remains. Manage Agent authority from the existing server.", + "add_server.replace_offer": "Replace outstanding offer", + "add_server.replace_failed": "Failed to replace outstanding offer", "card_latency": "Latency", "card_load": "Load", "card_net_total": "Total", @@ -54,32 +59,41 @@ "card_pending.code_expires_in": "Code {{prefix}}… · expires in {{countdown}}", "card_pending.code_expired": "Code {{prefix}}… expired", "card_pending.no_code": "No outstanding enrollment code", - "card_pending.regenerate_code": "Regenerate code", + "card_pending.issue_offer": "Issue new enrollment offer", "card_pending.delete_server": "Delete server", "card_pending.delete_confirm_title": "Delete pending server?", "card_pending.delete_confirm_description": "This removes the server row and revokes the outstanding enrollment code.", - "card_pending.regenerate_title": "New enrollment code", - "card_pending.regenerate_description": "Copy this now — the code is shown only once.", - "card_pending.regenerate_failed": "Failed to regenerate code", - "card_pending.regenerated": "New code generated", + "card_pending.offer_title": "New enrollment offer", + "card_pending.offer_description": "Copy this code now. It is shown only once.", + "card_pending.offer_failed": "Failed to issue enrollment offer", + "card_pending.offer_issued": "Enrollment offer issued", "card_pending.deleted": "Server deleted", "card_pending.delete_failed": "Failed to delete server", - "recover_agent.title": "Recover Agent", - "recover_agent.description": "Mint a new enrollment code bound to this server so a replacement or reinstalled agent can re-enroll without losing history.", - "recover_agent.server_name_label": "Server", - "recover_agent.caps_label": "Agent capabilities", - "recover_agent.caps_hint": "Pick which features the agent is allowed to run.", - "recover_agent.revoke_immediately": "Revoke current agent immediately", - "recover_agent.revoke_warning": "The current agent will be kicked offline and cannot reconnect until the new install command runs.", - "recover_agent.ttl_tip": "Code valid for 10 minutes", - "recover_agent.generate": "Generate code", - "recover_agent.generating": "Generating…", - "recover_agent.generate_failed": "Failed to mint enrollment code", - "recover_agent.outstanding_notice_title": "Outstanding code exists", - "recover_agent.outstanding_notice_body": "There is already an outstanding enrollment code for this server. Revoke it before minting a new one.", - "recover_agent.revoke": "Revoke outstanding code", - "recover_agent.revoke_failed": "Failed to revoke outstanding code", - "recover_agent.revoked": "Outstanding code revoked", + "agent_reenrollment.title": "Agent re-enrollment", + "agent_reenrollment.description": "Issue a one-time offer so a replacement or reinstalled Agent can claim this existing server identity.", + "agent_reenrollment.caps_label": "Agent capabilities", + "agent_reenrollment.caps_hint": "Choose the capabilities for the replacement Agent install.", + "agent_reenrollment.emergency_mode": "Emergency re-enrollment", + "agent_reenrollment.emergency_description": "Revokes current Agent authority and fences its connection immediately.", + "agent_reenrollment.graceful_description": "Graceful mode keeps current Agent authority valid until the new offer is claimed.", + "agent_reenrollment.ttl_tip": "Offer valid for 10 minutes", + "agent_reenrollment.generate": "Begin re-enrollment", + "agent_reenrollment.generating": "Issuing…", + "agent_reenrollment.generate_failed": "Failed to begin Agent re-enrollment", + "agent_reenrollment.outstanding_notice_title": "Outstanding enrollment offer", + "agent_reenrollment.outstanding_notice_body": "Replace this exact offer if its plaintext code was lost, or revoke it without issuing a successor.", + "agent_reenrollment.expired_notice_title": "Enrollment offer expired", + "agent_reenrollment.expired_notice_body": "This offer is terminal and can no longer be replaced or revoked.", + "agent_reenrollment.replace_offer": "Replace offer", + "agent_reenrollment.replace_failed": "Failed to replace enrollment offer", + "agent_reenrollment.revoke_offer": "Revoke offer", + "agent_reenrollment.revoke_offer_failed": "Failed to revoke enrollment offer", + "agent_reenrollment.offer_revoked": "Enrollment offer revoked", + "agent_reenrollment.revoke_authority": "Revoke Agent authority", + "agent_reenrollment.revoke_authority_title": "Revoke Agent authority?", + "agent_reenrollment.revoke_authority_description": "{{name}} will be fenced immediately and left unclaimed. This does not create an enrollment offer.", + "agent_reenrollment.revoke_authority_failed": "Failed to revoke Agent authority", + "agent_reenrollment.authority_revoked": "Agent authority revoked", "servers_online": "{{online}} of {{total}} servers online", "search_placeholder": "Search servers\u2026", "delete_selected": "Delete {{count}}", @@ -133,7 +147,7 @@ "detail_back": "Back to Dashboard", "detail_edit": "Edit", - "detail_recover_agent": "Recover Agent", + "detail_agent_reenrollment": "Agent re-enrollment", "card_actions": "Server actions", "detail_terminal": "Terminal", "detail_files": "Files", diff --git a/apps/web/src/locales/zh/servers.json b/apps/web/src/locales/zh/servers.json index 55b080b2..bc613c57 100644 --- a/apps/web/src/locales/zh/servers.json +++ b/apps/web/src/locales/zh/servers.json @@ -32,6 +32,11 @@ "add_server.copied": "已复制", "add_server.done": "完成", "add_server.another": "再添加一个", + "add_server.replayed": "此接入请求已创建服务器 {{serverId}},原始明文注册码无法找回。", + "add_server.replay_code_lost": "按当前 Offer 的精确 ID 替换它,以签发新的单次注册码。", + "add_server.replay_no_offer": "当前没有未完成的 Offer,请从已有服务器管理 Agent Authority。", + "add_server.replace_offer": "替换当前 Offer", + "add_server.replace_failed": "替换 Enrollment Offer 失败", "card_latency": "延迟", "card_load": "负载", "card_net_total": "总流量", @@ -54,32 +59,41 @@ "card_pending.code_expires_in": "注册码 {{prefix}}… · {{countdown}} 后过期", "card_pending.code_expired": "注册码 {{prefix}}… 已过期", "card_pending.no_code": "暂无有效注册码", - "card_pending.regenerate_code": "重新生成注册码", + "card_pending.issue_offer": "签发新的 Enrollment Offer", "card_pending.delete_server": "删除服务器", "card_pending.delete_confirm_title": "删除待接入的服务器?", "card_pending.delete_confirm_description": "将删除该服务器记录并吊销未使用的注册码。", - "card_pending.regenerate_title": "新的注册码", - "card_pending.regenerate_description": "请立即复制 —— 注册码只显示一次。", - "card_pending.regenerate_failed": "重新生成注册码失败", - "card_pending.regenerated": "已生成新注册码", + "card_pending.offer_title": "新的 Enrollment Offer", + "card_pending.offer_description": "请立即复制注册码,它只显示一次。", + "card_pending.offer_failed": "签发 Enrollment Offer 失败", + "card_pending.offer_issued": "已签发 Enrollment Offer", "card_pending.deleted": "已删除服务器", "card_pending.delete_failed": "删除服务器失败", - "recover_agent.title": "恢复 Agent", - "recover_agent.description": "为该服务器生成一个新的注册码,让替换或重装的 Agent 可以重新接入而不丢失历史数据。", - "recover_agent.server_name_label": "服务器", - "recover_agent.caps_label": "Agent 能力", - "recover_agent.caps_hint": "选择允许 Agent 启用的功能。", - "recover_agent.revoke_immediately": "立即吊销当前 Agent", - "recover_agent.revoke_warning": "当前 Agent 将立即断线,且在执行新安装命令前无法重连。", - "recover_agent.ttl_tip": "注册码 10 分钟内有效", - "recover_agent.generate": "生成注册码", - "recover_agent.generating": "生成中…", - "recover_agent.generate_failed": "生成注册码失败", - "recover_agent.outstanding_notice_title": "存在未使用的注册码", - "recover_agent.outstanding_notice_body": "该服务器已存在一个未使用的注册码。请先吊销后再生成新的。", - "recover_agent.revoke": "吊销未使用的注册码", - "recover_agent.revoke_failed": "吊销注册码失败", - "recover_agent.revoked": "已吊销注册码", + "agent_reenrollment.title": "Agent 重新接入", + "agent_reenrollment.description": "签发单次 Offer,让替换或重装的 Agent 接管这台服务器的既有身份。", + "agent_reenrollment.caps_label": "Agent 能力", + "agent_reenrollment.caps_hint": "选择替换 Agent 安装时启用的能力。", + "agent_reenrollment.emergency_mode": "紧急重新接入", + "agent_reenrollment.emergency_description": "立即吊销当前 Agent Authority 并封锁其连接。", + "agent_reenrollment.graceful_description": "平滑模式会保留当前 Agent Authority,直到新 Offer 被领取。", + "agent_reenrollment.ttl_tip": "Offer 10 分钟内有效", + "agent_reenrollment.generate": "开始重新接入", + "agent_reenrollment.generating": "签发中…", + "agent_reenrollment.generate_failed": "开始 Agent 重新接入失败", + "agent_reenrollment.outstanding_notice_title": "存在未完成的 Enrollment Offer", + "agent_reenrollment.outstanding_notice_body": "若明文注册码丢失,请按精确 ID 替换此 Offer;也可以直接吊销且不创建后继。", + "agent_reenrollment.expired_notice_title": "Enrollment Offer 已过期", + "agent_reenrollment.expired_notice_body": "此 Offer 已进入终态,不能再替换或吊销。", + "agent_reenrollment.replace_offer": "替换 Offer", + "agent_reenrollment.replace_failed": "替换 Enrollment Offer 失败", + "agent_reenrollment.revoke_offer": "吊销 Offer", + "agent_reenrollment.revoke_offer_failed": "吊销 Enrollment Offer 失败", + "agent_reenrollment.offer_revoked": "Enrollment Offer 已吊销", + "agent_reenrollment.revoke_authority": "吊销 Agent Authority", + "agent_reenrollment.revoke_authority_title": "吊销 Agent Authority?", + "agent_reenrollment.revoke_authority_description": "{{name}} 将立即被封锁并变为未认领状态,此操作不会创建 Enrollment Offer。", + "agent_reenrollment.revoke_authority_failed": "吊销 Agent Authority 失败", + "agent_reenrollment.authority_revoked": "Agent Authority 已吊销", "servers_online": "{{total}} 台服务器中 {{online}} 台在线", "search_placeholder": "搜索服务器\u2026", "delete_selected": "删除 {{count}} 项", @@ -133,7 +147,7 @@ "detail_back": "返回仪表盘", "detail_edit": "编辑", - "detail_recover_agent": "恢复 Agent", + "detail_agent_reenrollment": "Agent 重新接入", "card_actions": "服务器操作", "detail_terminal": "终端", "detail_files": "文件", diff --git a/apps/web/src/routes/_authed/servers/$id-page.tsx b/apps/web/src/routes/_authed/servers/$id-page.tsx index 97041bb2..5cf8e505 100644 --- a/apps/web/src/routes/_authed/servers/$id-page.tsx +++ b/apps/web/src/routes/_authed/servers/$id-page.tsx @@ -5,9 +5,9 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { CountryFlag } from '@/components/country-flag' import { NetworkTab } from '@/components/network/network-tab' +import { AgentReenrollmentDialog } from '@/components/server/agent-reenrollment-dialog' import { AgentVersionSection } from '@/components/server/agent-version-section' import { CapabilitiesDialog } from '@/components/server/capabilities-dialog' -import { RecoverAgentDialog } from '@/components/server/recover-agent-dialog' import { ServerEditDialog } from '@/components/server/server-edit-dialog' import { StatusBadge } from '@/components/server/status-badge' import { UpgradeJobBadge } from '@/components/server/upgrade-job-badge' @@ -88,7 +88,7 @@ function ServerActionButtons({ isOnline, liveHydrated, onEditOpen, - onRecoverOpen, + onReenrollmentOpen, serverWithCaps, terminalEnabled }: { @@ -99,14 +99,13 @@ function ServerActionButtons({ isOnline: boolean liveHydrated: boolean onEditOpen: () => void - onRecoverOpen: () => void + onReenrollmentOpen: () => void serverWithCaps: ServerResponse & ServerWithCaps terminalEnabled: boolean }) { const { t } = useTranslation('servers') // Gate online/offline-specific buttons on liveHydrated so the button list does - // not flicker (offline-only Recovery button shown then hidden, online-only - // Terminal/Files/Docker hidden then shown) when WS data arrives. + // not flicker while online-only Terminal/Files/Docker actions hydrate. return ( <div className="flex flex-wrap gap-2"> <Button onClick={onEditOpen} size="sm" variant="outline"> @@ -114,9 +113,9 @@ function ServerActionButtons({ {t('detail_edit')} </Button> <CapabilitiesDialog server={serverWithCaps} /> - {isAdmin && liveHydrated && !isOnline && ( - <Button onClick={onRecoverOpen} size="sm" variant="outline"> - {t('detail_recover_agent', { defaultValue: 'Recover Agent' })} + {isAdmin && liveHydrated && ( + <Button onClick={onReenrollmentOpen} size="sm" variant="outline"> + {t('detail_agent_reenrollment', { defaultValue: 'Agent re-enrollment' })} </Button> )} {liveHydrated && isOnline && terminalEnabled && ( @@ -153,7 +152,7 @@ export function ServerDetailPage() { const { range: rangeParam, tab: tabParam } = routeApi.useSearch() const navigate = routeApi.useNavigate() const [editOpen, setEditOpen] = useState(false) - const [recoverOpen, setRecoverOpen] = useState(false) + const [reenrollmentOpen, setReenrollmentOpen] = useState(false) const { user } = useAuth() const { data: latestAgentVersion } = useQuery<{ version?: string | null }>({ queryKey: ['agent', 'latest-version'], @@ -247,7 +246,7 @@ export function ServerDetailPage() { isOnline={isOnline} liveHydrated={liveHydrated} onEditOpen={() => setEditOpen(true)} - onRecoverOpen={() => setRecoverOpen(true)} + onReenrollmentOpen={() => setReenrollmentOpen(true)} serverWithCaps={serverWithCaps} terminalEnabled={terminalEnabled} /> @@ -273,14 +272,14 @@ export function ServerDetailPage() { /> <ServerEditDialog onClose={() => setEditOpen(false)} open={editOpen} server={server} /> - <RecoverAgentDialog - onOpenChange={setRecoverOpen} - open={recoverOpen} + <AgentReenrollmentDialog + onOpenChange={setReenrollmentOpen} + open={reenrollmentOpen} server={{ id: server.id, name: server.name, capabilities: server.capabilities, - outstanding_enrollment: server.outstanding_enrollment ?? null + agent_authority: server.agent_authority }} /> </div> diff --git a/apps/web/src/routes/_authed/servers/$id.test.tsx b/apps/web/src/routes/_authed/servers/$id.test.tsx index 412f12fd..5227e27a 100644 --- a/apps/web/src/routes/_authed/servers/$id.test.tsx +++ b/apps/web/src/routes/_authed/servers/$id.test.tsx @@ -68,8 +68,8 @@ vi.mock('@/components/server/server-edit-dialog', () => ({ ServerEditDialog: () => null })) -vi.mock('@/components/server/recover-agent-dialog', () => ({ - RecoverAgentDialog: () => null +vi.mock('@/components/server/agent-reenrollment-dialog', () => ({ + AgentReenrollmentDialog: () => null })) vi.mock('@/components/server/status-badge', () => ({ @@ -243,10 +243,10 @@ describe('ServerDetailPage', () => { expect(headerGrid?.children[2]).toContainElement(editButton) }) - it('shows recovery action for offline server when admin', () => { + it('shows Agent re-enrollment action for an admin', () => { render(<ServerDetailPage />) - expect(screen.getByText('Recover Agent')).toBeInTheDocument() + expect(screen.getByText('Agent re-enrollment')).toBeInTheDocument() }) it('shows cost insights on the billing summary', () => { diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 394ccc2d..6c70fc26 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -31,7 +31,6 @@ hex = "0.4" semver = "1" x509-parser = "0.16" webpki-roots = "1" -gethostname = "0.5" dashmap = "6" libc = "0.2" rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } diff --git a/crates/agent/src/fingerprint.rs b/crates/agent/src/fingerprint.rs deleted file mode 100644 index 5c1d2abf..00000000 --- a/crates/agent/src/fingerprint.rs +++ /dev/null @@ -1,147 +0,0 @@ -use sha2::{Digest, Sha256}; - -/// Generate a machine fingerprint from stable host identity. -/// Returns empty string if machine_id is unavailable (caller should skip fingerprint). -pub fn generate() -> String { - let machine_id = match read_machine_id() { - Some(id) => id, - None => { - tracing::warn!("Could not read machine-id, fingerprint will be skipped"); - return String::new(); - } - }; - - fingerprint_from_machine_id(&machine_id) -} - -fn fingerprint_from_machine_id(machine_id: &str) -> String { - let hash = Sha256::digest(machine_id.as_bytes()); - hex::encode(hash) -} - -#[cfg(target_os = "linux")] -fn read_machine_id() -> Option<String> { - std::fs::read_to_string("/etc/machine-id") - .ok() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) -} - -#[cfg(target_os = "macos")] -fn read_machine_id() -> Option<String> { - let output = std::process::Command::new("ioreg") - .args(["-rd1", "-c", "IOPlatformExpertDevice"]) - .output() - .ok()?; - let stdout = String::from_utf8_lossy(&output.stdout); - for line in stdout.lines() { - if line.contains("IOPlatformUUID") { - return line.split('"').nth(3).map(|s| s.to_string()); - } - } - None -} - -#[cfg(target_os = "windows")] -fn read_machine_id() -> Option<String> { - let output = std::process::Command::new("reg") - .args([ - "query", - r"HKLM\SOFTWARE\Microsoft\Cryptography", - "/v", - "MachineGuid", - ]) - .output() - .ok()?; - let stdout = String::from_utf8_lossy(&output.stdout); - for line in stdout.lines() { - if line.contains("MachineGuid") { - return line.split_whitespace().last().map(|s| s.to_string()); - } - } - None -} - -#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] -fn read_machine_id() -> Option<String> { - None -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_fingerprint_uses_machine_id_only() { - let machine_id = "test-machine-id-1234"; - let expected = hex::encode(Sha256::digest(machine_id.as_bytes())); - assert_eq!(fingerprint_from_machine_id(machine_id), expected); - } - - #[test] - fn test_generate_returns_64_hex_chars_or_empty() { - let fp = generate(); - assert!( - fp.is_empty() || (fp.len() == 64 && fp.chars().all(|c| c.is_ascii_hexdigit())), - "Fingerprint must be empty or 64 hex chars, got: {fp}" - ); - } - - #[test] - fn test_sha256_deterministic() { - let machine_id = "test-machine-id-1234"; - let hash1 = fingerprint_from_machine_id(machine_id); - let hash2 = fingerprint_from_machine_id(machine_id); - assert_eq!(hash1, hash2); - assert_eq!(hash1.len(), 64); - } - - #[test] - fn fingerprint_distinct_inputs_yield_distinct_hashes() { - // Different machine ids must not collide. - let a = fingerprint_from_machine_id("host-a"); - let b = fingerprint_from_machine_id("host-b"); - assert_ne!(a, b); - assert_eq!(a.len(), 64); - assert_eq!(b.len(), 64); - } - - #[test] - fn fingerprint_of_empty_input_is_sha256_of_empty() { - // The helper does not special-case empty input; it hashes verbatim. - // (The empty-id guard lives in read_machine_id, not here.) - let expected = hex::encode(Sha256::digest(b"")); - assert_eq!(fingerprint_from_machine_id(""), expected); - assert_eq!(fingerprint_from_machine_id("").len(), 64); - } - - #[test] - fn fingerprint_matches_known_vector() { - // Lock in a known SHA-256 hex to guard against accidental algorithm - // changes. echo -n "abc" | sha256sum. - let known = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; - assert_eq!(fingerprint_from_machine_id("abc"), known); - } - - #[test] - fn generate_is_deterministic_across_calls() { - // On any host, generate() must be stable: either consistently empty - // (no machine-id) or a consistent 64-hex digest. - let first = generate(); - let second = generate(); - assert_eq!(first, second); - } - - /// On macOS the test runner can read the platform UUID via `ioreg`, so the - /// `read_machine_id` arm and the non-empty `generate` branch are exercised. - #[cfg(target_os = "macos")] - #[test] - fn macos_read_machine_id_yields_non_empty_fingerprint() { - if let Some(id) = read_machine_id() { - assert!(!id.is_empty()); - let fp = generate(); - assert_eq!(fp.len(), 64); - assert_eq!(fp, fingerprint_from_machine_id(&id)); - } - } -} diff --git a/crates/agent/src/main.rs b/crates/agent/src/main.rs index 032b4211..58d67987 100644 --- a/crates/agent/src/main.rs +++ b/crates/agent/src/main.rs @@ -4,15 +4,14 @@ mod collector; mod config; mod docker; mod file_manager; -mod fingerprint; mod firewall; mod ip_quality; mod network_prober; mod pinger; mod probe_utils; -mod rebind; mod register; mod reporter; +mod run_token_store; mod security; mod terminal; mod traceroute; @@ -198,31 +197,24 @@ async fn main() -> anyhow::Result<()> { ); } - let machine_fingerprint = fingerprint::generate(); - if !machine_fingerprint.is_empty() { - tracing::info!( - "Machine fingerprint: {}...{}", - &machine_fingerprint[..8], - &machine_fingerprint[56..] - ); - } - if config.token.is_empty() { if config.enrollment_code.is_empty() { anyhow::bail!( - "No token and no enrollment_code. Generate a one-time code in the \ - server UI (Settings) and set `enrollment_code` in agent.toml or the \ + "No token and no enrollment_code. Add a Server in the server UI and set \ + its one-time code as `enrollment_code` in agent.toml or the \ SERVERBEE_ENROLLMENT_CODE environment variable." ); } - tracing::info!("No token found, registering..."); - match register::register_agent_with_backoff(&config, &machine_fingerprint).await { - Ok((server_id, token)) => { + tracing::info!("No run token found, staging one before claiming Agent authority..."); + match register::register_agent_with_backoff(&mut config).await { + Ok(register::RegistrationOutcome::Confirmed { server_id }) => { tracing::info!("Registration successful (server_id={server_id})"); - if let Err(e) = register::save_token(&token) { - tracing::warn!("Failed to save token: {e}"); - } - config.token = token; + } + Ok(register::RegistrationOutcome::Ambiguous) => { + tracing::warn!( + "Registration response was ambiguous; trying WebSocket authentication with \ + the staged run token before retrying the same claim" + ); } Err(register::RegisterError::PermanentAuth(msg)) => { eprintln!( @@ -259,17 +251,11 @@ async fn main() -> anyhow::Result<()> { security_tx, ); - let mut reporter = Reporter::new(config, machine_fingerprint, capabilities); + let mut reporter = Reporter::new(config, capabilities); reporter.run_with_external(Some(security_rx)).await; Ok(()) } -#[cfg(test)] -#[test] -fn persist_rebind_token() { - crate::rebind::assert_persist_rebind_token(); -} - #[cfg(test)] #[test] fn config_path() { diff --git a/crates/agent/src/probe_utils.rs b/crates/agent/src/probe_utils.rs index 493a10e2..e6f75221 100644 --- a/crates/agent/src/probe_utils.rs +++ b/crates/agent/src/probe_utils.rs @@ -600,8 +600,8 @@ mod tests { #[test] fn parse_ping_time_scans_multiple_lines() { // First line has no marker; second line carries the value. - let out = "PING start\n64 bytes: icmp_seq=1 time=3.14 ms"; - assert!((parse_ping_time(out).unwrap() - 3.14).abs() < 0.001); + let out = "PING start\n64 bytes: icmp_seq=1 time=3.12 ms"; + assert!((parse_ping_time(out).unwrap() - 3.12).abs() < 0.001); } // ── parse_ping_batch_output: remaining branches ────────────────────────── diff --git a/crates/agent/src/register.rs b/crates/agent/src/register.rs index d276a96a..fc715713 100644 --- a/crates/agent/src/register.rs +++ b/crates/agent/src/register.rs @@ -1,35 +1,24 @@ +use std::path::Path; use std::time::Duration; -use anyhow::Result; +use base64::Engine; +use rand::RngCore; use reqwest::StatusCode; use serde::{Deserialize, Serialize}; use crate::config::AgentConfig; -#[cfg(not(test))] -use crate::rebind::persist_rebind_token; -#[cfg(test)] -use crate::rebind::persist_rebind_token_impl as persist_rebind_token; - -/// Exit code reported to systemd when the agent gives up because of a -/// permanent registration error (bad/used/expired enrollment code). Matches -/// LSB "configuration error" (78) and is paired with -/// `RestartPreventExitStatus=78` in the systemd unit so we don't burn through -/// the rate-limit window with hopeless retries. +/// Matches LSB "configuration error" and the systemd +/// `RestartPreventExitStatus=78` policy used by the installer. pub const EXIT_CODE_PERMANENT_AUTH_FAILURE: i32 = 78; -/// In-process retry cap before bailing back to systemd. Keeps the agent -/// trying through transient outages without spamming the rate-limit window -/// on permanent misconfig. const MAX_REGISTER_ATTEMPTS: u32 = 30; -const INITIAL_BACKOFF: Duration = Duration::from_secs(5); const MAX_BACKOFF: Duration = Duration::from_secs(300); const DEFAULT_RATE_LIMIT_BACKOFF: Duration = Duration::from_secs(60); #[derive(Serialize)] -struct RegisterRequest { - #[serde(skip_serializing_if = "String::is_empty")] - fingerprint: String, +struct RegisterRequest<'a> { + proposed_run_token: &'a str, } #[derive(Deserialize)] @@ -40,645 +29,330 @@ struct RegisterResponse { #[derive(Deserialize)] struct RegisterData { server_id: String, - token: String, } -/// Categorized registration failure. Callers use this to decide between -/// retrying in-process (transient), waiting then retrying (rate-limited), or -/// giving up immediately (permanent). +#[derive(Debug, Eq, PartialEq)] +pub enum RegistrationOutcome { + Confirmed { + server_id: String, + }, + /// The claim request may have committed but its response was not usable. + /// The caller must try WebSocket authentication with the already-staged + /// token before deciding whether to retry the same claim. + Ambiguous, +} + #[derive(Debug)] pub enum RegisterError { - /// HTTP 401/403 — enrollment code is invalid, expired, or already used. - /// Operator must rotate the code; retrying will never succeed. + Persistence(String), PermanentAuth(String), - /// HTTP 429 — server is throttling this IP. Honor `Retry-After`. RateLimited { retry_after: Duration, message: String, }, - /// 5xx, network error, or non-401 4xx — likely transient. - Transient(String), } impl std::fmt::Display for RegisterError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::PermanentAuth(m) => write!(f, "permanent auth failure: {m}"), - Self::RateLimited { retry_after, message } => write!( + Self::Persistence(message) => write!(f, "run-token persistence failed: {message}"), + Self::PermanentAuth(message) => write!(f, "permanent auth failure: {message}"), + Self::RateLimited { + retry_after, + message, + } => write!( f, "rate limited (retry after {}s): {message}", retry_after.as_secs() ), - Self::Transient(m) => write!(f, "transient error: {m}"), } } } impl std::error::Error for RegisterError {} -/// Single attempt at registration. Used by both the bootstrap loop and the -/// reporter's mid-session rebind path. Errors are categorized so callers can -/// retry intelligently. -pub async fn register_agent(config: &AgentConfig, fingerprint: &str) -> Result<(String, String)> { - register_once(config, fingerprint).await.map_err(|e| { - anyhow::anyhow!( - "Registration failed: {e}. \ - Check that the enrollment code is valid and not expired or already used." - ) - }) +/// Ensure the Agent owns a restart-safe run token before any claim request can +/// consume an Enrollment offer. Explicit `SERVERBEE_TOKEN` values are never +/// rewritten. +pub fn stage_run_token(config: &mut AgentConfig) -> Result<(), RegisterError> { + stage_run_token_at( + config, + AgentConfig::config_path_for_persistence(), + AgentConfig::token_env_override_present(), + ) } -async fn register_once( - config: &AgentConfig, - fingerprint: &str, -) -> Result<(String, String), RegisterError> { +fn stage_run_token_at( + config: &mut AgentConfig, + path: impl AsRef<Path>, + token_env_override_present: bool, +) -> Result<(), RegisterError> { + if !config.token.is_empty() { + return Ok(()); + } + if token_env_override_present { + return Err(RegisterError::Persistence( + "SERVERBEE_TOKEN is present but empty; refusing to overwrite an explicit override" + .to_string(), + )); + } + + let token = generate_run_token(); + crate::run_token_store::persist_run_token(path, &token) + .map_err(|error| RegisterError::Persistence(error.to_string()))?; + config.token = token; + Ok(()) +} + +fn generate_run_token() -> String { + let mut bytes = [0_u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) +} + +/// One claim attempt. A run token is staged first, so persistence failure can +/// never burn the single-use Enrollment offer. +pub async fn register_agent( + config: &mut AgentConfig, +) -> Result<RegistrationOutcome, RegisterError> { + stage_run_token(config)?; + register_once(config).await +} + +async fn register_once(config: &AgentConfig) -> Result<RegistrationOutcome, RegisterError> { let url = format!( "{}/api/agent/register", config.server_url.trim_end_matches('/') ); - let client = reqwest::Client::new(); - let resp = match client - .post(&url) + let response = match reqwest::Client::new() + .post(url) .bearer_auth(&config.enrollment_code) .json(&RegisterRequest { - fingerprint: fingerprint.to_string(), + proposed_run_token: &config.token, }) .send() .await { - Ok(r) => r, - Err(e) => return Err(RegisterError::Transient(format!("network error: {e}"))), + Ok(response) => response, + Err(error) => { + tracing::warn!("Enrollment claim response is ambiguous: {error}"); + return Ok(RegistrationOutcome::Ambiguous); + } }; - let status = resp.status(); + let status = response.status(); if status.is_success() { - let data: RegisterResponse = resp - .json() - .await - .map_err(|e| RegisterError::Transient(format!("invalid response body: {e}")))?; - tracing::info!("Registered as server_id={}", data.data.server_id); - return Ok((data.data.server_id, data.data.token)); + let data: RegisterResponse = match response.json().await { + Ok(data) => data, + Err(error) => { + tracing::warn!("Enrollment claim response body is ambiguous: {error}"); + return Ok(RegistrationOutcome::Ambiguous); + } + }; + tracing::info!( + "Enrollment claim confirmed for server_id={}", + data.data.server_id + ); + return Ok(RegistrationOutcome::Confirmed { + server_id: data.data.server_id, + }); } - let retry_after = parse_retry_after(&resp); - let body = resp.text().await.unwrap_or_default(); - let trimmed = body.trim(); - let msg = format!("HTTP {status}. Server said: {trimmed}"); - + let retry_after = parse_retry_after(&response); + let body = response.text().await.unwrap_or_default(); + let message = format!("HTTP {status}. Server said: {}", body.trim()); match status { - StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Err(RegisterError::PermanentAuth(msg)), + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { + Err(RegisterError::PermanentAuth(message)) + } StatusCode::TOO_MANY_REQUESTS => Err(RegisterError::RateLimited { retry_after: retry_after.unwrap_or(DEFAULT_RATE_LIMIT_BACKOFF), - message: msg, + message, }), - _ => Err(RegisterError::Transient(msg)), + _ => { + tracing::warn!("Enrollment claim result is ambiguous: {message}"); + Ok(RegistrationOutcome::Ambiguous) + } } } -fn parse_retry_after(resp: &reqwest::Response) -> Option<Duration> { - let raw = resp +fn parse_retry_after(response: &reqwest::Response) -> Option<Duration> { + response .headers() .get(reqwest::header::RETRY_AFTER)? .to_str() - .ok()?; - // Only the delta-seconds form is supported. HTTP-date form is rare in - // practice and adds a date-parsing dep — caller falls back to a sane default. - raw.trim().parse::<u64>().ok().map(Duration::from_secs) + .ok()? + .trim() + .parse::<u64>() + .ok() + .map(Duration::from_secs) } -/// Retry registration with category-aware backoff. Returns immediately on -/// permanent auth failure so the caller can exit with -/// [`EXIT_CODE_PERMANENT_AUTH_FAILURE`]. -/// -/// - 401/403 → `Err(PermanentAuth)` immediately, no retry -/// - 429 → sleep `Retry-After` (or 60s), then retry -/// - 5xx/net → exponential backoff (5s, 10s, 20s, ..., capped at 5min) -/// -/// Caps at [`MAX_REGISTER_ATTEMPTS`] in-process retries so we eventually -/// surface failures to systemd instead of looping forever. pub async fn register_agent_with_backoff( - config: &AgentConfig, - fingerprint: &str, -) -> Result<(String, String), RegisterError> { - let mut backoff = INITIAL_BACKOFF; + config: &mut AgentConfig, +) -> Result<RegistrationOutcome, RegisterError> { + stage_run_token(config)?; for attempt in 1..=MAX_REGISTER_ATTEMPTS { - match register_once(config, fingerprint).await { - Ok(v) => return Ok(v), - Err(RegisterError::PermanentAuth(msg)) => { - tracing::error!( - "Permanent registration failure on attempt {attempt}: {msg}. \ - Rotate the enrollment code in the server UI and restart the agent." - ); - return Err(RegisterError::PermanentAuth(msg)); - } - Err(RegisterError::RateLimited { retry_after, message }) => { + match register_once(config).await { + Ok(outcome) => return Ok(outcome), + Err(RegisterError::RateLimited { + retry_after, + message, + }) => { let wait = retry_after.min(MAX_BACKOFF); tracing::warn!( - "Registration rate-limited on attempt {attempt}/{MAX_REGISTER_ATTEMPTS}: {message}. \ - Sleeping {}s before retry.", + "Enrollment claim rate-limited on attempt {attempt}/{MAX_REGISTER_ATTEMPTS}: \ + {message}. Sleeping {}s before retry.", wait.as_secs() ); tokio::time::sleep(wait).await; } - Err(RegisterError::Transient(msg)) => { - tracing::warn!( - "Transient registration error on attempt {attempt}/{MAX_REGISTER_ATTEMPTS}: {msg}. \ - Sleeping {}s before retry.", - backoff.as_secs() - ); - tokio::time::sleep(backoff).await; - backoff = (backoff * 2).min(MAX_BACKOFF); - } + Err(error) => return Err(error), } } - Err(RegisterError::Transient(format!( - "exhausted {MAX_REGISTER_ATTEMPTS} in-process retries" - ))) -} - -pub fn save_token(token: &str) -> Result<()> { - if AgentConfig::token_env_override_present() { - anyhow::bail!("SERVERBEE_TOKEN is set; refusing to persist token to agent.toml"); - } - - persist_rebind_token(AgentConfig::config_path_for_persistence(), token) + Ok(RegistrationOutcome::Ambiguous) } #[cfg(test)] mod tests { use std::fs; - use std::path::PathBuf; - use std::sync::Arc; - use std::time::Duration; - use tempfile::TempDir; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; + use tokio::sync::oneshot; - use super::{ - DEFAULT_RATE_LIMIT_BACKOFF, EXIT_CODE_PERMANENT_AUTH_FAILURE, RegisterError, register_agent, - register_agent_with_backoff, register_once, + use super::*; + use crate::config::{ + CapabilitiesConfig, CollectorConfig, FileConfig, IpChangeConfig, LogConfig, SecurityConfig, + UpgradeConfig, }; - use crate::config::AgentConfig; - - /// A minimal local HTTP/1.1 mock. It accepts up to `responses.len()` - /// connections (one request each) and replies with the queued raw - /// response bytes in order, then keeps accepting and replying with the - /// last response for any extra connections. Fully local (127.0.0.1), - /// deterministic, no external network. - async fn spawn_mock_server(responses: Vec<String>) -> (String, tokio::task::JoinHandle<()>) { - let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); - let addr = listener.local_addr().expect("local_addr"); - let base_url = format!("http://{addr}"); - let responses = Arc::new(responses); - - let handle = tokio::spawn(async move { - let mut idx = 0usize; - loop { - let Ok((mut socket, _)) = listener.accept().await else { - return; - }; - // Drain the request headers so the client's write completes. - // We only need to consume enough to let the client proceed; - // read once and ignore the contents. - let mut buf = [0u8; 4096]; - let _ = socket.read(&mut buf).await; - - let pick = idx.min(responses.len().saturating_sub(1)); - let body = responses - .get(pick) - .cloned() - .unwrap_or_else(|| http_response(200, "{}")); - idx += 1; - - let _ = socket.write_all(body.as_bytes()).await; - let _ = socket.flush().await; - let _ = socket.shutdown().await; - } - }); - - (base_url, handle) - } - /// Build a raw HTTP/1.1 response with a JSON body and a `Connection: - /// close` so reqwest treats the body as complete on socket close. - fn http_response(status: u16, body: &str) -> String { - let reason = match status { - 200 => "OK", - 401 => "Unauthorized", - 403 => "Forbidden", - 429 => "Too Many Requests", - 500 => "Internal Server Error", - _ => "Status", - }; - format!( - "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() - ) - } - - /// Same as [`http_response`] but injects an extra raw header line (e.g. - /// `Retry-After: 0`). - fn http_response_with_header(status: u16, header: &str, body: &str) -> String { - let reason = match status { - 429 => "Too Many Requests", - 200 => "OK", - _ => "Status", - }; - format!( - "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\n{header}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() - ) - } - - fn config_for(server_url: &str) -> AgentConfig { + fn config(server_url: String) -> AgentConfig { AgentConfig { - server_url: server_url.to_string(), - enrollment_code: "ENR-TEST".to_string(), - ..Default::default() + server_url, + token: String::new(), + enrollment_code: "enrollment-code-0123456789".to_string(), + collector: CollectorConfig::default(), + log: LogConfig::default(), + file: FileConfig::default(), + ip_change: IpChangeConfig::default(), + upgrade: UpgradeConfig::default(), + security: SecurityConfig::default(), + capabilities: CapabilitiesConfig::default(), } } - struct CurrentDirGuard { - original: PathBuf, - } - - impl Drop for CurrentDirGuard { - fn drop(&mut self) { - let _ = std::env::set_current_dir(&self.original); - } - } - - fn set_current_dir(dir: &TempDir) -> CurrentDirGuard { - let original = std::env::current_dir().expect("cwd"); - std::env::set_current_dir(dir.path()).expect("set cwd"); - CurrentDirGuard { original } - } - - #[test] - fn save_token_rejects_persistence_when_serverbee_token_is_set() { - crate::config::with_serverbee_token_env(Some("env-token"), || { - let tempdir = TempDir::new().expect("tempdir"); - let _cwd_guard = set_current_dir(&tempdir); - - let result = super::save_token("persisted-token"); - - let err = result.expect_err("save_token should fail"); - assert!( - err.to_string().contains("SERVERBEE_TOKEN"), - "unexpected error: {err}" - ); - assert!( - !tempdir.path().join("agent.toml").exists(), - "token persistence should not write a config file" - ); - }); - } - - #[test] - fn save_token_allows_persistence_when_serverbee_token_is_unset() { - crate::config::with_serverbee_token_env(None, || { - let tempdir = TempDir::new().expect("tempdir"); - let _cwd_guard = set_current_dir(&tempdir); - - super::save_token("persisted-token").expect("save_token"); - - let content = fs::read_to_string(tempdir.path().join("agent.toml")) - .expect("read persisted config"); - assert!( - content.contains("token = \"persisted-token\""), - "expected persisted token, got: {content}" - ); + async fn spawn_server( + response: &'static str, + ) -> ( + String, + oneshot::Receiver<String>, + tokio::task::JoinHandle<()>, + ) { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let address = listener.local_addr().expect("address"); + let (request_tx, request_rx) = oneshot::channel(); + let handle = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("accept"); + let mut bytes = Vec::new(); + let mut buffer = [0_u8; 4096]; + loop { + let read = socket.read(&mut buffer).await.expect("read"); + if read == 0 { + break; + } + bytes.extend_from_slice(&buffer[..read]); + if let Some(header_end) = bytes.windows(4).position(|window| window == b"\r\n\r\n") + { + let headers = String::from_utf8_lossy(&bytes[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length:") + .and_then(|value| value.trim().parse::<usize>().ok()) + }) + .unwrap_or(0); + if bytes.len() >= header_end + 4 + content_length { + break; + } + } + } + let _ = request_tx.send(String::from_utf8_lossy(&bytes).into_owned()); + socket.write_all(response.as_bytes()).await.expect("write"); }); + (format!("http://{address}"), request_rx, handle) } - // ---- RegisterError::Display ---- - #[test] - fn register_error_display_covers_all_variants() { - let permanent = RegisterError::PermanentAuth("bad code".to_string()); - assert_eq!( - permanent.to_string(), - "permanent auth failure: bad code" - ); - - let rate_limited = RegisterError::RateLimited { - retry_after: Duration::from_secs(42), - message: "slow down".to_string(), - }; - assert_eq!( - rate_limited.to_string(), - "rate limited (retry after 42s): slow down" + fn generated_run_tokens_are_base64url_unique_and_redaction_safe() { + let first = generate_run_token(); + let second = generate_run_token(); + assert_eq!(first.len(), 43); + assert!( + first + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_')) ); - - let transient = RegisterError::Transient("oops".to_string()); - assert_eq!(transient.to_string(), "transient error: oops"); - } - - #[test] - fn register_error_implements_std_error() { - // Exercise the std::error::Error blanket impl via trait object. - let err: Box<dyn std::error::Error> = - Box::new(RegisterError::Transient("x".to_string())); - assert!(err.to_string().contains("transient error")); - // Debug derive is present. - let dbg = format!("{:?}", RegisterError::PermanentAuth("p".to_string())); - assert!(dbg.contains("PermanentAuth")); + assert_ne!(first, second); } #[test] - fn exit_code_constant_matches_systemd_contract() { - assert_eq!(EXIT_CODE_PERMANENT_AUTH_FAILURE, 78); - } - - // ---- register_once: happy path ---- - - #[tokio::test] - async fn register_once_returns_server_id_and_token_on_success() { - let body = r#"{"data":{"server_id":"srv-123","token":"tok-abc"}}"#; - let (url, handle) = spawn_mock_server(vec![http_response(200, body)]).await; - let config = config_for(&url); + fn staging_persists_before_exposing_token_to_the_caller() { + let temp = TempDir::new().expect("tempdir"); + let path = temp.path().join("agent.toml"); + fs::write(&path, "server_url = \"http://127.0.0.1:9527\"\n").expect("seed"); + let mut config = config("http://127.0.0.1:9527".to_string()); - let (server_id, token) = register_once(&config, "fp-1").await.expect("register ok"); - assert_eq!(server_id, "srv-123"); - assert_eq!(token, "tok-abc"); + stage_run_token_at(&mut config, &path, false).expect("stage token"); - handle.abort(); + let persisted = fs::read_to_string(path).expect("read config"); + assert!(persisted.contains(&format!("token = \"{}\"", config.token))); } - #[tokio::test] - async fn register_once_trims_trailing_slash_in_server_url() { - let body = r#"{"data":{"server_id":"srv-9","token":"t9"}}"#; - let (url, handle) = spawn_mock_server(vec![http_response(200, body)]).await; - // Append a trailing slash; register_once must trim it before building - // the /api/agent/register path. - let config = config_for(&format!("{url}/")); + #[test] + fn persistence_failure_leaves_no_in_memory_credential() { + let temp = TempDir::new().expect("tempdir"); + let missing_parent = temp.path().join("missing").join("agent.toml"); + let mut config = config("http://127.0.0.1:9527".to_string()); - let result = register_once(&config, "").await; - assert!(result.is_ok(), "trailing slash should be trimmed: {result:?}"); + let result = stage_run_token_at(&mut config, missing_parent, false); - handle.abort(); + assert!(matches!(result, Err(RegisterError::Persistence(_)))); + assert!(config.token.is_empty()); } - // ---- register_once: invalid body ---- - #[tokio::test] - async fn register_once_maps_invalid_body_to_transient() { - // 200 OK but the body is not valid RegisterResponse JSON. - let (url, handle) = spawn_mock_server(vec![http_response(200, "not json")]).await; - let config = config_for(&url); - - let err = register_once(&config, "fp").await.expect_err("should fail"); - match err { - RegisterError::Transient(msg) => { - assert!( - msg.contains("invalid response body"), - "unexpected message: {msg}" - ); - } - other => panic!("expected Transient, got {other:?}"), - } - - handle.abort(); - } + async fn claim_sends_staged_token() { + let response = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 33\r\nConnection: close\r\n\r\n{\"data\":{\"server_id\":\"server-1\"}}"; + let (url, request_rx, handle) = spawn_server(response).await; + let mut config = config(url); + config.token = "staged-token-0123456789abcdefghijkl".to_string(); - // ---- register_once: status-code categorization ---- + let outcome = register_agent(&mut config).await.expect("claim"); + let request = request_rx.await.expect("request"); + handle.await.expect("server task"); - #[tokio::test] - async fn register_once_maps_401_to_permanent_auth() { - let (url, handle) = - spawn_mock_server(vec![http_response(401, "invalid enrollment code")]).await; - let config = config_for(&url); - - let err = register_once(&config, "fp").await.expect_err("should fail"); - match err { - RegisterError::PermanentAuth(msg) => { - assert!(msg.contains("401"), "expected status in msg: {msg}"); - assert!( - msg.contains("invalid enrollment code"), - "expected server body in msg: {msg}" - ); + assert_eq!( + outcome, + RegistrationOutcome::Confirmed { + server_id: "server-1".to_string() } - other => panic!("expected PermanentAuth, got {other:?}"), - } - - handle.abort(); - } - - #[tokio::test] - async fn register_once_maps_403_to_permanent_auth() { - let (url, handle) = spawn_mock_server(vec![http_response(403, "forbidden")]).await; - let config = config_for(&url); - - let err = register_once(&config, "fp").await.expect_err("should fail"); - assert!( - matches!(err, RegisterError::PermanentAuth(_)), - "expected PermanentAuth, got {err:?}" ); - - handle.abort(); + assert!(request.contains(&format!("\"proposed_run_token\":\"{}\"", config.token))); } #[tokio::test] - async fn register_once_maps_429_with_retry_after_header() { - let (url, handle) = spawn_mock_server(vec![http_response_with_header( - 429, - "Retry-After: 7", - "slow down", - )]) - .await; - let config = config_for(&url); - - let err = register_once(&config, "fp").await.expect_err("should fail"); - match err { - RegisterError::RateLimited { retry_after, message } => { - assert_eq!(retry_after, Duration::from_secs(7)); - assert!(message.contains("429"), "unexpected message: {message}"); - } - other => panic!("expected RateLimited, got {other:?}"), - } - - handle.abort(); - } - - #[tokio::test] - async fn register_once_429_without_retry_after_uses_default_backoff() { - // No Retry-After header → parse_retry_after returns None → default. - let (url, handle) = spawn_mock_server(vec![http_response(429, "throttled")]).await; - let config = config_for(&url); - - let err = register_once(&config, "fp").await.expect_err("should fail"); - match err { - RegisterError::RateLimited { retry_after, .. } => { - assert_eq!(retry_after, DEFAULT_RATE_LIMIT_BACKOFF); - } - other => panic!("expected RateLimited, got {other:?}"), - } - - handle.abort(); - } + async fn lost_success_body_is_ambiguous_and_keeps_staged_token() { + let response = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}"; + let (url, _request_rx, handle) = spawn_server(response).await; + let mut config = config(url); + config.token = "staged-token-0123456789abcdefghijkl".to_string(); - #[tokio::test] - async fn register_once_429_with_unparsable_retry_after_uses_default() { - // Non-numeric Retry-After (HTTP-date form unsupported) → falls back. - let (url, handle) = spawn_mock_server(vec![http_response_with_header( - 429, - "Retry-After: Wed, 21 Oct 2015 07:28:00 GMT", - "throttled", - )]) - .await; - let config = config_for(&url); - - let err = register_once(&config, "fp").await.expect_err("should fail"); - match err { - RegisterError::RateLimited { retry_after, .. } => { - assert_eq!(retry_after, DEFAULT_RATE_LIMIT_BACKOFF); - } - other => panic!("expected RateLimited, got {other:?}"), - } + let outcome = register_agent(&mut config).await.expect("ambiguous claim"); + handle.await.expect("server task"); - handle.abort(); - } - - #[tokio::test] - async fn register_once_maps_500_to_transient() { - let (url, handle) = spawn_mock_server(vec![http_response(500, "server boom")]).await; - let config = config_for(&url); - - let err = register_once(&config, "fp").await.expect_err("should fail"); - match err { - RegisterError::Transient(msg) => { - assert!(msg.contains("500"), "unexpected message: {msg}"); - } - other => panic!("expected Transient, got {other:?}"), - } - - handle.abort(); - } - - // ---- register_once: network error (no listener) ---- - - #[tokio::test] - async fn register_once_maps_connection_refused_to_transient() { - // Bind then immediately drop the listener so the port is closed; the - // POST should fail to connect and surface as a Transient network error. - let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); - let addr = listener.local_addr().expect("addr"); - drop(listener); - let config = config_for(&format!("http://{addr}")); - - let err = register_once(&config, "fp").await.expect_err("should fail"); - match err { - RegisterError::Transient(msg) => { - assert!(msg.contains("network error"), "unexpected message: {msg}"); - } - other => panic!("expected Transient, got {other:?}"), - } - } - - // ---- register_agent: wraps register_once errors ---- - - #[tokio::test] - async fn register_agent_wraps_error_with_guidance() { - let (url, handle) = spawn_mock_server(vec![http_response(401, "nope")]).await; - let config = config_for(&url); - - let err = register_agent(&config, "fp").await.expect_err("should fail"); - let msg = err.to_string(); - assert!(msg.contains("Registration failed"), "unexpected: {msg}"); - assert!(msg.contains("enrollment code"), "unexpected: {msg}"); - - handle.abort(); - } - - #[tokio::test] - async fn register_agent_returns_ok_tuple_on_success() { - let body = r#"{"data":{"server_id":"S","token":"T"}}"#; - let (url, handle) = spawn_mock_server(vec![http_response(200, body)]).await; - let config = config_for(&url); - - let (sid, tok) = register_agent(&config, "fp").await.expect("ok"); - assert_eq!(sid, "S"); - assert_eq!(tok, "T"); - - handle.abort(); - } - - // ---- register_agent_with_backoff ---- - - #[tokio::test] - async fn backoff_returns_immediately_on_success() { - let body = r#"{"data":{"server_id":"sid","token":"tk"}}"#; - let (url, handle) = spawn_mock_server(vec![http_response(200, body)]).await; - let config = config_for(&url); - - let (sid, tk) = register_agent_with_backoff(&config, "fp") - .await - .expect("ok"); - assert_eq!(sid, "sid"); - assert_eq!(tk, "tk"); - - handle.abort(); - } - - #[tokio::test] - async fn backoff_returns_immediately_on_permanent_auth() { - let (url, handle) = spawn_mock_server(vec![http_response(401, "bad")]).await; - let config = config_for(&url); - - let err = register_agent_with_backoff(&config, "fp") - .await - .expect_err("should fail"); - assert!( - matches!(err, RegisterError::PermanentAuth(_)), - "expected PermanentAuth, got {err:?}" - ); - - handle.abort(); - } - - #[tokio::test] - async fn backoff_honors_rate_limit_then_succeeds() { - // First connection: 429 with Retry-After: 0 (zero-second sleep keeps - // the test fast and deterministic). Second connection: 200 success. - let body = r#"{"data":{"server_id":"after-throttle","token":"ttt"}}"#; - let (url, handle) = spawn_mock_server(vec![ - http_response_with_header(429, "Retry-After: 0", "throttled"), - http_response(200, body), - ]) - .await; - let config = config_for(&url); - - let (sid, tk) = register_agent_with_backoff(&config, "fp") - .await - .expect("ok after retry"); - assert_eq!(sid, "after-throttle"); - assert_eq!(tk, "ttt"); - - handle.abort(); - } - - #[tokio::test] - async fn backoff_retries_transient_then_succeeds() { - // First connection: 500 (transient). Second connection: 200 success. - // We pause tokio time so the 5s exponential backoff sleep resolves - // instantly via auto-advance instead of stalling the test. - tokio::time::pause(); - - let body = r#"{"data":{"server_id":"recovered","token":"rk"}}"#; - let (url, handle) = spawn_mock_server(vec![ - http_response(500, "boom"), - http_response(200, body), - ]) - .await; - let config = config_for(&url); - - let (sid, tk) = register_agent_with_backoff(&config, "fp") - .await - .expect("ok after transient retry"); - assert_eq!(sid, "recovered"); - assert_eq!(tk, "rk"); - - handle.abort(); + assert_eq!(outcome, RegistrationOutcome::Ambiguous); + assert_eq!(config.token, "staged-token-0123456789abcdefghijkl"); } } diff --git a/crates/agent/src/reporter/mod.rs b/crates/agent/src/reporter/mod.rs index 815e0253..08d065a0 100644 --- a/crates/agent/src/reporter/mod.rs +++ b/crates/agent/src/reporter/mod.rs @@ -35,21 +35,15 @@ const MAX_REREGISTER_ATTEMPTS: u32 = 3; pub struct Reporter { config: AgentConfig, - fingerprint: String, capabilities: Arc<CapabilityAuthority>, firewall_manager: Arc<FirewallManager>, } impl Reporter { - pub fn new( - config: AgentConfig, - fingerprint: String, - capabilities: Arc<CapabilityAuthority>, - ) -> Self { + pub fn new(config: AgentConfig, capabilities: Arc<CapabilityAuthority>) -> Self { let firewall_manager = Arc::new(FirewallManager::new(Arc::new(CliNftExecutor))); Self { config, - fingerprint, capabilities, firewall_manager, } @@ -94,19 +88,17 @@ impl Reporter { ({reregister_attempts}/{MAX_REREGISTER_ATTEMPTS})" ); - match register::register_agent(&self.config, &self.fingerprint).await { - Ok((server_id, token)) => { + match register::register_agent(&mut self.config).await { + Ok(register::RegistrationOutcome::Confirmed { server_id }) => { tracing::info!( "Re-registration successful for server {server_id}" ); - if let Err(save_err) = register::save_token(&token) { - tracing::warn!( - "Failed to save refreshed token: {save_err}" - ); - } - self.config.token = token; // Do NOT skip backoff — prevents tight re-registration loop } + Ok(register::RegistrationOutcome::Ambiguous) => tracing::warn!( + "Re-registration response was ambiguous; the next WebSocket \ + attempt will verify the staged token" + ), Err(register_err) => { tracing::error!("Re-registration failed: {register_err}"); } @@ -1248,7 +1240,10 @@ mod tests { // returns Ok. let (listener, addr) = bind_fake_server().await; let tmp = tempfile::tempdir().unwrap(); - let mut reporter = Reporter::new(e2e_config(&addr, tmp.path()), "fp".to_string(), CapabilityAuthority::fixed(ALL_CAPS)); + let mut reporter = Reporter::new( + e2e_config(&addr, tmp.path()), + CapabilityAuthority::fixed(ALL_CAPS), + ); let server = tokio::spawn(async move { let mut ws = accept_ws(&listener).await; @@ -1293,7 +1288,10 @@ mod tests { // the fake server reads off the wire. let (listener, addr) = bind_fake_server().await; let tmp = tempfile::tempdir().unwrap(); - let mut reporter = Reporter::new(e2e_config(&addr, tmp.path()), "fp".to_string(), CapabilityAuthority::fixed(ALL_CAPS)); + let mut reporter = Reporter::new( + e2e_config(&addr, tmp.path()), + CapabilityAuthority::fixed(ALL_CAPS), + ); let server = tokio::spawn(async move { let mut ws = accept_ws(&listener).await; @@ -1328,7 +1326,10 @@ mod tests { // agent Pong over the real socket. let (listener, addr) = bind_fake_server().await; let tmp = tempfile::tempdir().unwrap(); - let mut reporter = Reporter::new(e2e_config(&addr, tmp.path()), "fp".to_string(), CapabilityAuthority::fixed(ALL_CAPS)); + let mut reporter = Reporter::new( + e2e_config(&addr, tmp.path()), + CapabilityAuthority::fixed(ALL_CAPS), + ); let server = tokio::spawn(async move { let mut ws = accept_ws(&listener).await; @@ -1357,7 +1358,10 @@ mod tests { // select! loop. let (listener, addr) = bind_fake_server().await; let tmp = tempfile::tempdir().unwrap(); - let mut reporter = Reporter::new(e2e_config(&addr, tmp.path()), "fp".to_string(), CapabilityAuthority::fixed(ALL_CAPS)); + let mut reporter = Reporter::new( + e2e_config(&addr, tmp.path()), + CapabilityAuthority::fixed(ALL_CAPS), + ); let server = tokio::spawn(async move { let mut ws = accept_ws(&listener).await; @@ -1406,7 +1410,10 @@ mod tests { // receive-dispatch path for a "silent" variant plus the Close arm. let (listener, addr) = bind_fake_server().await; let tmp = tempfile::tempdir().unwrap(); - let mut reporter = Reporter::new(e2e_config(&addr, tmp.path()), "fp".to_string(), CapabilityAuthority::fixed(ALL_CAPS)); + let mut reporter = Reporter::new( + e2e_config(&addr, tmp.path()), + CapabilityAuthority::fixed(ALL_CAPS), + ); let server = tokio::spawn(async move { let mut ws = accept_ws(&listener).await; @@ -1437,7 +1444,10 @@ mod tests { // Ok(()) (the normal-reconnect signal), not an error. let (listener, addr) = bind_fake_server().await; let tmp = tempfile::tempdir().unwrap(); - let mut reporter = Reporter::new(e2e_config(&addr, tmp.path()), "fp".to_string(), CapabilityAuthority::fixed(ALL_CAPS)); + let mut reporter = Reporter::new( + e2e_config(&addr, tmp.path()), + CapabilityAuthority::fixed(ALL_CAPS), + ); let server = tokio::spawn(async move { let mut ws = accept_ws(&listener).await; @@ -1471,7 +1481,10 @@ mod tests { // Drop the listener so the port refuses connections. drop(listener); let tmp = tempfile::tempdir().unwrap(); - let mut reporter = Reporter::new(e2e_config(&addr, tmp.path()), "fp".to_string(), CapabilityAuthority::fixed(ALL_CAPS)); + let mut reporter = Reporter::new( + e2e_config(&addr, tmp.path()), + CapabilityAuthority::fixed(ALL_CAPS), + ); let connect = run_connect_once(&mut reporter, Duration::from_secs(10)).await; let connect = connect.expect("connect should fail fast, not hang"); @@ -1490,7 +1503,10 @@ mod tests { // is bounded by an outer timeout that aborts the never-returning task. let (listener, addr) = bind_fake_server().await; let tmp = tempfile::tempdir().unwrap(); - let mut reporter = Reporter::new(e2e_config(&addr, tmp.path()), "fp".to_string(), CapabilityAuthority::fixed(ALL_CAPS)); + let mut reporter = Reporter::new( + e2e_config(&addr, tmp.path()), + CapabilityAuthority::fixed(ALL_CAPS), + ); // Server: accept two connections; each time send Welcome, read // SystemInfo, then close. Signal each successful handshake. @@ -1569,7 +1585,10 @@ mod tests { // the loop. let (listener, addr) = bind_fake_server().await; let tmp = tempfile::tempdir().unwrap(); - let mut reporter = Reporter::new(e2e_config(&addr, tmp.path()), "fp".to_string(), CapabilityAuthority::fixed(ALL_CAPS)); + let mut reporter = Reporter::new( + e2e_config(&addr, tmp.path()), + CapabilityAuthority::fixed(ALL_CAPS), + ); let server = tokio::spawn(async move { let mut ws = accept_ws(&listener).await; @@ -1602,7 +1621,10 @@ mod tests { // No WS frame results; we confirm liveness with Ping->Pong. let (listener, addr) = bind_fake_server().await; let tmp = tempfile::tempdir().unwrap(); - let mut reporter = Reporter::new(e2e_config(&addr, tmp.path()), "fp".to_string(), CapabilityAuthority::fixed(ALL_CAPS)); + let mut reporter = Reporter::new( + e2e_config(&addr, tmp.path()), + CapabilityAuthority::fixed(ALL_CAPS), + ); let server = tokio::spawn(async move { let mut ws = accept_ws(&listener).await; @@ -1640,7 +1662,10 @@ mod tests { let (listener, addr) = bind_fake_server().await; let tmp = tempfile::tempdir().unwrap(); let caps = ALL_CAPS & !serverbee_common::constants::CAP_TERMINAL; - let mut reporter = Reporter::new(e2e_config(&addr, tmp.path()), "fp".to_string(), CapabilityAuthority::fixed(caps)); + let mut reporter = Reporter::new( + e2e_config(&addr, tmp.path()), + CapabilityAuthority::fixed(caps), + ); let server = tokio::spawn(async move { let mut ws = accept_ws(&listener).await; @@ -1735,7 +1760,7 @@ mod tests { let authority = CapabilityAuthority::new(base, grants_path); tokio::spawn(Arc::clone(&authority).run(Duration::from_millis(100))); - let mut reporter = Reporter::new(config, "fp".to_string(), authority); + let mut reporter = Reporter::new(config, authority); let server = tokio::spawn(async move { let mut ws = accept_ws(&listener).await; @@ -1811,7 +1836,10 @@ mod tests { // ack, so the dispatcher always emits exactly one reply frame. let (listener, addr) = bind_fake_server().await; let tmp = tempfile::tempdir().unwrap(); - let mut reporter = Reporter::new(e2e_config(&addr, tmp.path()), "fp".to_string(), CapabilityAuthority::fixed(ALL_CAPS)); + let mut reporter = Reporter::new( + e2e_config(&addr, tmp.path()), + CapabilityAuthority::fixed(ALL_CAPS), + ); let server = tokio::spawn(async move { let mut ws = accept_ws(&listener).await; @@ -1837,7 +1865,10 @@ mod tests { // `nft` but the manager still returns a (failed-state) ack frame. let (listener, addr) = bind_fake_server().await; let tmp = tempfile::tempdir().unwrap(); - let mut reporter = Reporter::new(e2e_config(&addr, tmp.path()), "fp".to_string(), CapabilityAuthority::fixed(ALL_CAPS)); + let mut reporter = Reporter::new( + e2e_config(&addr, tmp.path()), + CapabilityAuthority::fixed(ALL_CAPS), + ); let server = tokio::spawn(async move { let mut ws = accept_ws(&listener).await; @@ -1867,7 +1898,10 @@ mod tests { let (listener, addr) = bind_fake_server().await; let tmp = tempfile::tempdir().unwrap(); let caps = ALL_CAPS & !serverbee_common::constants::CAP_FILE; - let mut reporter = Reporter::new(e2e_config(&addr, tmp.path()), "fp".to_string(), CapabilityAuthority::fixed(caps)); + let mut reporter = Reporter::new( + e2e_config(&addr, tmp.path()), + CapabilityAuthority::fixed(caps), + ); let server = tokio::spawn(async move { let mut ws = accept_ws(&listener).await; @@ -1914,7 +1948,7 @@ mod tests { let state = tempfile::tempdir().unwrap(); let mut config = e2e_config(&addr, state.path()); config.file = enabled_file_cfg(&root); - let mut reporter = Reporter::new(config, "fp".to_string(), CapabilityAuthority::fixed(ALL_CAPS)); + let mut reporter = Reporter::new(config, CapabilityAuthority::fixed(ALL_CAPS)); let dest = root.join("upload.bin"); let dest_s = dest.to_string_lossy().to_string(); @@ -1956,7 +1990,10 @@ mod tests { let (listener, addr) = bind_fake_server().await; let tmp = tempfile::tempdir().unwrap(); let caps = ALL_CAPS & !serverbee_common::constants::CAP_EXEC; - let mut reporter = Reporter::new(e2e_config(&addr, tmp.path()), "fp".to_string(), CapabilityAuthority::fixed(caps)); + let mut reporter = Reporter::new( + e2e_config(&addr, tmp.path()), + CapabilityAuthority::fixed(caps), + ); let server = tokio::spawn(async move { let mut ws = accept_ws(&listener).await; @@ -2002,7 +2039,10 @@ mod tests { let (listener, addr) = bind_fake_server().await; let tmp = tempfile::tempdir().unwrap(); let caps = ALL_CAPS & !serverbee_common::constants::CAP_UPGRADE; - let mut reporter = Reporter::new(e2e_config(&addr, tmp.path()), "fp".to_string(), CapabilityAuthority::fixed(caps)); + let mut reporter = Reporter::new( + e2e_config(&addr, tmp.path()), + CapabilityAuthority::fixed(caps), + ); let server = tokio::spawn(async move { let mut ws = accept_ws(&listener).await; @@ -2045,7 +2085,10 @@ mod tests { // channel and forwarded over the WS. No traceroute subprocess spawns. let (listener, addr) = bind_fake_server().await; let tmp = tempfile::tempdir().unwrap(); - let mut reporter = Reporter::new(e2e_config(&addr, tmp.path()), "fp".to_string(), CapabilityAuthority::fixed(ALL_CAPS)); + let mut reporter = Reporter::new( + e2e_config(&addr, tmp.path()), + CapabilityAuthority::fixed(ALL_CAPS), + ); let server = tokio::spawn(async move { let mut ws = accept_ws(&listener).await; @@ -2087,7 +2130,10 @@ mod tests { // running, proven by a subsequent Ping->Pong. let (listener, addr) = bind_fake_server().await; let tmp = tempfile::tempdir().unwrap(); - let mut reporter = Reporter::new(e2e_config(&addr, tmp.path()), "fp".to_string(), CapabilityAuthority::fixed(ALL_CAPS)); + let mut reporter = Reporter::new( + e2e_config(&addr, tmp.path()), + CapabilityAuthority::fixed(ALL_CAPS), + ); let server = tokio::spawn(async move { let mut ws = accept_ws(&listener).await; @@ -2115,7 +2161,10 @@ mod tests { // dispatching application messages via a ServerMessage::Ping->Pong. let (listener, addr) = bind_fake_server().await; let tmp = tempfile::tempdir().unwrap(); - let mut reporter = Reporter::new(e2e_config(&addr, tmp.path()), "fp".to_string(), CapabilityAuthority::fixed(ALL_CAPS)); + let mut reporter = Reporter::new( + e2e_config(&addr, tmp.path()), + CapabilityAuthority::fixed(ALL_CAPS), + ); let server = tokio::spawn(async move { let mut ws = accept_ws(&listener).await; diff --git a/crates/agent/src/rebind.rs b/crates/agent/src/run_token_store.rs similarity index 81% rename from crates/agent/src/rebind.rs rename to crates/agent/src/run_token_store.rs index ad100cb0..e879eafa 100644 --- a/crates/agent/src/rebind.rs +++ b/crates/agent/src/run_token_store.rs @@ -42,7 +42,7 @@ fn is_table_header(line: &str) -> bool { trimmed.starts_with('[') && trimmed.ends_with(']') } -pub(crate) fn persist_rebind_token_impl(path: impl AsRef<Path>, token: &str) -> anyhow::Result<()> { +pub(crate) fn persist_run_token(path: impl AsRef<Path>, token: &str) -> anyhow::Result<()> { let path = path.as_ref(); let existing = if path.exists() { fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))? @@ -54,15 +54,20 @@ pub(crate) fn persist_rebind_token_impl(path: impl AsRef<Path>, token: &str) -> let parent = path.parent().unwrap_or_else(|| Path::new(".")); let file_name = path.file_name().unwrap_or_else(|| OsStr::new("agent.toml")); let temp_path = parent.join(format!( - ".{}.rebind.{}.tmp", + ".{}.run-token.{}.tmp", file_name.to_string_lossy(), uuid::Uuid::new_v4() )); let write_result = (|| -> anyhow::Result<()> { - let mut temp_file = OpenOptions::new() - .create_new(true) - .write(true) + let mut options = OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut temp_file = options .open(&temp_path) .with_context(|| format!("failed to create {}", temp_path.display()))?; temp_file @@ -71,6 +76,7 @@ pub(crate) fn persist_rebind_token_impl(path: impl AsRef<Path>, token: &str) -> temp_file .sync_all() .with_context(|| format!("failed to sync {}", temp_path.display()))?; + #[cfg(not(unix))] if path.exists() && let Ok(metadata) = fs::metadata(path) { @@ -97,9 +103,6 @@ pub(crate) fn persist_rebind_token_impl(path: impl AsRef<Path>, token: &str) -> write_result } -#[cfg(not(test))] -pub(crate) use persist_rebind_token_impl as persist_rebind_token; - #[cfg(unix)] fn replace_file(temp_path: &Path, path: &Path) -> anyhow::Result<()> { fs::rename(temp_path, path).with_context(|| { @@ -146,12 +149,12 @@ fn replace_file(temp_path: &Path, path: &Path) -> anyhow::Result<()> { } #[cfg(test)] -pub(crate) fn assert_persist_rebind_token() { +pub(crate) fn assert_persist_run_token() { let tempdir = tempfile::TempDir::new().expect("tempdir"); let path = tempdir.path().join("agent.toml"); fs::write(&path, "server_url = \"http://127.0.0.1:9527\"\n").expect("seed file"); - persist_rebind_token_impl(&path, "focused-token").expect("persist"); + persist_run_token(&path, "focused-token").expect("persist"); let content = fs::read_to_string(&path).expect("read file"); assert_eq!( @@ -167,8 +170,23 @@ mod tests { use super::*; use tempfile::TempDir; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + + #[cfg(unix)] + #[test] + fn persist_run_token_creates_owner_only_config() { + let tempdir = TempDir::new().expect("tempdir"); + let path = tempdir.path().join("agent.toml"); + + super::persist_run_token(&path, "fresh-token").expect("persist"); + + let mode = fs::metadata(path).expect("metadata").permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } + #[test] - fn persist_rebind_token_replaces_existing_token_line_without_touching_other_lines() { + fn persist_run_token_replaces_existing_token_line_without_touching_other_lines() { let tempdir = TempDir::new().expect("tempdir"); let path = tempdir.path().join("agent.toml"); fs::write( @@ -179,7 +197,7 @@ log.level = "debug""#, ) .expect("seed file"); - super::persist_rebind_token_impl(&path, "new-token").expect("persist"); + super::persist_run_token(&path, "new-token").expect("persist"); let content = fs::read_to_string(&path).expect("read file"); assert_eq!( @@ -191,12 +209,12 @@ log.level = "debug""# } #[test] - fn persist_rebind_token_appends_token_line_when_missing() { + fn persist_run_token_appends_token_line_when_missing() { let tempdir = TempDir::new().expect("tempdir"); let path = tempdir.path().join("agent.toml"); fs::write(&path, "server_url = \"http://127.0.0.1:9527\"\n").expect("seed file"); - super::persist_rebind_token_impl(&path, "fresh-token").expect("persist"); + super::persist_run_token(&path, "fresh-token").expect("persist"); let content = fs::read_to_string(&path).expect("read file"); assert_eq!( @@ -208,7 +226,7 @@ token = "fresh-token" } #[test] - fn persist_rebind_token_inserts_before_first_table_header() { + fn persist_run_token_inserts_before_first_table_header() { let tempdir = TempDir::new().expect("tempdir"); let path = tempdir.path().join("agent.toml"); fs::write( @@ -221,7 +239,7 @@ level = "info""#, ) .expect("seed file"); - super::persist_rebind_token_impl(&path, "fresh-token").expect("persist"); + super::persist_run_token(&path, "fresh-token").expect("persist"); let content = fs::read_to_string(&path).expect("read file"); assert_eq!( @@ -236,19 +254,19 @@ level = "info""# } #[test] - fn persist_rebind_token_preserves_trailing_newline() { + fn persist_run_token_preserves_trailing_newline() { let tempdir = TempDir::new().expect("tempdir"); let path = tempdir.path().join("agent.toml"); fs::write(&path, "server_url = \"http://127.0.0.1:9527\"\n").expect("seed file"); - super::persist_rebind_token_impl(&path, "fresh-token").expect("persist"); + super::persist_run_token(&path, "fresh-token").expect("persist"); let content = fs::read_to_string(&path).expect("read file"); assert!(content.ends_with('\n')); } #[test] - fn persist_rebind_token_preserves_nested_token_and_inserts_top_level_token() { + fn persist_run_token_preserves_nested_token_and_inserts_top_level_token() { let tempdir = TempDir::new().expect("tempdir"); let path = tempdir.path().join("agent.toml"); fs::write( @@ -262,7 +280,7 @@ level = "info""#, ) .expect("seed file"); - super::persist_rebind_token_impl(&path, "top-level").expect("persist"); + super::persist_run_token(&path, "top-level").expect("persist"); let content = fs::read_to_string(&path).expect("read file"); assert_eq!( @@ -278,21 +296,21 @@ level = "info""# } #[test] - fn persist_rebind_token_creates_file_when_absent() { + fn persist_run_token_creates_file_when_absent() { // Path does not exist → existing is empty; the rendered output is just // the token line with no trailing newline. let tempdir = TempDir::new().expect("tempdir"); let path = tempdir.path().join("agent.toml"); assert!(!path.exists()); - super::persist_rebind_token_impl(&path, "brand-new").expect("persist"); + super::persist_run_token(&path, "brand-new").expect("persist"); let content = fs::read_to_string(&path).expect("read file"); assert_eq!(content, "token = \"brand-new\""); } #[test] - fn persist_rebind_token_replaces_indented_token_line() { + fn persist_run_token_replaces_indented_token_line() { // A leading-whitespace token line is recognised and replaced wholesale // (the replacement is not indented). let tempdir = TempDir::new().expect("tempdir"); @@ -303,7 +321,7 @@ level = "info""# ) .expect("seed file"); - super::persist_rebind_token_impl(&path, "replaced").expect("persist"); + super::persist_run_token(&path, "replaced").expect("persist"); let content = fs::read_to_string(&path).expect("read file"); assert_eq!( @@ -340,8 +358,8 @@ level = "info""# } #[test] - fn run_assert_persist_rebind_token_helper() { + fn run_assert_persist_run_token_helper() { // Exercise the standalone shared assertion helper. - super::assert_persist_rebind_token(); + super::assert_persist_run_token(); } } diff --git a/crates/common/src/protocol.rs b/crates/common/src/protocol.rs index 011905dd..b86f047c 100644 --- a/crates/common/src/protocol.rs +++ b/crates/common/src/protocol.rs @@ -595,6 +595,10 @@ pub enum BrowserMessage { ServerOffline { server_id: String, }, + AgentAuthorityChanged { + server_id: String, + agent_authority: crate::types::AgentAuthorityStateSummary, + }, CapabilitiesChanged { server_id: String, capabilities: u32, @@ -831,6 +835,37 @@ mod tests { } } + #[test] + fn test_browser_agent_authority_changed_round_trip() { + let msg = BrowserMessage::AgentAuthorityChanged { + server_id: "server-1".to_string(), + agent_authority: crate::types::AgentAuthorityStateSummary { + status: crate::types::AgentAuthorityStatus::Unclaimed, + outstanding_offer: None, + }, + }; + let json = serde_json::to_string(&msg).unwrap(); + assert_eq!( + json, + r#"{"type":"agent_authority_changed","server_id":"server-1","agent_authority":{"status":"unclaimed","outstanding_offer":null}}"# + ); + + match serde_json::from_str::<BrowserMessage>(&json).unwrap() { + BrowserMessage::AgentAuthorityChanged { + server_id, + agent_authority, + } => { + assert_eq!(server_id, "server-1"); + assert_eq!( + agent_authority.status, + crate::types::AgentAuthorityStatus::Unclaimed + ); + assert!(agent_authority.outstanding_offer.is_none()); + } + _ => panic!("Expected AgentAuthorityChanged"), + } + } + #[test] fn test_network_probe_sync_serializes() { let msg = ServerMessage::NetworkProbeSync { diff --git a/crates/common/src/types.rs b/crates/common/src/types.rs index a2c67af7..82e59191 100644 --- a/crates/common/src/types.rs +++ b/crates/common/src/types.rs @@ -180,6 +180,22 @@ pub struct OutstandingEnrollmentSummary { pub created_at: String, } +#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "lowercase")] +pub enum AgentAuthorityStatus { + #[default] + Claimed, + Unclaimed, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +pub struct AgentAuthorityStateSummary { + pub status: AgentAuthorityStatus, + pub outstanding_offer: Option<OutstandingEnrollmentSummary>, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ServerStatus { pub id: String, @@ -219,6 +235,10 @@ pub struct ServerStatus { pub tags: Vec<String>, #[serde(default)] pub cpu_cores: Option<i32>, + /// Canonical Agent credential and Enrollment-offer projection. Online + /// state remains independent and is represented by `online`. + #[serde(default)] + pub agent_authority: AgentAuthorityStateSummary, /// `true` iff the server row has a non-NULL `token_hash`. Pending servers /// (created via `POST /api/servers` but not yet enrolled by an agent) have /// `has_token = false`. Defaults to `true` for backward compatibility with @@ -363,7 +383,7 @@ mod tests { } /// Build a `ServerStatus` JSON object with every required field populated. - /// `has_token` and `outstanding_enrollment` are intentionally omitted so + /// Agent authority compatibility fields are intentionally omitted so /// callers can assert the serde defaults. fn server_status_json_without_token() -> serde_json::Value { json!({ @@ -405,6 +425,8 @@ mod tests { let status: ServerStatus = serde_json::from_value(legacy).unwrap(); assert!(status.has_token, "missing has_token must default to true"); assert!(status.outstanding_enrollment.is_none()); + assert_eq!(status.agent_authority.status, AgentAuthorityStatus::Claimed); + assert!(status.agent_authority.outstanding_offer.is_none()); } #[test] diff --git a/crates/server/src/dev_demo.rs b/crates/server/src/dev_demo.rs index ccf38974..71df379e 100644 --- a/crates/server/src/dev_demo.rs +++ b/crates/server/src/dev_demo.rs @@ -568,7 +568,6 @@ async fn seed_servers(db: &DatabaseConnection, now: chrono::DateTime<Utc>) -> Re protocol_version: Set(PROTOCOL_VERSION as i32), features: Set("[]".to_string()), last_remote_addr: Set(Some(spec.ipv4.to_string())), - fingerprint: Set(Some(format!("dev-demo-fingerprint-{}", spec.id_suffix))), created_at: Set(now - Duration::days(45 - index as i64)), updated_at: Set(now - Duration::seconds((index % 4) as i64 * 11)), } diff --git a/crates/server/src/entity/agent_authority_event.rs b/crates/server/src/entity/agent_authority_event.rs new file mode 100644 index 00000000..794c3760 --- /dev/null +++ b/crates/server/src/entity/agent_authority_event.rs @@ -0,0 +1,25 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "agent_authority_events")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: String, + pub server_id: String, + pub server_name: String, + pub actor_kind: String, + pub actor_id: Option<String>, + pub request_source: String, + pub offer_id: Option<String>, + pub transition: String, + pub mode: Option<String>, + pub offer_outcome: Option<String>, + pub authority_before: String, + pub authority_after: String, + pub created_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/server/src/entity/agent_enrollment.rs b/crates/server/src/entity/enrollment_offer.rs similarity index 66% rename from crates/server/src/entity/agent_enrollment.rs rename to crates/server/src/entity/enrollment_offer.rs index 2d9c03ab..66843c02 100644 --- a/crates/server/src/entity/agent_enrollment.rs +++ b/crates/server/src/entity/enrollment_offer.rs @@ -1,7 +1,7 @@ use sea_orm::entity::prelude::*; #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] -#[sea_orm(table_name = "agent_enrollments")] +#[sea_orm(table_name = "enrollment_offers")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: String, @@ -11,19 +11,14 @@ pub struct Model { pub target_server_id: String, pub created_by: String, pub expires_at: DateTimeUtc, - pub consumed_at: Option<DateTimeUtc>, - pub revoked_at: Option<DateTimeUtc>, + pub outcome: Option<String>, + pub terminal_at: Option<DateTimeUtc>, + pub successor_offer_id: Option<String>, pub created_at: DateTimeUtc, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation { - #[sea_orm( - belongs_to = "super::user::Entity", - from = "Column::CreatedBy", - to = "super::user::Column::Id" - )] - User, #[sea_orm( belongs_to = "super::server::Entity", from = "Column::TargetServerId", @@ -32,12 +27,6 @@ pub enum Relation { Server, } -impl Related<super::user::Entity> for Entity { - fn to() -> RelationDef { - Relation::User.def() - } -} - impl Related<super::server::Entity> for Entity { fn to() -> RelationDef { Relation::Server.def() diff --git a/crates/server/src/entity/mod.rs b/crates/server/src/entity/mod.rs index 8ec9b0ff..c192052e 100644 --- a/crates/server/src/entity/mod.rs +++ b/crates/server/src/entity/mod.rs @@ -1,4 +1,4 @@ -pub mod agent_enrollment; +pub mod agent_authority_event; pub mod alert_rule; pub mod alert_state; pub mod api_key; @@ -9,6 +9,7 @@ pub mod dashboard; pub mod dashboard_widget; pub mod device_token; pub mod docker_event; +pub mod enrollment_offer; pub mod gpu_record; pub mod incident; pub mod incident_update; @@ -31,6 +32,7 @@ pub mod record_hourly; pub mod security_event; pub mod server; pub mod server_group; +pub mod server_onboarding_request; pub mod server_tag; pub mod service_monitor; pub mod service_monitor_record; diff --git a/crates/server/src/entity/server.rs b/crates/server/src/entity/server.rs index 900fc539..6efc0446 100644 --- a/crates/server/src/entity/server.rs +++ b/crates/server/src/entity/server.rs @@ -46,7 +46,6 @@ pub struct Model { #[sea_orm(default_value = "[]")] pub features: String, pub last_remote_addr: Option<String>, - pub fingerprint: Option<String>, pub created_at: DateTimeUtc, pub updated_at: DateTimeUtc, } diff --git a/crates/server/src/entity/server_onboarding_request.rs b/crates/server/src/entity/server_onboarding_request.rs new file mode 100644 index 00000000..8e7cf342 --- /dev/null +++ b/crates/server/src/entity/server_onboarding_request.rs @@ -0,0 +1,31 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "server_onboarding_requests")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: String, + pub actor_id: String, + pub request_id: String, + pub normalized_input_hash: String, + pub server_id: String, + pub created_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::server::Entity", + from = "Column::ServerId", + to = "super::server::Column::Id" + )] + Server, +} + +impl Related<super::server::Entity> for Entity { + fn to() -> RelationDef { + Relation::Server.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/server/src/migration/m20260713_000075_agent_authority_lifecycle.rs b/crates/server/src/migration/m20260713_000075_agent_authority_lifecycle.rs new file mode 100644 index 00000000..47a8628c --- /dev/null +++ b/crates/server/src/migration/m20260713_000075_agent_authority_lifecycle.rs @@ -0,0 +1,130 @@ +use sea_orm_migration::prelude::*; + +pub struct Migration; + +impl MigrationName for Migration { + fn name(&self) -> &str { + "m20260713_000075_agent_authority_lifecycle" + } +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + db.execute_unprepared( + r#" + CREATE TABLE enrollment_offers ( + id TEXT PRIMARY KEY NOT NULL, + code_hash TEXT NOT NULL, + code_prefix TEXT NOT NULL, + target_server_id TEXT NOT NULL + REFERENCES servers(id) ON DELETE CASCADE, + created_by TEXT NOT NULL, + expires_at TIMESTAMP NOT NULL, + outcome TEXT, + terminal_at TIMESTAMP, + successor_offer_id TEXT, + created_at TIMESTAMP NOT NULL, + CHECK (outcome IS NULL OR outcome IN ('consumed', 'revoked', 'replaced', 'expired')), + CHECK ( + (outcome IS NULL AND terminal_at IS NULL AND successor_offer_id IS NULL) + OR + (outcome IS NOT NULL AND terminal_at IS NOT NULL) + ), + CHECK ( + (outcome = 'replaced' AND successor_offer_id IS NOT NULL) + OR + (outcome IS NULL) + OR + (outcome != 'replaced' AND successor_offer_id IS NULL) + ) + ); + + INSERT INTO enrollment_offers ( + id, code_hash, code_prefix, target_server_id, created_by, + expires_at, outcome, terminal_at, successor_offer_id, created_at + ) + SELECT + id, + code_hash, + code_prefix, + target_server_id, + created_by, + expires_at, + CASE + WHEN consumed_at IS NOT NULL THEN 'consumed' + WHEN revoked_at IS NOT NULL THEN 'revoked' + WHEN expires_at <= CURRENT_TIMESTAMP THEN 'expired' + ELSE NULL + END, + CASE + WHEN consumed_at IS NOT NULL THEN consumed_at + WHEN revoked_at IS NOT NULL THEN revoked_at + WHEN expires_at <= CURRENT_TIMESTAMP THEN expires_at + ELSE NULL + END, + NULL, + created_at + FROM agent_enrollments; + + DROP TABLE agent_enrollments; + + CREATE UNIQUE INDEX idx_enrollment_offers_outstanding_per_server + ON enrollment_offers(target_server_id) + WHERE outcome IS NULL; + CREATE INDEX idx_enrollment_offers_code_prefix + ON enrollment_offers(code_prefix); + CREATE INDEX idx_enrollment_offers_server_created + ON enrollment_offers(target_server_id, created_at DESC); + + CREATE TABLE agent_authority_events ( + id TEXT PRIMARY KEY NOT NULL, + server_id TEXT NOT NULL, + server_name TEXT NOT NULL, + actor_kind TEXT NOT NULL CHECK (actor_kind IN ('user', 'system', 'agent')), + actor_id TEXT, + request_source TEXT NOT NULL, + offer_id TEXT, + transition TEXT NOT NULL CHECK (transition IN ( + 'initial_offer_issued', 'offer_issued', 'reenrollment_started', + 'offer_consumed', 'offer_revoked', 'offer_replaced', + 'offer_expired', 'authority_revoked', 'server_deleted' + )), + mode TEXT CHECK (mode IS NULL OR mode IN ('graceful', 'emergency')), + offer_outcome TEXT, + authority_before TEXT NOT NULL + CHECK (authority_before IN ('claimed', 'unclaimed')), + authority_after TEXT NOT NULL + CHECK (authority_after IN ('claimed', 'unclaimed')), + created_at TIMESTAMP NOT NULL, + CHECK (offer_outcome IS NULL OR offer_outcome IN ('consumed', 'revoked', 'replaced', 'expired')) + ); + CREATE INDEX idx_agent_authority_events_server_created + ON agent_authority_events(server_id, created_at DESC); + + CREATE TABLE server_onboarding_requests ( + id TEXT PRIMARY KEY NOT NULL, + actor_id TEXT NOT NULL, + request_id TEXT NOT NULL, + normalized_input_hash TEXT NOT NULL, + server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + created_at TIMESTAMP NOT NULL, + UNIQUE(actor_id, request_id) + ); + CREATE INDEX idx_server_onboarding_requests_server + ON server_onboarding_requests(server_id); + + ALTER TABLE servers DROP COLUMN fingerprint; + "#, + ) + .await?; + + Ok(()) + } + + async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> { + Ok(()) + } +} diff --git a/crates/server/src/migration/mod.rs b/crates/server/src/migration/mod.rs index 6c8ef5b4..74160677 100644 --- a/crates/server/src/migration/mod.rs +++ b/crates/server/src/migration/mod.rs @@ -43,6 +43,7 @@ mod m20260619_000071_add_password_changed_at; mod m20260621_000072_add_geo_manual; mod m20260702_000073_retention_time_indexes; mod m20260702_000074_hash_existing_session_tokens; +mod m20260713_000075_agent_authority_lifecycle; pub struct Migrator; @@ -92,6 +93,244 @@ impl MigratorTrait for Migrator { Box::new(m20260621_000072_add_geo_manual::Migration), Box::new(m20260702_000073_retention_time_indexes::Migration), Box::new(m20260702_000074_hash_existing_session_tokens::Migration), + Box::new(m20260713_000075_agent_authority_lifecycle::Migration), ] } } + +#[cfg(test)] +mod tests { + use sea_orm::{ConnectionTrait, Database, DatabaseBackend, Statement}; + use sea_orm_migration::MigratorTrait; + + use super::Migrator; + + async fn migrated_db() -> sea_orm::DatabaseConnection { + let db = Database::connect("sqlite::memory:") + .await + .expect("connect in-memory sqlite"); + Migrator::up(&db, None).await.expect("run migrations"); + db + } + + async fn seed_user_and_server(db: &sea_orm::DatabaseConnection, server_id: &str) { + db.execute(Statement::from_string( + DatabaseBackend::Sqlite, + "INSERT OR IGNORE INTO users (id, username, password_hash, role, must_change_password, created_at, updated_at) VALUES ('actor-1', 'actor-1', 'hash', 'admin', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)".to_string(), + )) + .await + .expect("seed actor"); + db.execute(Statement::from_string( + DatabaseBackend::Sqlite, + format!( + "INSERT INTO servers (id, name, weight, hidden, capabilities, protocol_version, features, geo_manual, created_at, updated_at) VALUES ('{server_id}', 'Server {server_id}', 0, 0, 0, 1, '[]', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)" + ), + )) + .await + .expect("seed server"); + } + + #[tokio::test] + async fn agent_authority_schema_replaces_fingerprint_and_legacy_enrollments() { + let db = migrated_db().await; + + let server_columns = db + .query_all(Statement::from_string( + DatabaseBackend::Sqlite, + "PRAGMA table_info('servers')".to_string(), + )) + .await + .expect("inspect servers schema"); + let server_column_names: Vec<String> = server_columns + .iter() + .map(|row| row.try_get("", "name").expect("column name")) + .collect(); + assert!( + !server_column_names.iter().any(|name| name == "fingerprint"), + "live servers schema must not retain machine fingerprints" + ); + + let offer_columns = db + .query_all(Statement::from_string( + DatabaseBackend::Sqlite, + "PRAGMA table_info('enrollment_offers')".to_string(), + )) + .await + .expect("inspect enrollment offer schema"); + assert!(!offer_columns.is_empty(), "enrollment_offers must exist"); + + let legacy_table = db + .query_one(Statement::from_string( + DatabaseBackend::Sqlite, + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'agent_enrollments'" + .to_string(), + )) + .await + .expect("inspect legacy enrollment table"); + assert!( + legacy_table.is_none(), + "legacy agent_enrollments must be removed" + ); + } + + #[tokio::test] + async fn agent_authority_offer_constraints_enforce_one_exclusive_outcome() { + let db = migrated_db().await; + seed_user_and_server(&db, "server-1").await; + + let insert_outstanding = |id: &str| { + Statement::from_string( + DatabaseBackend::Sqlite, + format!( + "INSERT INTO enrollment_offers (id, code_hash, code_prefix, target_server_id, created_by, expires_at, created_at) VALUES ('{id}', 'hash', 'prefix01', 'server-1', 'actor-1', '2999-01-01 00:00:00', CURRENT_TIMESTAMP)" + ), + ) + }; + db.execute(insert_outstanding("offer-1")) + .await + .expect("insert first outstanding offer"); + assert!( + db.execute(insert_outstanding("offer-2")).await.is_err(), + "a Server cannot have two Outstanding offers" + ); + + let missing_terminal_time = db + .execute(Statement::from_string( + DatabaseBackend::Sqlite, + "INSERT INTO enrollment_offers (id, code_hash, code_prefix, target_server_id, created_by, expires_at, outcome, created_at) VALUES ('bad-consumed', 'hash', 'prefix02', 'server-1', 'actor-1', '2999-01-01 00:00:00', 'consumed', CURRENT_TIMESTAMP)".to_string(), + )) + .await; + assert!( + missing_terminal_time.is_err(), + "a terminal outcome must carry terminal_at" + ); + + let replaced_without_successor = db + .execute(Statement::from_string( + DatabaseBackend::Sqlite, + "INSERT INTO enrollment_offers (id, code_hash, code_prefix, target_server_id, created_by, expires_at, outcome, terminal_at, created_at) VALUES ('bad-replaced', 'hash', 'prefix03', 'server-1', 'actor-1', '2999-01-01 00:00:00', 'replaced', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)".to_string(), + )) + .await; + assert!( + replaced_without_successor.is_err(), + "Replaced must identify its successor" + ); + + let successor_on_consumed = db + .execute(Statement::from_string( + DatabaseBackend::Sqlite, + "INSERT INTO enrollment_offers (id, code_hash, code_prefix, target_server_id, created_by, expires_at, outcome, terminal_at, successor_offer_id, created_at) VALUES ('bad-successor', 'hash', 'prefix04', 'server-1', 'actor-1', '2999-01-01 00:00:00', 'consumed', CURRENT_TIMESTAMP, 'offer-9', CURRENT_TIMESTAMP)".to_string(), + )) + .await; + assert!( + successor_on_consumed.is_err(), + "only Replaced may identify a successor" + ); + } + + #[tokio::test] + async fn agent_authority_migration_converts_legacy_terminal_facts() { + let db = Database::connect("sqlite::memory:") + .await + .expect("connect in-memory sqlite"); + let migrations_before_authority = Migrator::migrations().len() as u32 - 1; + Migrator::up(&db, Some(migrations_before_authority)) + .await + .expect("run legacy migrations"); + seed_user_and_server(&db, "legacy-server").await; + seed_user_and_server(&db, "legacy-expired-server").await; + + db.execute(Statement::from_string( + DatabaseBackend::Sqlite, + r#" + INSERT INTO agent_enrollments (id, code_hash, code_prefix, target_server_id, created_by, expires_at, consumed_at, revoked_at, created_at) VALUES + ('double-terminal', 'hash', 'double01', 'legacy-server', 'actor-1', '2999-01-01 00:00:00', '2026-01-01 00:00:00', '2026-01-02 00:00:00', '2026-01-01 00:00:00'), + ('revoked', 'hash', 'revoke01', 'legacy-server', 'actor-1', '2999-01-01 00:00:00', NULL, '2026-01-02 00:00:00', '2026-01-01 00:00:00'), + ('expired', 'hash', 'expire01', 'legacy-expired-server', 'actor-1', '2026-01-01 00:00:00', NULL, NULL, '2026-01-01 00:00:00'), + ('outstanding', 'hash', 'open0001', 'legacy-server', 'actor-1', '2999-01-01 00:00:00', NULL, NULL, '2026-01-01 00:00:00') + "# + .to_string(), + )) + .await + .expect("seed legacy offers"); + + Migrator::up(&db, None) + .await + .expect("run authority migration"); + + let rows = db + .query_all(Statement::from_string( + DatabaseBackend::Sqlite, + "SELECT id, outcome FROM enrollment_offers ORDER BY id".to_string(), + )) + .await + .expect("read migrated offers"); + let outcomes: std::collections::HashMap<String, Option<String>> = rows + .iter() + .map(|row| { + ( + row.try_get("", "id").expect("offer id"), + row.try_get("", "outcome").expect("offer outcome"), + ) + }) + .collect(); + assert_eq!( + outcomes.get("double-terminal"), + Some(&Some("consumed".to_string())), + "legacy consume wins over the later revoke bug" + ); + assert_eq!(outcomes.get("revoked"), Some(&Some("revoked".to_string()))); + assert_eq!(outcomes.get("expired"), Some(&Some("expired".to_string()))); + assert_eq!(outcomes.get("outstanding"), Some(&None)); + } + + #[tokio::test] + async fn agent_authority_events_survive_server_deletion_without_secrets() { + let db = migrated_db().await; + seed_user_and_server(&db, "server-delete").await; + db.execute(Statement::from_string( + DatabaseBackend::Sqlite, + "INSERT INTO enrollment_offers (id, code_hash, code_prefix, target_server_id, created_by, expires_at, created_at) VALUES ('offer-delete', 'secret-hash', 'prefix05', 'server-delete', 'actor-1', '2999-01-01 00:00:00', CURRENT_TIMESTAMP)".to_string(), + )) + .await + .expect("insert offer"); + db.execute(Statement::from_string( + DatabaseBackend::Sqlite, + "INSERT INTO agent_authority_events (id, server_id, server_name, actor_kind, actor_id, request_source, offer_id, transition, authority_before, authority_after, created_at) VALUES ('event-delete', 'server-delete', 'Deleted Server', 'user', 'actor-1', 'api', 'offer-delete', 'server_deleted', 'unclaimed', 'unclaimed', CURRENT_TIMESTAMP)".to_string(), + )) + .await + .expect("insert authority event"); + + db.execute(Statement::from_string( + DatabaseBackend::Sqlite, + "DELETE FROM servers WHERE id = 'server-delete'".to_string(), + )) + .await + .expect("delete server"); + + let offer_count: i64 = db + .query_one(Statement::from_string( + DatabaseBackend::Sqlite, + "SELECT COUNT(*) AS count FROM enrollment_offers WHERE target_server_id = 'server-delete'" + .to_string(), + )) + .await + .expect("count offers") + .expect("count row") + .try_get("", "count") + .expect("offer count"); + let event_count: i64 = db + .query_one(Statement::from_string( + DatabaseBackend::Sqlite, + "SELECT COUNT(*) AS count FROM agent_authority_events WHERE server_id = 'server-delete'" + .to_string(), + )) + .await + .expect("count events") + .expect("count row") + .try_get("", "count") + .expect("event count"); + assert_eq!(offer_count, 0, "credential-bearing offers cascade"); + assert_eq!(event_count, 1, "secret-free authority history is retained"); + } +} diff --git a/crates/server/src/openapi.rs b/crates/server/src/openapi.rs index cd2ca0be..d8a6df7e 100644 --- a/crates/server/src/openapi.rs +++ b/crates/server/src/openapi.rs @@ -72,14 +72,16 @@ impl Modify for SecurityAddon { // agent crate::router::api::agent::latest_version, crate::router::api::agent::register, - crate::router::api::agent::list_enrollments, - crate::router::api::agent::delete_enrollment, - crate::router::api::agent::rotate_token, // servers crate::router::api::server::list_servers, crate::router::api::server::create_server, - crate::router::api::server::recover_server, - crate::router::api::server::regenerate_code, + crate::router::api::server::begin_reenrollment, + crate::router::api::server::issue_offer_for_unclaimed, + crate::router::api::server::replace_offer, + crate::router::api::server::revoke_offer, + crate::router::api::server::revoke_agent_authority, + crate::router::api::server::get_agent_authority, + crate::router::api::server::get_authority_history, crate::router::api::server::get_server, crate::router::api::server::update_server, crate::router::api::server::delete_server, @@ -279,19 +281,23 @@ impl Modify for SecurityAddon { crate::router::api::oauth::OAuthProvidersResponse, // agent crate::service::upgrade_release::LatestAgentVersionResponse, + crate::router::api::agent::RegisterRequest, crate::router::api::agent::RegisterResponse, - crate::router::api::agent::EnrollmentSummary, - crate::router::api::agent::RotateTokenResponse, // servers crate::router::api::server::ServerResponse, crate::router::api::server::TemporaryGrantDto, serverbee_common::types::OutstandingEnrollmentSummary, + serverbee_common::types::AgentAuthorityStatus, + serverbee_common::types::AgentAuthorityStateSummary, crate::router::api::server::CreateServerRequest, crate::router::api::server::CreateServerResponse, - crate::router::api::server::RecoverRequest, - crate::router::api::server::RecoverResponse, - crate::router::api::server::RegenerateCodeRequest, - crate::router::api::server::RegenerateCodeResponse, + crate::router::api::server::ReenrollmentModeRequest, + crate::router::api::server::ReenrollmentRequest, + crate::router::api::server::IssueOfferRequest, + crate::router::api::server::EnrollmentOfferResponse, + crate::router::api::server::RevokeOfferResponse, + crate::router::api::server::RevokeAuthorityResponse, + crate::router::api::server::AuthorityEventResponse, crate::router::api::server::EnrollmentIssueResponse, crate::router::api::server::BatchDeleteRequest, crate::router::api::server::BatchDeleteResponse, diff --git a/crates/server/src/router/api/agent.rs b/crates/server/src/router/api/agent.rs index 680c9387..1001d00b 100644 --- a/crates/server/src/router/api/agent.rs +++ b/crates/server/src/router/api/agent.rs @@ -1,57 +1,31 @@ use std::net::SocketAddr; use std::sync::Arc; -use axum::extract::{ConnectInfo, Path, State}; +use axum::extract::{ConnectInfo, State}; use axum::http::HeaderMap; use axum::routing::{get, post}; -use axum::{Extension, Json, Router}; -use chrono::Utc; -use sea_orm::{ActiveModelTrait, ActiveValue::Set, EntityTrait, TransactionTrait}; +use axum::{Json, Router}; use serde::{Deserialize, Serialize}; -use crate::entity::server; use crate::error::{ApiResponse, AppError, ok}; -use crate::middleware::auth::CurrentUser; use crate::router::utils::extract_client_ip; -use crate::service::audit::AuditService; -use crate::service::auth::AuthService; -use crate::service::enrollment::EnrollmentService; +use crate::service::agent_authority::{ + ClaimAgent, ClaimError, EnrollmentCode, ProposedRunToken, RequestSource, +}; use crate::service::upgrade_release::LatestAgentVersionResponse; use crate::state::AppState; -#[derive(Debug, Deserialize, utoipa::ToSchema)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct RegisterRequest { - #[serde(default)] - fingerprint: String, + proposed_run_token: String, } #[derive(Debug, Serialize, utoipa::ToSchema)] pub struct RegisterResponse { server_id: String, - token: String, } -#[derive(Debug, Serialize, utoipa::ToSchema)] -pub struct EnrollmentSummary { - pub id: String, - pub target_server_id: String, - pub code_prefix: String, - pub created_by: String, - pub expires_at: String, - pub consumed_at: Option<String>, - pub revoked_at: Option<String>, - pub created_at: String, -} - -#[derive(Debug, Serialize, utoipa::ToSchema)] -pub struct RotateTokenResponse { - server_id: String, - /// New plaintext run token — shown once. The agent must be reconfigured - /// with this value (or it will need to re-enroll). - token: String, -} - -/// Public routes for agent registration (Bearer auth checked inside handler). +/// Public routes for Agent enrollment (Bearer auth is checked by the handler). pub fn public_router() -> Router<Arc<AppState>> { Router::new().route("/agent/register", post(register)) } @@ -79,10 +53,11 @@ pub async fn latest_version( post, path = "/api/agent/register", tag = "agent", + request_body = RegisterRequest, responses( - (status = 200, description = "Agent registered against the bound server", body = RegisterResponse), - (status = 400, description = "Invalid fingerprint format"), - (status = 401, description = "Invalid, expired, revoked, or already-used enrollment code"), + (status = 200, description = "Agent claimed the bound Server authority", body = RegisterResponse), + (status = 400, description = "Missing or invalid Agent-proposed run token"), + (status = 401, description = "Enrollment claim rejected"), ), security(("bearer_token" = [])) )] @@ -90,9 +65,8 @@ async fn register( State(state): State<Arc<AppState>>, ConnectInfo(addr): ConnectInfo<SocketAddr>, headers: HeaderMap, - body: Option<Json<RegisterRequest>>, + Json(body): Json<RegisterRequest>, ) -> Result<Json<ApiResponse<RegisterResponse>>, AppError> { - // 1. Rate limiting by client IP. let ip = extract_client_ip( &ConnectInfo(addr), &headers, @@ -105,459 +79,32 @@ async fn register( )); } - // 2. Extract Bearer enrollment code. - let bearer = headers + let code = headers .get("authorization") - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.strip_prefix("Bearer ")) - .ok_or(AppError::Unauthorized)? - .to_string(); - - // 3. Validate fingerprint format BEFORE opening the transaction so a - // malformed payload cannot burn the operator's single-use code. - // The fingerprint is informational only — it is recorded on the - // server row but NEVER used for lookup or deduplication. - let fingerprint = body - .as_ref() - .map(|b| b.fingerprint.clone()) - .filter(|f| !f.is_empty()); - if let Some(ref fp) = fingerprint - && (fp.len() != 64 || !fp.chars().all(|c| c.is_ascii_hexdigit())) - { - return Err(AppError::BadRequest( - "Invalid fingerprint format".to_string(), - )); - } - - // 4. Single transaction: consume enrollment + stamp token on the bound - // server. If anything fails the consume is rolled back so the operator - // can retry with the same code. - let tx_bearer = bearer.clone(); - let tx_ip = ip.clone(); - let tx_fp = fingerprint.clone(); - let (server_id, plaintext_token, enrollment_id, enrollment_prefix) = state - .db - .transaction::<_, (String, String, String, String), AppError>(move |tx| { - Box::pin(async move { - let enrollment = - EnrollmentService::verify_and_consume_tx(tx, &tx_bearer) - .await? - .ok_or(AppError::Unauthorized)?; - - let server_row = server::Entity::find_by_id(&enrollment.target_server_id) - .one(tx) - .await? - .ok_or_else(|| { - // Should be impossible: the FK on agent_enrollments - // guarantees the bound server exists. - AppError::Internal("Bound server vanished".to_string()) - })?; - - let plaintext = AuthService::generate_session_token(); - let token_hash = AuthService::hash_password(&plaintext)?; - let token_prefix = plaintext[..8.min(plaintext.len())].to_string(); - let server_id = server_row.id.clone(); - let enrollment_id = enrollment.id.clone(); - let enrollment_prefix = enrollment.code_prefix.clone(); - - let mut active: server::ActiveModel = server_row.into(); - active.token_hash = Set(Some(token_hash)); - active.token_prefix = Set(Some(token_prefix)); - active.last_remote_addr = Set(Some(tx_ip)); - active.fingerprint = Set(tx_fp); - active.updated_at = Set(Utc::now()); - active.update(tx).await?; - - Ok((server_id, plaintext, enrollment_id, enrollment_prefix)) - }) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .ok_or(AppError::Unauthorized) + .and_then(|value| EnrollmentCode::parse(value).map_err(|_| AppError::Unauthorized))?; + let proposed_run_token = + ProposedRunToken::parse(body.proposed_run_token).map_err(AppError::BadRequest)?; + let source = RequestSource::parse("agent:register") + .map_err(|error| AppError::Internal(format!("invalid request source: {error}")))?; + + let receipt = state + .agent_authority + .claim(ClaimAgent { + code, + proposed_run_token, + source, + remote_addr: Some(ip), }) .await - .map_err(|e| match e { - sea_orm::TransactionError::Connection(db_err) => AppError::from(db_err), - sea_orm::TransactionError::Transaction(app_err) => app_err, + .map_err(|error| match error { + ClaimError::Rejected => AppError::Unauthorized, + ClaimError::Store(error) => error, })?; - // Audit log AFTER commit so we don't log fictitious enrollments on rollback. - let _ = AuditService::log( - &state.db, - "system", - "agent_enrolled", - Some(&format!( - "server_id={server_id} enrollment={enrollment_id} prefix={enrollment_prefix}" - )), - &ip, - ) - .await; - - // No ServerOnline broadcast here — that event belongs to - // `AgentManager::add_connection` when the WS actually connects. The UI - // picks up the pending→registered flip on its next list refresh, and the - // agent immediately opens a WS after this response which triggers the - // proper online event. ok(RegisterResponse { - server_id, - token: plaintext_token, - }) -} - -/// Admin-only routes for managing enrollment codes. -pub fn admin_router() -> Router<Arc<AppState>> { - Router::new() - .route("/agent/enrollments", get(list_enrollments)) - .route( - "/agent/enrollments/{id}", - axum::routing::delete(delete_enrollment), - ) - .route("/agent/{id}/rotate-token", post(rotate_token)) -} - -#[utoipa::path( - get, - path = "/api/agent/enrollments", - tag = "agent", - responses((status = 200, description = "List enrollment codes", body = [EnrollmentSummary])), - security(("session_cookie" = []), ("api_key" = [])) -)] -async fn list_enrollments( - State(state): State<Arc<AppState>>, -) -> Result<Json<ApiResponse<Vec<EnrollmentSummary>>>, AppError> { - let rows = EnrollmentService::list(&state.db).await?; - let out = rows - .into_iter() - .map(|m| EnrollmentSummary { - id: m.id, - target_server_id: m.target_server_id, - code_prefix: m.code_prefix, - created_by: m.created_by, - expires_at: m.expires_at.to_rfc3339(), - consumed_at: m.consumed_at.map(|d| d.to_rfc3339()), - revoked_at: m.revoked_at.map(|d| d.to_rfc3339()), - created_at: m.created_at.to_rfc3339(), - }) - .collect(); - ok(out) -} - -#[utoipa::path( - delete, - path = "/api/agent/enrollments/{id}", - tag = "agent", - params(("id" = String, Path, description = "Enrollment id")), - responses((status = 200, description = "Deleted")), - security(("session_cookie" = []), ("api_key" = [])) -)] -async fn delete_enrollment( - State(state): State<Arc<AppState>>, - ConnectInfo(addr): ConnectInfo<SocketAddr>, - headers: HeaderMap, - Extension(current_user): Extension<CurrentUser>, - Path(id): Path<String>, -) -> Result<Json<ApiResponse<&'static str>>, AppError> { - // DELETE is mapped to revoke(): the row stays in the table with a - // non-null `revoked_at` for audit/history. Idempotent. - EnrollmentService::revoke(&state.db, &id).await?; - let ip = extract_client_ip( - &ConnectInfo(addr), - &headers, - &state.config.server.trusted_proxies, - ) - .to_string(); - let detail = format!("id={id}"); - let _ = AuditService::log( - &state.db, - &current_user.user_id, - "agent_enrollment_revoked", - Some(&detail), - &ip, - ) - .await; - ok("revoked") -} - -#[utoipa::path( - post, - path = "/api/agent/{id}/rotate-token", - tag = "agent", - params(("id" = String, Path, description = "Server id")), - responses( - (status = 200, description = "Token rotated; old token revoked", body = RotateTokenResponse), - (status = 400, description = "Server is pending (no token to rotate); use recover instead"), - (status = 404, description = "Server not found"), - ), - security(("session_cookie" = []), ("api_key" = []), ("bearer_token" = [])) -)] -async fn rotate_token( - State(state): State<Arc<AppState>>, - ConnectInfo(addr): ConnectInfo<SocketAddr>, - headers: HeaderMap, - Extension(current_user): Extension<CurrentUser>, - Path(id): Path<String>, -) -> Result<Json<ApiResponse<RotateTokenResponse>>, AppError> { - let existing = server::Entity::find_by_id(&id) - .one(&state.db) - .await? - .ok_or_else(|| AppError::NotFound("Server not found".to_string()))?; - - if existing.token_hash.is_none() { - return Err(AppError::BadRequest( - "cannot rotate token of a pending server; use recover instead".into(), - )); - } - - let plaintext = AuthService::generate_session_token(); - let token_hash = AuthService::hash_password(&plaintext)?; - let token_prefix = plaintext[..8.min(plaintext.len())].to_string(); - - let mut active: server::ActiveModel = existing.into(); - active.token_hash = Set(Some(token_hash)); - active.token_prefix = Set(Some(token_prefix)); - active.updated_at = Set(Utc::now()); - active.update(&state.db).await?; - - // Drop any live agent connection so it must reconnect with the new token. - state.agent_manager.remove_connection(&id); - - let ip = extract_client_ip( - &ConnectInfo(addr), - &headers, - &state.config.server.trusted_proxies, - ) - .to_string(); - let detail = format!("server_id={id}"); - let _ = AuditService::log( - &state.db, - &current_user.user_id, - "agent_token_rotated", - Some(&detail), - &ip, - ) - .await; - - ok(RotateTokenResponse { - server_id: id, - token: plaintext, + server_id: receipt.server_id.into_inner(), }) } - -#[cfg(test)] -mod enrollment_endpoint_tests { - use crate::entity::{server, user}; - use crate::service::enrollment::EnrollmentService; - use crate::test_utils::setup_test_db; - use chrono::Utc; - use sea_orm::*; - use serverbee_common::constants::CAP_DEFAULT; - use uuid::Uuid; - - /// Seed a user so the `created_by` FK on `agent_enrollments` is satisfied. - async fn seed_user(db: &DatabaseConnection) -> String { - let id = Uuid::new_v4().to_string(); - let now = Utc::now(); - user::ActiveModel { - id: Set(id.clone()), - username: Set(format!("user-{id}")), - password_hash: Set("$argon2id$v=19$m=19456,t=2,p=1$x$x".to_string()), - role: Set("admin".to_string()), - totp_secret: Set(None), - must_change_password: Set(false), - password_changed_at: Set(None), - created_at: Set(now), - updated_at: Set(now), - } - .insert(db) - .await - .expect("seed user"); - id - } - - /// Seed a pending server (no token yet) so the new `target_server_id` - /// FK on `agent_enrollments` is satisfied. - async fn seed_pending_server(db: &DatabaseConnection) -> String { - let id = Uuid::new_v4().to_string(); - let now = Utc::now(); - server::ActiveModel { - id: Set(id.clone()), - token_hash: Set(None), - token_prefix: Set(None), - name: Set("t".to_string()), - cpu_name: Set(None), - cpu_cores: Set(None), - cpu_arch: Set(None), - os: Set(None), - kernel_version: Set(None), - mem_total: Set(None), - swap_total: Set(None), - disk_total: Set(None), - ipv4: Set(None), - ipv6: Set(None), - region: Set(None), - country_code: Set(None), - geo_manual: Set(false), - virtualization: Set(None), - agent_version: Set(None), - group_id: Set(None), - weight: Set(0), - hidden: Set(false), - remark: Set(None), - public_remark: Set(None), - price: Set(None), - billing_cycle: Set(None), - currency: Set(None), - expired_at: Set(None), - traffic_limit: Set(None), - traffic_limit_type: Set(None), - billing_start_day: Set(None), - capabilities: Set(CAP_DEFAULT as i32), - protocol_version: Set(1), - features: Set("[]".to_string()), - last_remote_addr: Set(None), - fingerprint: Set(None), - created_at: Set(now), - updated_at: Set(now), - } - .insert(db) - .await - .expect("seed pending server"); - id - } - - #[tokio::test] - async fn mint_then_list_shows_prefix_not_code() { - let (db, _tmp) = setup_test_db().await; - let uid = seed_user(&db).await; - let sid = seed_pending_server(&db).await; - let (_m, code) = EnrollmentService::mint_for_server(&db, &sid, &uid, 600) - .await - .unwrap(); - let list = EnrollmentService::list(&db).await.unwrap(); - assert_eq!(list.len(), 1); - assert_eq!(list[0].code_prefix, &code[..8]); - assert!( - !list[0].code_hash.contains(&code), - "plaintext code never stored" - ); - } - - // `register_flow_consumes_code_single_use` deleted: the new service-level - // `verify_and_consume_single_use` test in `service::enrollment::tests` - // already covers the same property at higher fidelity (real tx). The - // end-to-end register flow will get its own integration test in T8. - - #[tokio::test] - async fn rotate_token_invalidates_old_token() { - use crate::service::auth::AuthService; - - let (db, _tmp) = setup_test_db().await; - - let old_plain = AuthService::generate_session_token(); - let old_hash = AuthService::hash_password(&old_plain).unwrap(); - let sid = Uuid::new_v4().to_string(); - let now = Utc::now(); - - let server_model = server::ActiveModel { - id: Set(sid.clone()), - token_hash: Set(Some(old_hash.clone())), - token_prefix: Set(Some(old_plain[..8].to_string())), - name: Set("t".to_string()), - cpu_name: Set(None), - cpu_cores: Set(None), - cpu_arch: Set(None), - os: Set(None), - kernel_version: Set(None), - mem_total: Set(None), - swap_total: Set(None), - disk_total: Set(None), - ipv4: Set(None), - ipv6: Set(None), - region: Set(None), - country_code: Set(None), - geo_manual: Set(false), - virtualization: Set(None), - agent_version: Set(None), - group_id: Set(None), - weight: Set(0), - hidden: Set(false), - remark: Set(None), - public_remark: Set(None), - price: Set(None), - billing_cycle: Set(None), - currency: Set(None), - expired_at: Set(None), - traffic_limit: Set(None), - traffic_limit_type: Set(None), - billing_start_day: Set(None), - capabilities: Set(CAP_DEFAULT as i32), - protocol_version: Set(1), - features: Set("[]".to_string()), - last_remote_addr: Set(None), - fingerprint: Set(None), - created_at: Set(now), - updated_at: Set(now), - }; - server_model.insert(&db).await.expect("insert server"); - - // Simulate rotation: new token + hash, persist. - let new_plain = AuthService::generate_session_token(); - let new_hash = AuthService::hash_password(&new_plain).unwrap(); - let existing = server::Entity::find_by_id(&sid) - .one(&db) - .await - .unwrap() - .unwrap(); - let mut active: server::ActiveModel = existing.into(); - active.token_hash = Set(Some(new_hash.clone())); - active.token_prefix = Set(Some(new_plain[..8].to_string())); - active.update(&db).await.unwrap(); - - // Old token must no longer verify; new one must. - assert!( - !AuthService::verify_password(&old_plain, &new_hash).unwrap(), - "old token must not verify against rotated hash" - ); - assert!( - AuthService::verify_password(&new_plain, &new_hash).unwrap(), - "new token verifies" - ); - } - - #[tokio::test] - async fn enrollment_summary_dto_never_exposes_code_or_hash() { - let (db, _tmp) = setup_test_db().await; - let uid = seed_user(&db).await; - let sid = seed_pending_server(&db).await; - let (model, code) = EnrollmentService::mint_for_server(&db, &sid, &uid, 600) - .await - .unwrap(); - - // Mirror exactly the mapping in `list_enrollments`. - let summary = super::EnrollmentSummary { - id: model.id, - target_server_id: model.target_server_id, - code_prefix: model.code_prefix, - created_by: model.created_by, - expires_at: model.expires_at.to_rfc3339(), - consumed_at: model.consumed_at.map(|d| d.to_rfc3339()), - revoked_at: model.revoked_at.map(|d| d.to_rfc3339()), - created_at: model.created_at.to_rfc3339(), - }; - let json = serde_json::to_string(&summary).expect("serialize"); - - assert!( - !json.contains("code_hash"), - "DTO must never expose code_hash: {json}" - ); - assert!( - !json.contains(&code), - "DTO must never expose the plaintext code" - ); - // code_prefix is the only code-derived field that may appear. - assert!(json.contains(&format!("\"code_prefix\":\"{}\"", &code[..8]))); - // The bound server id is part of the DTO post-T11. - assert!(json.contains(&format!("\"target_server_id\":\"{sid}\""))); - // The `label` field is gone post-T11. - assert!( - !json.contains("\"label\""), - "EnrollmentSummary must not expose `label` after T11" - ); - } -} diff --git a/crates/server/src/router/api/mod.rs b/crates/server/src/router/api/mod.rs index 1a230462..3d5c7e47 100644 --- a/crates/server/src/router/api/mod.rs +++ b/crates/server/src/router/api/mod.rs @@ -104,7 +104,6 @@ pub fn router(state: Arc<AppState>) -> Router<Arc<AppState>> { .merge(dashboard::write_router()) .merge(widget_module::write_router()) .merge(setting::router()) - .merge(agent::admin_router()) .merge(brand::write_router()) .merge(notification::router()) .merge(alert::router()) diff --git a/crates/server/src/router/api/server.rs b/crates/server/src/router/api/server.rs index a203732a..2618b904 100644 --- a/crates/server/src/router/api/server.rs +++ b/crates/server/src/router/api/server.rs @@ -2,20 +2,18 @@ use std::net::SocketAddr; use std::sync::Arc; use axum::extract::{ConnectInfo, Extension, Path, Query, State}; -use axum::http::HeaderMap; +use axum::http::{HeaderMap, StatusCode}; use axum::routing::{delete, get, post, put}; use axum::{Json, Router}; use crate::router::utils::extract_client_ip; use chrono::{DateTime, Utc}; use sea_orm::{ - ActiveModelTrait, ActiveValue::Set, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, - TransactionTrait, + ActiveModelTrait, ActiveValue::Set, ColumnTrait, EntityTrait, QueryFilter, TransactionTrait, }; use serde::{Deserialize, Serialize}; -use uuid::Uuid; -use crate::entity::{agent_enrollment, server, server_tag}; +use crate::entity::server; use crate::error::{ApiResponse, AppError, ok}; use crate::middleware::auth::CurrentUser; use crate::router::api::network_probe::{ @@ -25,16 +23,19 @@ use crate::router::api::network_probe::{ use crate::service::agent_manager::AgentManager; use crate::service::agent_reconcile::AgentDesiredStateDomain; use crate::service::audit::AuditService; -use crate::service::enrollment::{DEFAULT_TTL_SECS, EnrollmentService}; use crate::service::network_probe::NetworkProbeService; use crate::service::record::{QueryHistoryResult, RecordService}; use crate::service::server::{ServerService, UpdateServerInput}; -use crate::service::server_tag as server_tag_service; +use crate::service::server_onboarding::{ + OnboardServer, OnboardingError, OnboardingRequestId, OnboardingResult, ServerProfile, +}; use crate::service::task_scheduler; use crate::service::upgrade_tracker::{StartUpgradeJobError, UpgradeLookup}; use crate::state::AppState; use serverbee_common::protocol::ServerMessage; -use serverbee_common::types::OutstandingEnrollmentSummary; +use serverbee_common::types::{ + AgentAuthorityStateSummary, AgentAuthorityStatus, OutstandingEnrollmentSummary, +}; const DEFAULT_SERVER_NAME: &str = "New Server"; @@ -137,6 +138,7 @@ pub struct ServerResponse { pub temporary: Vec<TemporaryGrantDto>, pub protocol_version: i32, features: Vec<String>, + pub agent_authority: AgentAuthorityStateSummary, /// `true` iff the server row has a non-NULL `token_hash`. Pending servers /// (created via `POST /api/servers` but not yet enrolled by an agent) have /// `has_token = false`; the UI uses this to render a "pending" badge. @@ -152,6 +154,7 @@ pub struct ServerResponse { #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct CreateServerRequest { + pub onboarding_request_id: String, pub name: String, #[serde(default)] pub group_id: Option<String>, @@ -197,39 +200,76 @@ pub struct EnrollmentIssueResponse { #[derive(Debug, Serialize, utoipa::ToSchema)] pub struct CreateServerResponse { pub server_id: String, - pub enrollment: EnrollmentIssueResponse, + pub replayed: bool, + pub enrollment: Option<EnrollmentIssueResponse>, + pub outstanding_offer: Option<OutstandingEnrollmentSummary>, } -#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] -pub struct RecoverRequest { - /// If `true`, clear the server's `token_hash`/`token_prefix` and kick the - /// currently connected agent WebSocket as part of the same transaction. - /// Use this when the operator suspects the existing agent token has been - /// compromised. If `false`, the existing token remains valid and only a - /// new bound enrollment is minted alongside it. - pub revoke_immediately: bool, +#[derive(Debug, Clone, Copy, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum ReenrollmentModeRequest { + Graceful, + Emergency, } -#[derive(Debug, Serialize, utoipa::ToSchema)] -pub struct RecoverResponse { - pub enrollment: EnrollmentIssueResponse, +#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] +pub struct ReenrollmentRequest { + pub mode: ReenrollmentModeRequest, + #[serde(default)] + pub ttl_secs: Option<i64>, } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] -pub struct RegenerateCodeRequest { - /// Optimistic concurrency token. If `Some`, must match the current - /// outstanding enrollment id exactly; otherwise the server returns 409. - /// If `None`, last-writer-wins: any outstanding enrollment is revoked - /// and a fresh one is minted. +pub struct IssueOfferRequest { #[serde(default)] - pub expected_enrollment_id: Option<String>, + pub ttl_secs: Option<i64>, } #[derive(Debug, Serialize, utoipa::ToSchema)] -pub struct RegenerateCodeResponse { +pub struct EnrollmentOfferResponse { pub enrollment: EnrollmentIssueResponse, } +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct RevokeOfferResponse { + pub offer_id: String, + pub already_revoked: bool, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct RevokeAuthorityResponse { + pub server_id: String, + pub changed: bool, +} + +#[derive(Debug, Deserialize, utoipa::IntoParams)] +pub struct AuthorityHistoryQuery { + pub server_id: String, + #[serde(default = "default_authority_history_limit")] + pub limit: u64, +} + +fn default_authority_history_limit() -> u64 { + 100 +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct AuthorityEventResponse { + pub id: String, + pub server_id: String, + pub server_name: String, + pub actor_kind: String, + pub actor_id: Option<String>, + pub request_source: String, + pub offer_id: Option<String>, + pub transition: String, + pub mode: Option<String>, + pub offer_outcome: Option<String>, + pub authority_before: String, + pub authority_after: String, + pub created_at: DateTime<Utc>, +} + fn runtime_capability_fields( agent_manager: &AgentManager, server_id: &str, @@ -247,7 +287,7 @@ fn runtime_capability_fields( fn build_server_response( s: server::Model, agent_manager: &AgentManager, - outstanding_enrollment: Option<OutstandingEnrollmentSummary>, + agent_authority: AgentAuthorityStateSummary, ) -> ServerResponse { let (agent_local_capabilities, effective_capabilities) = runtime_capability_fields(agent_manager, &s.id); @@ -258,7 +298,8 @@ fn build_server_response( .map(Into::into) .collect(); - let has_token = s.token_hash.is_some(); + let has_token = agent_authority.status == AgentAuthorityStatus::Claimed; + let outstanding_enrollment = agent_authority.outstanding_offer.clone(); ServerResponse { id: s.id, @@ -296,6 +337,7 @@ fn build_server_response( temporary, protocol_version: s.protocol_version, features: serde_json::from_str(&s.features).unwrap_or_default(), + agent_authority, has_token, outstanding_enrollment, created_at: s.created_at, @@ -303,59 +345,38 @@ fn build_server_response( } } -/// Fetch the single outstanding (not consumed, not revoked) enrollment for a -/// server, mapped to the response DTO. Returns `Ok(None)` when there is no -/// outstanding enrollment. -async fn fetch_outstanding_enrollment( - db: &sea_orm::DatabaseConnection, - server_id: &str, -) -> Result<Option<OutstandingEnrollmentSummary>, AppError> { - let row = agent_enrollment::Entity::find() - .filter(agent_enrollment::Column::TargetServerId.eq(server_id)) - .filter(agent_enrollment::Column::ConsumedAt.is_null()) - .filter(agent_enrollment::Column::RevokedAt.is_null()) - .one(db) - .await?; - Ok(row.map(|m| OutstandingEnrollmentSummary { - id: m.id, - code_prefix: m.code_prefix, - expires_at: m.expires_at.to_rfc3339(), - created_at: m.created_at.to_rfc3339(), - })) -} - -/// Batch fetch of outstanding enrollments for a set of server ids. Avoids -/// the N+1 pattern when serializing the `GET /api/servers` list. Returns a -/// map keyed by `target_server_id`. -async fn fetch_outstanding_enrollments_batch( - db: &sea_orm::DatabaseConnection, +async fn fetch_authority_states_batch( + authority: &crate::service::agent_authority::AgentAuthority, server_ids: &[String], -) -> Result<std::collections::HashMap<String, OutstandingEnrollmentSummary>, AppError> { - let mut out = std::collections::HashMap::new(); - if server_ids.is_empty() { - return Ok(out); - } - let rows = agent_enrollment::Entity::find() - .filter(agent_enrollment::Column::TargetServerId.is_in(server_ids.iter().cloned())) - .filter(agent_enrollment::Column::ConsumedAt.is_null()) - .filter(agent_enrollment::Column::RevokedAt.is_null()) - .all(db) - .await?; - for m in rows { - // The partial unique index `idx_enrollments_active_per_server` - // guarantees at most one outstanding row per server, so the last-write - // wins behavior here is fine (and unreachable in practice). - out.insert( - m.target_server_id.clone(), - OutstandingEnrollmentSummary { - id: m.id, - code_prefix: m.code_prefix, - expires_at: m.expires_at.to_rfc3339(), - created_at: m.created_at.to_rfc3339(), - }, - ); - } - Ok(out) +) -> Result<std::collections::HashMap<String, AgentAuthorityStateSummary>, AppError> { + let ids = server_ids + .iter() + .cloned() + .map(|server_id| { + crate::service::agent_authority::ServerId::parse(server_id) + .map_err(|error| AppError::Internal(format!("invalid stored server id: {error}"))) + }) + .collect::<Result<Vec<_>, _>>()?; + authority + .states(&ids) + .await + .map_err(|error| match error { + crate::service::agent_authority::StateError::NotFound => { + AppError::Internal("batch authority projection lost a server".to_string()) + } + crate::service::agent_authority::StateError::Store(error) => error, + }) + .map(|states| { + states + .into_iter() + .map(|state| { + ( + state.server_id.as_str().to_string(), + authority_state_response(state), + ) + }) + .collect() + }) } /// GET endpoints accessible to all authenticated users (admin + member). @@ -363,6 +384,8 @@ pub fn read_router() -> Router<Arc<AppState>> { Router::new() .route("/servers", get(list_servers)) .route("/servers/{id}", get(get_server)) + .route("/servers/{id}/agent-authority", get(get_agent_authority)) + .route("/agent-authority/events", get(get_authority_history)) .route("/servers/{id}/records", get(get_records)) .route("/servers/{id}/gpu-records", get(get_gpu_records)) .route( @@ -392,10 +415,25 @@ pub fn write_router() -> Router<Arc<AppState>> { .route("/servers/batch-delete", post(batch_delete)) .route("/servers/cleanup", delete(cleanup_orphaned_servers)) .route("/servers/{id}/upgrade", post(trigger_upgrade)) - .route("/servers/{id}/recover", post(recover_server)) .route( - "/servers/{id}/regenerate-code", - post(regenerate_code), + "/servers/{id}/agent-authority/re-enrollment", + post(begin_reenrollment), + ) + .route( + "/servers/{id}/agent-authority/offers", + post(issue_offer_for_unclaimed), + ) + .route( + "/servers/{id}/agent-authority/offers/{offer_id}/replace", + post(replace_offer), + ) + .route( + "/servers/{id}/agent-authority/offers/{offer_id}", + delete(revoke_offer), + ) + .route( + "/servers/{id}/agent-authority", + delete(revoke_agent_authority), ) .route( "/servers/{id}/network-probes/targets", @@ -417,14 +455,24 @@ async fn list_servers( ) -> Result<Json<ApiResponse<Vec<ServerResponse>>>, AppError> { let servers = ServerService::list_servers(&state.db).await?; let ids: Vec<String> = servers.iter().map(|s| s.id.clone()).collect(); - let mut outstanding = fetch_outstanding_enrollments_batch(&state.db, &ids).await?; - ok(servers + let mut authority_states = fetch_authority_states_batch(&state.agent_authority, &ids).await?; + let response = servers .into_iter() .map(|server| { - let pending = outstanding.remove(&server.id); - build_server_response(server, &state.agent_manager, pending) + let authority = authority_states.remove(&server.id).ok_or_else(|| { + AppError::Internal(format!( + "Agent Authority projection missing Server {}", + server.id + )) + })?; + Ok(build_server_response( + server, + &state.agent_manager, + authority, + )) }) - .collect()) + .collect::<Result<Vec<_>, AppError>>()?; + ok(response) } /// Create a pending server row and a server-bound enrollment in a single @@ -437,7 +485,7 @@ async fn list_servers( /// /// `caps` is accepted in the request for the install.sh `--caps` arg but is /// NOT persisted on the server row. The server row always starts at -/// `CAP_DEFAULT`; the operator can edit capabilities afterwards. +/// `CAP_DEFAULT`; the Agent later reports its locally configured capabilities. #[utoipa::path( post, path = "/api/servers", @@ -451,429 +499,498 @@ async fn list_servers( )] async fn create_server( State(state): State<Arc<AppState>>, - ConnectInfo(addr): ConnectInfo<SocketAddr>, Extension(current_user): Extension<CurrentUser>, - headers: HeaderMap, Json(body): Json<CreateServerRequest>, ) -> Result<Json<ApiResponse<CreateServerResponse>>, AppError> { - use serverbee_common::constants::CAP_DEFAULT; - - let name = body.name.trim().to_string(); - if name.is_empty() { - return Err(AppError::BadRequest("name is required".into())); + let request_id = + OnboardingRequestId::parse(body.onboarding_request_id).map_err(AppError::BadRequest)?; + let offer_ttl = parse_offer_ttl(body.ttl_secs)?; + let result = state + .server_onboarding + .onboard(OnboardServer { + actor_id: current_user.user_id, + request_id, + source: request_source("api:create-server")?, + profile: ServerProfile { + name: body.name, + group_id: body.group_id, + tags: body.tags, + remark: body.remark, + public_remark: body.public_remark, + price: body.price, + currency: body.currency, + billing_cycle: body.billing_cycle, + billing_start_day: body.billing_start_day, + expired_at: body.expired_at, + traffic_limit: body.traffic_limit, + traffic_limit_type: body.traffic_limit_type, + }, + offer_ttl, + }) + .await + .map_err(map_onboarding_error)?; + + match result { + OnboardingResult::Created { + server_id, + enrollment, + } => ok(CreateServerResponse { + server_id: server_id.into_inner(), + replayed: false, + enrollment: Some(enrollment_issue_response(enrollment)), + outstanding_offer: None, + }), + OnboardingResult::Replayed { + server_id, + outstanding_offer, + } => ok(CreateServerResponse { + server_id: server_id.into_inner(), + replayed: true, + enrollment: None, + outstanding_offer: outstanding_offer.map(outstanding_offer_response), + }), } +} - // Validate tags up-front so we fail before opening the tx. Reuses the - // shared validator so the rules stay identical to PUT /api/servers/{id}/tags. - let normalized_tags = server_tag_service::validate_tags(&body.tags)?; - - // Soft max_servers cap. `max_servers == 0` means "no cap" per AuthConfig - // default; only fire the pre-check when an actual limit is configured. - let max_servers = state.config.auth.max_servers; - if max_servers > 0 { - let count = server::Entity::find().count(&state.db).await?; - if count >= max_servers as u64 { - return Err(AppError::BadRequest(format!( - "Server limit reached ({max_servers}). Delete unused servers or increase max_servers in config." - ))); - } +fn request_source(value: &str) -> Result<crate::service::agent_authority::RequestSource, AppError> { + crate::service::agent_authority::RequestSource::parse(value) + .map_err(|error| AppError::Internal(format!("invalid request source: {error}"))) +} + +fn parse_offer_ttl( + value: Option<i64>, +) -> Result<crate::service::agent_authority::OfferTtl, AppError> { + crate::service::agent_authority::OfferTtl::seconds( + value.unwrap_or(crate::service::agent_authority::OfferTtl::DEFAULT_SECONDS), + ) + .map_err(AppError::BadRequest) +} + +fn parse_server_id(value: String) -> Result<crate::service::agent_authority::ServerId, AppError> { + crate::service::agent_authority::ServerId::parse(value).map_err(AppError::BadRequest) +} + +fn parse_offer_id(value: String) -> Result<crate::service::agent_authority::OfferId, AppError> { + crate::service::agent_authority::OfferId::parse(value).map_err(AppError::BadRequest) +} + +fn authority_actor(user: &CurrentUser) -> crate::service::agent_authority::Actor { + crate::service::agent_authority::Actor::User { + id: user.user_id.clone(), } +} - // Fetch default probe targets BEFORE the tx — ConfigService::get_typed - // takes &DatabaseConnection, not a generic conn. The targets array is - // small and stable, so reading it outside the tx is fine. - let probe_setting = NetworkProbeService::get_setting(&state.db).await?; - let default_target_ids = probe_setting.default_target_ids.clone(); +fn enrollment_issue_response( + enrollment: crate::service::agent_authority::IssuedOffer, +) -> EnrollmentIssueResponse { + EnrollmentIssueResponse { + id: enrollment.id.into_inner(), + code: enrollment.code.expose().to_string(), + code_prefix: enrollment.code_prefix, + expires_at: enrollment.expires_at.to_rfc3339(), + } +} - let ttl = body.ttl_secs.unwrap_or(DEFAULT_TTL_SECS); - let server_id = Uuid::new_v4().to_string(); - let now = Utc::now(); - let user_id = current_user.user_id.clone(); - let ip = extract_client_ip( - &ConnectInfo(addr), - &headers, - &state.config.server.trusted_proxies, - ) - .to_string(); +fn outstanding_offer_response( + offer: crate::service::agent_authority::OutstandingOffer, +) -> OutstandingEnrollmentSummary { + OutstandingEnrollmentSummary { + id: offer.id.into_inner(), + code_prefix: offer.code_prefix, + expires_at: offer.expires_at.to_rfc3339(), + created_at: offer.created_at.to_rfc3339(), + } +} - let tx_server_id = server_id.clone(); - let tx_user_id = user_id.clone(); - let tx_name = name.clone(); - let tx_tags = normalized_tags.clone(); - let tx_body = body.clone(); - - let (enrollment_model, plaintext_code) = state - .db - .transaction::<_, (agent_enrollment::Model, String), AppError>(move |tx| { - Box::pin(async move { - // 1. Insert the pending server row. token_hash = None marks - // the row as "pending" until the agent enrolls. - server::ActiveModel { - id: Set(tx_server_id.clone()), - token_hash: Set(None), - token_prefix: Set(None), - name: Set(tx_name), - cpu_name: Set(None), - cpu_cores: Set(None), - cpu_arch: Set(None), - os: Set(None), - kernel_version: Set(None), - mem_total: Set(None), - swap_total: Set(None), - disk_total: Set(None), - ipv4: Set(None), - ipv6: Set(None), - region: Set(None), - country_code: Set(None), - geo_manual: Set(false), - virtualization: Set(None), - agent_version: Set(None), - group_id: Set(tx_body.group_id.clone()), - weight: Set(0), - hidden: Set(false), - remark: Set(tx_body.remark.clone()), - public_remark: Set(tx_body.public_remark.clone()), - price: Set(tx_body.price), - billing_cycle: Set(tx_body.billing_cycle.clone()), - currency: Set(tx_body.currency.clone()), - expired_at: Set(tx_body.expired_at), - traffic_limit: Set(tx_body.traffic_limit), - traffic_limit_type: Set(tx_body.traffic_limit_type.clone()), - billing_start_day: Set(tx_body.billing_start_day), - capabilities: Set(CAP_DEFAULT as i32), - protocol_version: Set(1), - features: Set("[]".to_string()), - last_remote_addr: Set(None), - fingerprint: Set(None), - created_at: Set(now), - updated_at: Set(now), - } - .insert(tx) - .await?; - - // 2. Persist operator-supplied tags. - for tag in &tx_tags { - server_tag::ActiveModel { - server_id: Set(tx_server_id.clone()), - tag: Set(tag.clone()), - } - .insert(tx) - .await?; - } +fn authority_state_response( + state: crate::service::agent_authority::AuthorityState, +) -> AgentAuthorityStateSummary { + AgentAuthorityStateSummary { + status: match state.authority { + crate::service::agent_authority::AuthorityStatus::Claimed => { + AgentAuthorityStatus::Claimed + } + crate::service::agent_authority::AuthorityStatus::Unclaimed => { + AgentAuthorityStatus::Unclaimed + } + }, + outstanding_offer: state.outstanding_offer.map(outstanding_offer_response), + } +} - // 3. Apply default network probe targets inside the same tx - // so a failure rolls back the server row too. - NetworkProbeService::apply_defaults_tx( - tx, - &tx_server_id, - &default_target_ids, - ) - .await?; - - // 4. Mint the bound enrollment. The partial unique index - // `idx_enrollments_active_per_server` makes this atomic - // with the server insert: if two `POST /api/servers` - // requests raced on the same id (impossible — UUID), the - // second would also fail. With unique UUIDs the only way - // this errors is downstream of bad input, in which case - // we want the whole tx to roll back. - let (model, plaintext) = EnrollmentService::mint_for_server( - tx, - &tx_server_id, - &tx_user_id, - ttl, - ) - .await?; - - Ok((model, plaintext)) - }) +fn current_offer_details( + current: Option<crate::service::agent_authority::OutstandingOffer>, +) -> Option<serde_json::Value> { + current.map(|offer| { + serde_json::json!({ + "current_offer": { + "id": offer.id.into_inner(), + "code_prefix": offer.code_prefix, + "expires_at": offer.expires_at.to_rfc3339(), + "created_at": offer.created_at.to_rfc3339() + } }) - .await - .map_err(|e| match e { - sea_orm::TransactionError::Connection(db_err) => AppError::from(db_err), - sea_orm::TransactionError::Transaction(app_err) => app_err, - })?; + }) +} - // Audit log AFTER commit so we don't log fictitious creations on rollback. - let _ = AuditService::log( - &state.db, - &user_id, - "server_created", - Some(&format!( - "server_id={server_id} enrollment={} prefix={}", - enrollment_model.id, enrollment_model.code_prefix +fn conflict( + code: &'static str, + message: impl Into<String>, + details: Option<serde_json::Value>, +) -> AppError { + AppError::Domain { + status: StatusCode::CONFLICT, + code, + message: message.into(), + details, + } +} + +fn map_onboarding_error(error: OnboardingError) -> AppError { + match error { + OnboardingError::Invalid(message) => AppError::BadRequest(message), + OnboardingError::Validation(message) => AppError::Validation(message), + OnboardingError::LimitReached(limit) => AppError::BadRequest(format!( + "Server limit reached ({limit}). Delete unused servers or increase max_servers in config." )), - &ip, - ) - .await; + OnboardingError::IdempotencyConflict => conflict( + "ONBOARDING_IDEMPOTENCY_CONFLICT", + "onboarding_request_id was already used with different input", + None, + ), + OnboardingError::Store(error) => error, + } +} - ok(CreateServerResponse { - server_id, - enrollment: EnrollmentIssueResponse { - id: enrollment_model.id, - code: plaintext_code, - code_prefix: enrollment_model.code_prefix, - expires_at: enrollment_model.expires_at.to_rfc3339(), - }, - }) +fn map_issue_offer_error(error: crate::service::agent_authority::IssueOfferError) -> AppError { + use crate::service::agent_authority::IssueOfferError; + match error { + IssueOfferError::NotFound => AppError::NotFound("server not found".to_string()), + IssueOfferError::AlreadyClaimed => conflict( + "AGENT_AUTHORITY_ALREADY_CLAIMED", + "server authority is already claimed; begin re-enrollment instead", + None, + ), + IssueOfferError::OutstandingExists(current) => conflict( + "ENROLLMENT_OFFER_OUTSTANDING", + "an Outstanding enrollment offer already exists", + current_offer_details(Some(current)), + ), + IssueOfferError::Store(error) => error, + } +} + +fn map_reenrollment_error(error: crate::service::agent_authority::ReenrollmentError) -> AppError { + use crate::service::agent_authority::ReenrollmentError; + match error { + ReenrollmentError::NotFound => AppError::NotFound("server not found".to_string()), + ReenrollmentError::Unclaimed => conflict( + "AGENT_AUTHORITY_UNCLAIMED", + "server authority is Unclaimed; issue an offer instead", + None, + ), + ReenrollmentError::OutstandingExists(current) => conflict( + "ENROLLMENT_OFFER_OUTSTANDING", + "an Outstanding enrollment offer already exists", + current_offer_details(Some(current)), + ), + ReenrollmentError::Store(error) => error, + } +} + +fn map_replace_offer_error(error: crate::service::agent_authority::ReplaceOfferError) -> AppError { + use crate::service::agent_authority::ReplaceOfferError; + match error { + ReplaceOfferError::ServerNotFound => AppError::NotFound("server not found".to_string()), + ReplaceOfferError::OfferNotFound => { + AppError::NotFound("enrollment offer not found".to_string()) + } + ReplaceOfferError::NotOutstanding { outcome, current } => conflict( + "ENROLLMENT_OFFER_TERMINAL", + format!("enrollment offer is already {}", outcome.as_str()), + current_offer_details(current), + ), + ReplaceOfferError::Stale { current } => conflict( + "ENROLLMENT_OFFER_STALE", + "the exact offer is not the current Outstanding offer", + current_offer_details(current), + ), + ReplaceOfferError::Store(error) => error, + } +} + +fn map_revoke_offer_error(error: crate::service::agent_authority::RevokeOfferError) -> AppError { + use crate::service::agent_authority::RevokeOfferError; + match error { + RevokeOfferError::ServerNotFound => AppError::NotFound("server not found".to_string()), + RevokeOfferError::OfferNotFound => { + AppError::NotFound("enrollment offer not found".to_string()) + } + RevokeOfferError::Terminal(outcome) => conflict( + "ENROLLMENT_OFFER_TERMINAL", + format!("enrollment offer is already {}", outcome.as_str()), + None, + ), + RevokeOfferError::Store(error) => error, + } } -/// Mint a fresh bound enrollment for an already-enrolled server so the operator -/// can reinstall the agent. The target server MUST already have a token -/// (`token_hash IS NOT NULL`) — recover on a pending server is rejected with -/// `400`, use `regenerate-code` for that path. -/// -/// Recover NEVER auto-supersedes an outstanding enrollment: if one is still -/// active, this returns `409` and the operator is expected to either wait for -/// it to expire or revoke it first. Only `regenerate-code` auto-supersedes. -/// -/// `revoke_immediately`: -/// - `true` — clear `token_hash`/`token_prefix` inside the same transaction -/// and kick the currently connected agent WS after commit. The server -/// returns to pending until the new code is consumed. -/// - `false` — the existing token stays valid; the new code only becomes -/// active once the agent registers with it (`verify_and_consume_tx` then -/// rotates the token via `mint_token_for_server`). #[utoipa::path( post, - path = "/api/servers/{id}/recover", + path = "/api/servers/{id}/agent-authority/re-enrollment", tag = "servers", params(("id" = String, Path, description = "Server ID")), - request_body = RecoverRequest, + request_body = ReenrollmentRequest, responses( - (status = 200, description = "Recover enrollment minted", body = RecoverResponse), - (status = 400, description = "Server is pending (use regenerate-code instead)"), + (status = 200, description = "Re-enrollment offer issued", body = EnrollmentOfferResponse), (status = 404, description = "Server not found"), - (status = 409, description = "Outstanding enrollment exists; revoke it first"), + (status = 409, description = "Authority or offer state conflict"), ), security(("session_cookie" = []), ("api_key" = [])) )] -async fn recover_server( +async fn begin_reenrollment( State(state): State<Arc<AppState>>, - ConnectInfo(addr): ConnectInfo<SocketAddr>, Extension(current_user): Extension<CurrentUser>, - headers: HeaderMap, Path(id): Path<String>, - Json(body): Json<RecoverRequest>, -) -> Result<Json<ApiResponse<RecoverResponse>>, AppError> { - let user_id = current_user.user_id.clone(); - let ip = extract_client_ip( - &ConnectInfo(addr), - &headers, - &state.config.server.trusted_proxies, - ) - .to_string(); - - let tx_id = id.clone(); - let tx_user_id = user_id.clone(); - let revoke = body.revoke_immediately; - - let (enrollment_model, plaintext_code, kicked) = state - .db - .transaction::<_, (agent_enrollment::Model, String, bool), AppError>(move |tx| { - Box::pin(async move { - // 1. Load the server row; 404 if it doesn't exist. - let row = server::Entity::find_by_id(&tx_id) - .one(tx) - .await? - .ok_or_else(|| AppError::NotFound("server not found".into()))?; - - // 2. Recover is only for already-enrolled servers. A pending - // server (token_hash IS NULL) should use regenerate-code. - if row.token_hash.is_none() { - return Err(AppError::BadRequest( - "server is pending; use regenerate-code instead".into(), - )); + Json(body): Json<ReenrollmentRequest>, +) -> Result<Json<ApiResponse<EnrollmentOfferResponse>>, AppError> { + let enrollment = state + .agent_authority + .begin_reenrollment(crate::service::agent_authority::BeginReenrollment { + server_id: parse_server_id(id)?, + mode: match body.mode { + ReenrollmentModeRequest::Graceful => { + crate::service::agent_authority::ReenrollmentMode::Graceful } - - // 3. Recover NEVER auto-supersedes an outstanding enrollment. - // The partial unique index `idx_enrollments_active_per_server` - // would also reject the mint below, but checking first lets - // us return a precise 409 instead of a generic constraint - // error. - let outstanding = agent_enrollment::Entity::find() - .filter(agent_enrollment::Column::TargetServerId.eq(&tx_id)) - .filter(agent_enrollment::Column::ConsumedAt.is_null()) - .filter(agent_enrollment::Column::RevokedAt.is_null()) - .one(tx) - .await?; - if outstanding.is_some() { - return Err(AppError::Conflict( - "an outstanding enrollment exists; revoke it before recovering".into(), - )); + ReenrollmentModeRequest::Emergency => { + crate::service::agent_authority::ReenrollmentMode::Emergency } - - // 4. Optionally clear the server token inside the same tx. - let kicked = if revoke { - let mut active: server::ActiveModel = row.into(); - active.token_hash = Set(None); - active.token_prefix = Set(None); - active.updated_at = Set(Utc::now()); - active.update(tx).await?; - true - } else { - false - }; - - // 5. Mint the new bound enrollment. - let (model, plaintext) = - EnrollmentService::mint_for_server(tx, &tx_id, &tx_user_id, DEFAULT_TTL_SECS) - .await?; - - Ok((model, plaintext, kicked)) - }) + }, + actor: authority_actor(&current_user), + source: request_source("api:begin-re-enrollment")?, + ttl: parse_offer_ttl(body.ttl_secs)?, }) .await - .map_err(|e| match e { - sea_orm::TransactionError::Connection(db_err) => AppError::from(db_err), - sea_orm::TransactionError::Transaction(app_err) => app_err, - })?; - - // Post-commit side effects. - if kicked { - // Drop the agent WS connection; the agent will reconnect, see its - // token has been cleared, and exit/back off. Operator then runs the - // install command with the new code. - state.agent_manager.remove_connection(&id); - } - - let _ = AuditService::log( - &state.db, - &user_id, - "server_recover", - Some(&format!( - "server_id={id} enrollment={} prefix={} revoke_immediately={}", - enrollment_model.id, enrollment_model.code_prefix, kicked - )), - &ip, - ) - .await; - - ok(RecoverResponse { - enrollment: EnrollmentIssueResponse { - id: enrollment_model.id, - code: plaintext_code, - code_prefix: enrollment_model.code_prefix, - expires_at: enrollment_model.expires_at.to_rfc3339(), - }, + .map_err(map_reenrollment_error)?; + ok(EnrollmentOfferResponse { + enrollment: enrollment_issue_response(enrollment), }) } -/// Mint a fresh bound enrollment for a pending server, auto-superseding the -/// previous outstanding enrollment (if any) inside one transaction. The target -/// server MUST be pending (`token_hash IS NULL`); use `recover` for an already- -/// enrolled server. -/// -/// Optimistic concurrency: callers pass `expected_enrollment_id` to guard -/// against stomping on a concurrent operator's regenerated code. Semantics: -/// - `Some(id) && matches current outstanding` → proceed (CAS pass) -/// - `Some(id) && does NOT match` (including: there is no outstanding row, or -/// the row referenced has been revoked/consumed) → 409 -/// - `None && outstanding exists` → proceed (last-writer-wins) -/// - `None && no outstanding` → proceed (fresh mint) #[utoipa::path( post, - path = "/api/servers/{id}/regenerate-code", + path = "/api/servers/{id}/agent-authority/offers", tag = "servers", params(("id" = String, Path, description = "Server ID")), - request_body = RegenerateCodeRequest, + request_body = IssueOfferRequest, responses( - (status = 200, description = "Regenerate enrollment minted", body = RegenerateCodeResponse), - (status = 400, description = "Server is not pending; use recover instead"), + (status = 200, description = "Enrollment offer issued", body = EnrollmentOfferResponse), (status = 404, description = "Server not found"), - (status = 409, description = "expected_enrollment_id mismatch"), + (status = 409, description = "Authority or offer state conflict"), ), security(("session_cookie" = []), ("api_key" = [])) )] -async fn regenerate_code( +async fn issue_offer_for_unclaimed( State(state): State<Arc<AppState>>, - ConnectInfo(addr): ConnectInfo<SocketAddr>, Extension(current_user): Extension<CurrentUser>, - headers: HeaderMap, Path(id): Path<String>, - Json(body): Json<RegenerateCodeRequest>, -) -> Result<Json<ApiResponse<RegenerateCodeResponse>>, AppError> { - let user_id = current_user.user_id.clone(); - let ip = extract_client_ip( - &ConnectInfo(addr), - &headers, - &state.config.server.trusted_proxies, - ) - .to_string(); + Json(body): Json<IssueOfferRequest>, +) -> Result<Json<ApiResponse<EnrollmentOfferResponse>>, AppError> { + let enrollment = state + .agent_authority + .issue_offer_for_unclaimed(crate::service::agent_authority::IssueOfferForUnclaimed { + server_id: parse_server_id(id)?, + actor: authority_actor(&current_user), + source: request_source("api:issue-enrollment-offer")?, + ttl: parse_offer_ttl(body.ttl_secs)?, + }) + .await + .map_err(map_issue_offer_error)?; + ok(EnrollmentOfferResponse { + enrollment: enrollment_issue_response(enrollment), + }) +} - let tx_id = id.clone(); - let tx_user_id = user_id.clone(); - let expected = body.expected_enrollment_id.clone(); - - let (enrollment_model, plaintext_code) = state - .db - .transaction::<_, (agent_enrollment::Model, String), AppError>(move |tx| { - Box::pin(async move { - // 1. Load the server row; 404 if it doesn't exist. - let row = server::Entity::find_by_id(&tx_id) - .one(tx) - .await? - .ok_or_else(|| AppError::NotFound("server not found".into()))?; - - // 2. regenerate-code is only for pending servers. An already- - // enrolled server (token_hash IS NOT NULL) must use recover. - if row.token_hash.is_some() { - return Err(AppError::BadRequest( - "server is not pending; use recover instead".into(), - )); - } +#[utoipa::path( + post, + path = "/api/servers/{id}/agent-authority/offers/{offer_id}/replace", + tag = "servers", + params( + ("id" = String, Path, description = "Server ID"), + ("offer_id" = String, Path, description = "Exact current offer ID"), + ), + responses( + (status = 200, description = "Enrollment offer replaced", body = EnrollmentOfferResponse), + (status = 404, description = "Server or offer not found"), + (status = 409, description = "Offer is stale or terminal"), + ), + security(("session_cookie" = []), ("api_key" = [])) +)] +async fn replace_offer( + State(state): State<Arc<AppState>>, + Extension(current_user): Extension<CurrentUser>, + Path((id, offer_id)): Path<(String, String)>, +) -> Result<Json<ApiResponse<EnrollmentOfferResponse>>, AppError> { + let enrollment = state + .agent_authority + .replace_offer(crate::service::agent_authority::ReplaceOffer { + server_id: parse_server_id(id)?, + offer_id: parse_offer_id(offer_id)?, + actor: authority_actor(&current_user), + source: request_source("api:replace-enrollment-offer")?, + ttl: crate::service::agent_authority::OfferTtl::default(), + }) + .await + .map_err(map_replace_offer_error)?; + ok(EnrollmentOfferResponse { + enrollment: enrollment_issue_response(enrollment), + }) +} - // 3. Optimistic CAS: if caller provided expected_enrollment_id, - // it must match the current OUTSTANDING enrollment exactly. - // A None value means "I don't care what's outstanding" - // (last-writer-wins). - let current = agent_enrollment::Entity::find() - .filter(agent_enrollment::Column::TargetServerId.eq(&tx_id)) - .filter(agent_enrollment::Column::ConsumedAt.is_null()) - .filter(agent_enrollment::Column::RevokedAt.is_null()) - .one(tx) - .await?; - let current_id = current.as_ref().map(|m| m.id.clone()); - if expected.is_some() && expected != current_id { - return Err(AppError::Conflict( - "expected_enrollment_id mismatch".into(), - )); - } +#[utoipa::path( + delete, + path = "/api/servers/{id}/agent-authority/offers/{offer_id}", + tag = "servers", + params( + ("id" = String, Path, description = "Server ID"), + ("offer_id" = String, Path, description = "Offer ID"), + ), + responses( + (status = 200, description = "Enrollment offer revoked", body = RevokeOfferResponse), + (status = 404, description = "Server or offer not found"), + (status = 409, description = "Offer has another terminal outcome"), + ), + security(("session_cookie" = []), ("api_key" = [])) +)] +async fn revoke_offer( + State(state): State<Arc<AppState>>, + Extension(current_user): Extension<CurrentUser>, + Path((id, offer_id)): Path<(String, String)>, +) -> Result<Json<ApiResponse<RevokeOfferResponse>>, AppError> { + let receipt = state + .agent_authority + .revoke_offer(crate::service::agent_authority::RevokeOffer { + server_id: parse_server_id(id)?, + offer_id: parse_offer_id(offer_id)?, + actor: authority_actor(&current_user), + source: request_source("api:revoke-enrollment-offer")?, + }) + .await + .map_err(map_revoke_offer_error)?; + ok(RevokeOfferResponse { + offer_id: receipt.offer_id.into_inner(), + already_revoked: receipt.already_revoked, + }) +} - // 4. Revoke any outstanding row, then mint a fresh one. - EnrollmentService::revoke_outstanding_tx(tx, &tx_id).await?; - let (model, plaintext) = - EnrollmentService::mint_for_server(tx, &tx_id, &tx_user_id, DEFAULT_TTL_SECS) - .await?; - Ok((model, plaintext)) - }) +#[utoipa::path( + delete, + path = "/api/servers/{id}/agent-authority", + tag = "servers", + params(("id" = String, Path, description = "Server ID")), + responses( + (status = 200, description = "Agent authority revoked", body = RevokeAuthorityResponse), + (status = 404, description = "Server not found"), + ), + security(("session_cookie" = []), ("api_key" = [])) +)] +async fn revoke_agent_authority( + State(state): State<Arc<AppState>>, + Extension(current_user): Extension<CurrentUser>, + Path(id): Path<String>, +) -> Result<Json<ApiResponse<RevokeAuthorityResponse>>, AppError> { + let receipt = state + .agent_authority + .revoke_authority(crate::service::agent_authority::RevokeAuthority { + server_id: parse_server_id(id)?, + actor: authority_actor(&current_user), + source: request_source("api:revoke-agent-authority")?, }) .await - .map_err(|e| match e { - sea_orm::TransactionError::Connection(db_err) => AppError::from(db_err), - sea_orm::TransactionError::Transaction(app_err) => app_err, + .map_err(|error| match error { + crate::service::agent_authority::RevokeAuthorityError::NotFound => { + AppError::NotFound("server not found".to_string()) + } + crate::service::agent_authority::RevokeAuthorityError::Store(error) => error, })?; + ok(RevokeAuthorityResponse { + server_id: receipt.server_id.into_inner(), + changed: receipt.changed, + }) +} - let _ = AuditService::log( - &state.db, - &user_id, - "server_regenerate_code", - Some(&format!( - "server_id={id} enrollment={} prefix={}", - enrollment_model.id, enrollment_model.code_prefix - )), - &ip, - ) - .await; +#[utoipa::path( + get, + path = "/api/servers/{id}/agent-authority", + tag = "servers", + params(("id" = String, Path, description = "Server ID")), + responses( + (status = 200, description = "Agent authority state", body = AgentAuthorityStateSummary), + (status = 404, description = "Server not found"), + ), + security(("session_cookie" = []), ("api_key" = []), ("bearer_token" = [])) +)] +async fn get_agent_authority( + State(state): State<Arc<AppState>>, + Path(id): Path<String>, +) -> Result<Json<ApiResponse<AgentAuthorityStateSummary>>, AppError> { + let state = state + .agent_authority + .state(parse_server_id(id)?) + .await + .map_err(|error| match error { + crate::service::agent_authority::StateError::NotFound => { + AppError::NotFound("server not found".to_string()) + } + crate::service::agent_authority::StateError::Store(error) => error, + })?; + ok(authority_state_response(state)) +} - ok(RegenerateCodeResponse { - enrollment: EnrollmentIssueResponse { - id: enrollment_model.id, - code: plaintext_code, - code_prefix: enrollment_model.code_prefix, - expires_at: enrollment_model.expires_at.to_rfc3339(), - }, - }) +#[utoipa::path( + get, + path = "/api/agent-authority/events", + tag = "servers", + params(AuthorityHistoryQuery), + responses( + (status = 200, description = "Agent authority event history", body = Vec<AuthorityEventResponse>), + ), + security(("session_cookie" = []), ("api_key" = []), ("bearer_token" = [])) +)] +async fn get_authority_history( + State(state): State<Arc<AppState>>, + Query(query): Query<AuthorityHistoryQuery>, +) -> Result<Json<ApiResponse<Vec<AuthorityEventResponse>>>, AppError> { + let events = state + .agent_authority + .history(crate::service::agent_authority::HistoryQuery { + server_id: parse_server_id(query.server_id)?, + limit: query.limit, + }) + .await + .map_err(|error| match error { + crate::service::agent_authority::HistoryError::Store(error) => error, + })?; + ok(events + .into_iter() + .map(|event| AuthorityEventResponse { + id: event.id, + server_id: event.server_id.into_inner(), + server_name: event.server_name, + actor_kind: event.actor_kind.as_str().to_string(), + actor_id: event.actor_id, + request_source: event.request_source, + offer_id: event.offer_id.map(|id| id.into_inner()), + transition: event.transition.as_str().to_string(), + mode: event.mode.map(|mode| mode.as_str().to_string()), + offer_outcome: event + .offer_outcome + .map(|outcome| outcome.as_str().to_string()), + authority_before: event.authority_before.as_str().to_string(), + authority_after: event.authority_after.as_str().to_string(), + created_at: event.created_at, + }) + .collect()) } #[utoipa::path( @@ -892,8 +1009,21 @@ async fn get_server( Path(id): Path<String>, ) -> Result<Json<ApiResponse<ServerResponse>>, AppError> { let server = ServerService::get_server(&state.db, &id).await?; - let outstanding = fetch_outstanding_enrollment(&state.db, &id).await?; - ok(build_server_response(server, &state.agent_manager, outstanding)) + let authority = state + .agent_authority + .state(parse_server_id(id)?) + .await + .map_err(|error| match error { + crate::service::agent_authority::StateError::NotFound => { + AppError::NotFound("server not found".to_string()) + } + crate::service::agent_authority::StateError::Store(error) => error, + })?; + ok(build_server_response( + server, + &state.agent_manager, + authority_state_response(authority), + )) } #[utoipa::path( @@ -918,8 +1048,21 @@ async fn update_server( // longer change what the agent is allowed to do. let server = ServerService::update_server(&state.db, &id, input).await?; - let outstanding = fetch_outstanding_enrollment(&state.db, &id).await?; - ok(build_server_response(server, &state.agent_manager, outstanding)) + let authority = state + .agent_authority + .state(parse_server_id(id)?) + .await + .map_err(|error| match error { + crate::service::agent_authority::StateError::NotFound => { + AppError::NotFound("server not found".to_string()) + } + crate::service::agent_authority::StateError::Store(error) => error, + })?; + ok(build_server_response( + server, + &state.agent_manager, + authority_state_response(authority), + )) } #[utoipa::path( @@ -946,25 +1089,23 @@ pub async fn delete_server( &state.config.server.trusted_proxies, ) .to_string(); - // Capture the name before deletion so the audit detail is meaningful. - let server_name = ServerService::get_server(&state.db, &id) - .await - .ok() - .map(|s| s.name); - ServerService::delete_server(&state.db, &id).await?; - // Close any live agent connection so it doesn't linger after the row is gone. - state.agent_manager.remove_connection(&id); - // Drop the cached report too: the server row is gone, so its "last known - // metrics" cache is dead weight (and would otherwise never be reclaimed). - state.agent_manager.remove_cached_report(&id); + let deleted = state + .agent_authority + .delete_servers( + &[parse_server_id(id.clone())?], + &authority_actor(&current_user), + &request_source("api:delete-server")?, + ) + .await?; + let deleted = deleted + .into_iter() + .next() + .ok_or_else(|| AppError::NotFound("server not found".to_string()))?; let _ = AuditService::log( &state.db, &current_user.user_id, "server_deleted", - Some(&format!( - "server_id={id} name={}", - server_name.as_deref().unwrap_or("?") - )), + Some(&format!("server_id={id} name={}", deleted.name)), &ip, ) .await; @@ -994,15 +1135,21 @@ pub async fn batch_delete( &state.config.server.trusted_proxies, ) .to_string(); - let deleted = ServerService::batch_delete(&state.db, &body.ids).await?; - // Kick any live connections for the requested ids. `remove_connection` is - // a no-op when nothing is connected, so it's safe to call for ids that - // weren't actually deleted (e.g. unknown ids in the request). - for id in &body.ids { - state.agent_manager.remove_connection(id); - // Row deleted -> drop its display cache so it isn't retained forever. - state.agent_manager.remove_cached_report(id); - } + let server_ids = body + .ids + .iter() + .cloned() + .map(parse_server_id) + .collect::<Result<Vec<_>, _>>()?; + let deleted = state + .agent_authority + .delete_servers( + &server_ids, + &authority_actor(&current_user), + &request_source("api:batch-delete-servers")?, + ) + .await? + .len() as u64; let _ = AuditService::log( &state.db, &current_user.user_id, @@ -1219,15 +1366,11 @@ async fn set_server_network_targets( async fn cleanup_orphaned_servers( State(state): State<Arc<AppState>>, ) -> Result<Json<ApiResponse<CleanupResponse>>, AppError> { - use crate::entity::*; - let mut task_cleanup = task_scheduler::begin_server_cleanup(&state).await; - let txn = state.db.begin().await?; - let candidates = server::Entity::find() .filter(server::Column::Name.eq("New Server")) .filter(server::Column::Os.is_null()) - .all(&txn) + .all(&state.db) .await?; let orphan_ids = collect_orphan_server_ids(&candidates, |id| state.agent_manager.is_online(id)); @@ -1235,33 +1378,38 @@ async fn cleanup_orphaned_servers( return ok(CleanupResponse { deleted_count: 0 }); } - // Purge all server_id-scoped rows through the shared service helper so - // this path cannot drift from delete_server again. - ServerService::delete_server_scoped_rows(&txn, &orphan_ids).await?; - server_tag::Entity::delete_many() - .filter(server_tag::Column::ServerId.is_in(&orphan_ids)) - .exec(&txn) + let cleanup_actor = crate::service::agent_authority::Actor::System; + let cleanup_source = request_source("system:orphan-cleanup")?; + let typed_ids = orphan_ids + .into_iter() + .map(parse_server_id) + .collect::<Result<Vec<_>, _>>()?; + let deleted_rows = state + .agent_authority + .delete_servers(&typed_ids, &cleanup_actor, &cleanup_source) .await?; + let deleted_ids: Vec<String> = deleted_rows + .iter() + .map(|server| server.id.clone()) + .collect(); + if deleted_ids.is_empty() { + return ok(CleanupResponse { deleted_count: 0 }); + } + let txn = state.db.begin().await?; task_cleanup - .remove_server_references(&txn, &orphan_ids) + .remove_server_references(&txn, &deleted_ids) .await?; // Remaining tables with server_ids_json — per-table rules - cleanup_json_array_tables(&txn, &orphan_ids).await?; - - let deleted = server::Entity::delete_many() - .filter(server::Column::Id.is_in(&orphan_ids)) - .exec(&txn) - .await?; + cleanup_json_array_tables(&txn, &deleted_ids).await?; txn.commit().await?; task_cleanup.apply_after_commit(&state).await; - tracing::info!("Cleaned up {} orphaned servers", deleted.rows_affected); - ok(CleanupResponse { - deleted_count: deleted.rows_affected, - }) + let deleted_count = deleted_rows.len() as u64; + tracing::info!("Cleaned up {deleted_count} orphaned servers"); + ok(CleanupResponse { deleted_count }) } async fn cleanup_json_array_tables( @@ -1423,7 +1571,6 @@ mod cleanup_tests { protocol_version: 1, features: "[]".to_string(), last_remote_addr: None, - fingerprint: None, created_at: now, updated_at: now, } @@ -1552,7 +1699,6 @@ mod delete_audit_tests { protocol_version: 1, features: "[]".to_string(), last_remote_addr: None, - fingerprint: None, created_at: now, updated_at: now, }; diff --git a/crates/server/src/router/mod.rs b/crates/server/src/router/mod.rs index 1820d55e..3b050d49 100644 --- a/crates/server/src/router/mod.rs +++ b/crates/server/src/router/mod.rs @@ -27,6 +27,11 @@ pub fn create_router(state: Arc<AppState>) -> Router { .nest("/api", ws::terminal::router()) // Docker logs WS: /api/ws/docker/logs/:server_id (auth checked inside handler) .nest("/api", ws::docker_logs::router()) + // Unknown API paths must not fall through to the SPA index with 200. + .route( + "/api/{*path}", + axum::routing::any(|| async { axum::http::StatusCode::NOT_FOUND }), + ) // Swagger UI .merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi())) // Embedded frontend: serve the rust-embed SPA assets. diff --git a/crates/server/src/router/ws/agent/mod.rs b/crates/server/src/router/ws/agent/mod.rs index b4f1e2ec..8fe372b5 100644 --- a/crates/server/src/router/ws/agent/mod.rs +++ b/crates/server/src/router/ws/agent/mod.rs @@ -6,14 +6,17 @@ use axum::Router; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; use axum::extract::{ConnectInfo, Query, State}; use axum::http::HeaderMap; -use axum::response::Response; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; use axum::routing::get; use futures_util::{SinkExt, StreamExt}; use serde::Deserialize; use tokio::sync::mpsc; use crate::router::utils::extract_client_ip; -use crate::service::auth::AuthService; +use crate::service::agent_authority::{ + AdmissionError, NewConnection, PendingAdmission, PresentedRunToken, +}; use crate::service::record::RecordService; use crate::service::upgrade_tracker::UpgradeLookup; use crate::state::AppState; @@ -82,64 +85,39 @@ async fn agent_ws_handler( tracing::warn!( "Agent WS unauthorized from {addr}: missing token (query_present={query_present}, authorization_present={auth_present})" ); - return Response::builder() - .status(401) - .body("Unauthorized".into()) - .unwrap(); + return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(); } }; - // Validate agent token - let server = match AuthService::validate_agent_token(&state.db, &token).await { - Ok(Some(server)) => server, - Ok(None) => { - tracing::warn!( - "Agent WS unauthorized from {addr}: invalid token (source={}, prefix={})", - if query.token.as_deref() == Some(token.as_str()) { - "query" - } else { - "authorization" - }, - &token[..8.min(token.len())] - ); - return Response::builder() - .status(401) - .body("Unauthorized".into()) - .unwrap(); - } - Err(e) => { - tracing::error!("Failed to validate agent token: {e}"); - return Response::builder() - .status(500) - .body("Internal server error".into()) - .unwrap(); + let token = match PresentedRunToken::parse(token) { + Ok(token) => token, + Err(_) => { + tracing::warn!("Agent WS unauthorized from {addr}: invalid credential"); + return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(); + } + }; + let pending = match state.agent_authority.preflight_connection(token).await { + Ok(pending) => pending, + Err(AdmissionError::Rejected) => { + tracing::warn!("Agent WS unauthorized from {addr}: invalid credential"); + return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(); + } + Err(AdmissionError::Store(error)) => { + tracing::error!("Failed to preflight Agent WS admission: {error}"); + return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response(); } }; - let server_id = server.id.clone(); - let server_name = server.name.clone(); - let server_capabilities = server.capabilities; - tracing::info!("Agent WS upgrading for server {server_id} ({server_name}) from {addr}"); + tracing::info!("Agent WS preflight accepted from {addr}"); ws.max_message_size(MAX_WS_MESSAGE_SIZE) - .on_upgrade(move |socket| { - handle_agent_ws( - socket, - state, - server_id, - server_name, - server_capabilities, - addr, - ) - }) + .on_upgrade(move |socket| handle_agent_ws(socket, state, pending, addr)) } async fn handle_agent_ws( socket: WebSocket, state: Arc<AppState>, - server_id: String, - server_name: String, - server_capabilities: i32, + pending: PendingAdmission, remote_addr: SocketAddr, ) { let (mut ws_sink, mut ws_stream) = socket.split(); @@ -147,6 +125,31 @@ async fn handle_agent_ws( // Create mpsc channel for outgoing messages to this agent (buffer 64) let (tx, mut rx) = mpsc::channel::<ServerMessage>(64); + let admitted = match pending.admit(NewConnection { tx, remote_addr }).await { + Ok(admitted) => admitted, + Err(AdmissionError::Rejected) => { + tracing::warn!("Agent WS admission became invalid before upgrade completed"); + let _ = ws_sink.close().await; + return; + } + Err(AdmissionError::Store(error)) => { + tracing::error!("Failed to finalize Agent WS admission: {error}"); + let _ = ws_sink.close().await; + return; + } + }; + let server_id = admitted.server_id.into_inner(); + let server_name = admitted.server_name; + let server_capabilities = admitted.server_capabilities; + let connection_id = admitted.connection_id; + + // Seed the last-known agent capabilities from the persisted mirror so + // enforcement/display has a value before the agent's first SystemInfo. + // The agent overwrites this with its live value moments later. + state + .agent_manager + .update_agent_local_capabilities(&server_id, server_capabilities as u32); + // Send Welcome message. Capabilities are agent-owned, so the server does // NOT advertise any: the agent enforces purely on its local policy and // ignores this field. @@ -158,27 +161,15 @@ async fn handle_agent_ws( }; if let Err(e) = send_server_message(&mut ws_sink, &welcome).await { tracing::error!("Failed to send Welcome to {server_id}: {e}"); - return; - } - - // Register in AgentManager - let connection_id = { - let server_lock = state.agent_manager.server_cleanup_lock(&server_id); + let server_lock = state.agent_manager.server_lifecycle_lock(&server_id); let _guard = server_lock.lock().await; - let connection_id = - state - .agent_manager - .add_connection(server_id.clone(), server_name, tx, remote_addr); - // Seed the last-known agent capabilities from the persisted mirror so - // enforcement/display has a value before the agent's first SystemInfo. - // The agent overwrites this with its live value moments later. state .agent_manager - .update_agent_local_capabilities(&server_id, server_capabilities as u32); - connection_id - }; + .remove_connection_if_current(&server_id, connection_id); + return; + } - tracing::info!("Agent {server_id} connected from {remote_addr}"); + tracing::info!("Agent {server_id} ({server_name}) connected from {remote_addr}"); // Spawn a task to forward mpsc messages to WebSocket + send periodic Pings let sid_write = server_id.clone(); @@ -282,7 +273,7 @@ async fn handle_agent_ws( } // Cleanup: remove from AgentManager and abort write task - let server_lock = state.agent_manager.server_cleanup_lock(&server_id); + let server_lock = state.agent_manager.server_lifecycle_lock(&server_id); let _guard = server_lock.lock().await; if state .agent_manager @@ -310,19 +301,17 @@ async fn handle_current_connection_frame( connection_id: u64, frame: CurrentConnectionFrame, ) -> bool { - { - let server_lock = state.agent_manager.server_cleanup_lock(server_id); - let _guard = server_lock.lock().await; + let server_lock = state.agent_manager.server_lifecycle_lock(server_id); + let _guard = server_lock.lock().await; - if !state - .agent_manager - .is_current_connection(server_id, connection_id) - { - tracing::info!( - "Stopping superseded agent socket for {server_id} (connection_id={connection_id})" - ); - return false; - } + if !state + .agent_manager + .is_current_connection(server_id, connection_id) + { + tracing::info!( + "Stopping superseded agent socket for {server_id} (connection_id={connection_id})" + ); + return false; } match frame { @@ -649,7 +638,7 @@ mod tests { .agent_manager .add_connection("s1".into(), "Srv".into(), tx, test_addr()); - let server_lock = state.agent_manager.server_cleanup_lock("s1"); + let server_lock = state.agent_manager.server_lifecycle_lock("s1"); let held_guard = server_lock.lock().await; let task_state = Arc::clone(&state); diff --git a/crates/server/src/router/ws/browser.rs b/crates/server/src/router/ws/browser.rs index 736e3a93..aadf40d6 100644 --- a/crates/server/src/router/ws/browser.rs +++ b/crates/server/src/router/ws/browser.rs @@ -8,16 +8,18 @@ use axum::http::HeaderMap; use axum::response::{IntoResponse, Response}; use axum::routing::get; use futures_util::{SinkExt, StreamExt}; -use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder}; +use sea_orm::{EntityTrait, QueryOrder}; -use crate::entity::{agent_enrollment, server_tag}; +use crate::entity::server_tag; use crate::middleware::auth::resolve_ws_connection; use crate::service::agent_manager::aggregate_disk_io; use crate::service::server::ServerService; use crate::state::AppState; use serverbee_common::constants::MAX_WS_MESSAGE_SIZE; use serverbee_common::protocol::{BrowserClientMessage, BrowserMessage, ServerMessage}; -use serverbee_common::types::{OutstandingEnrollmentSummary, ServerStatus}; +use serverbee_common::types::{ + AgentAuthorityStateSummary, AgentAuthorityStatus, OutstandingEnrollmentSummary, ServerStatus, +}; pub fn router() -> Router<Arc<AppState>> { Router::new().route("/ws/servers", get(browser_ws_handler)) @@ -209,28 +211,68 @@ async fn build_full_sync(state: &Arc<AppState>, _is_admin: bool) -> BrowserMessa } let server_ids: Vec<String> = servers.iter().map(|s| s.id.clone()).collect(); - let outstanding_rows = if server_ids.is_empty() { - Vec::new() - } else { - agent_enrollment::Entity::find() - .filter(agent_enrollment::Column::TargetServerId.is_in(server_ids)) - .filter(agent_enrollment::Column::ConsumedAt.is_null()) - .filter(agent_enrollment::Column::RevokedAt.is_null()) - .all(&state.db) - .await - .unwrap_or_default() + let authority_ids = match server_ids + .into_iter() + .map(crate::service::agent_authority::ServerId::parse) + .collect::<Result<Vec<_>, _>>() + { + Ok(ids) => ids, + Err(error) => { + tracing::error!("Failed to parse stored Server ID for FullSync: {error}"); + return BrowserMessage::FullSync { + servers: Vec::new(), + upgrades: state.upgrade_tracker.snapshot(), + }; + } }; - let mut outstanding_by_server: HashMap<String, OutstandingEnrollmentSummary> = HashMap::new(); - for row in outstanding_rows { - outstanding_by_server.insert( - row.target_server_id.clone(), - OutstandingEnrollmentSummary { - id: row.id, - code_prefix: row.code_prefix, - expires_at: row.expires_at.to_rfc3339(), - created_at: row.created_at.to_rfc3339(), - }, + let mut authority_by_server: HashMap<String, AgentAuthorityStateSummary> = + match state.agent_authority.states(&authority_ids).await { + Ok(states) => states + .into_iter() + .map(|authority| { + let server_id = authority.server_id.as_str().to_string(); + let outstanding_offer = + authority + .outstanding_offer + .map(|offer| OutstandingEnrollmentSummary { + id: offer.id.into_inner(), + code_prefix: offer.code_prefix, + expires_at: offer.expires_at.to_rfc3339(), + created_at: offer.created_at.to_rfc3339(), + }); + let status = match authority.authority { + crate::service::agent_authority::AuthorityStatus::Claimed => { + AgentAuthorityStatus::Claimed + } + crate::service::agent_authority::AuthorityStatus::Unclaimed => { + AgentAuthorityStatus::Unclaimed + } + }; + ( + server_id, + AgentAuthorityStateSummary { + status, + outstanding_offer, + }, + ) + }) + .collect(), + Err(error) => { + tracing::error!("Failed to project Agent Authority for FullSync: {error}"); + return BrowserMessage::FullSync { + servers: Vec::new(), + upgrades: state.upgrade_tracker.snapshot(), + }; + } + }; + if authority_by_server.len() != servers.len() { + tracing::warn!( + "Server set changed while building FullSync; retrying on the next connection" ); + return BrowserMessage::FullSync { + servers: Vec::new(), + upgrades: state.upgrade_tracker.snapshot(), + }; } let statuses: Vec<ServerStatus> = servers @@ -290,6 +332,10 @@ async fn build_full_sync(state: &Arc<AppState>, _is_admin: bool) -> BrowserMessa .map(|r| aggregate_disk_io(r)) .unwrap_or((0, 0)); + let agent_authority = authority_by_server.remove(&server.id).unwrap_or_default(); + let outstanding_enrollment = agent_authority.outstanding_offer.clone(); + let has_token = agent_authority.status == AgentAuthorityStatus::Claimed; + ServerStatus { id: server.id.clone(), name: server.name.clone(), @@ -323,8 +369,9 @@ async fn build_full_sync(state: &Arc<AppState>, _is_admin: bool) -> BrowserMessa disk_write_bytes_per_sec, tags: tags_by_server.remove(&server.id).unwrap_or_default(), cpu_cores: server.cpu_cores, - has_token: server.token_hash.is_some(), - outstanding_enrollment: outstanding_by_server.remove(&server.id), + has_token, + agent_authority, + outstanding_enrollment, } }) .collect(); diff --git a/crates/server/src/service/agent_authority/mod.rs b/crates/server/src/service/agent_authority/mod.rs new file mode 100644 index 00000000..dff87af0 --- /dev/null +++ b/crates/server/src/service/agent_authority/mod.rs @@ -0,0 +1,1719 @@ +mod model; + +use std::collections::HashMap; +use std::sync::Arc; + +use chrono::{Duration, Utc}; +use sea_orm::{ + ActiveModelTrait, ActiveValue::Set, ColumnTrait, ConnectionTrait, DatabaseConnection, + DatabaseTransaction, EntityTrait, QueryFilter, QueryOrder, QuerySelect, TransactionTrait, +}; +use serverbee_common::types::{ + AgentAuthorityStateSummary, AgentAuthorityStatus, OutstandingEnrollmentSummary, +}; +use uuid::Uuid; + +use crate::entity::{agent_authority_event, enrollment_offer, server}; +use crate::error::AppError; +use crate::service::agent_manager::AgentManager; +use crate::service::auth::AuthService; +use crate::service::server::ServerService; + +pub use model::*; + +#[derive(Clone)] +pub struct AgentAuthority { + db: DatabaseConnection, + agent_manager: Arc<AgentManager>, +} + +impl AgentAuthority { + pub fn new(db: DatabaseConnection, agent_manager: Arc<AgentManager>) -> Self { + Self { db, agent_manager } + } + + pub async fn issue_offer_for_unclaimed( + &self, + input: IssueOfferForUnclaimed, + ) -> Result<IssuedOffer, IssueOfferError> { + let server_lock = self + .agent_manager + .server_lifecycle_lock(input.server_id.as_str()); + let _guard = server_lock.lock().await; + let tx = self.db.begin().await.map_err(AppError::from)?; + let server = server::Entity::find_by_id(input.server_id.as_str()) + .one(&tx) + .await + .map_err(AppError::from)? + .ok_or(IssueOfferError::NotFound)?; + if authority_status(&server) == AuthorityStatus::Claimed { + return Err(IssueOfferError::AlreadyClaimed); + } + + expire_elapsed_outstanding(&tx, &server, &input.actor, &input.source).await?; + if let Some(current) = find_outstanding(&tx, &server.id, Utc::now()).await? { + return Err(IssueOfferError::OutstandingExists(to_outstanding(current)?)); + } + + let issued = mint_offer(&tx, &server.id, &input.actor, input.ttl).await?; + insert_event( + &tx, + EventInput { + server: &server, + actor: &input.actor, + source: &input.source, + offer_id: Some(issued.id.as_str()), + transition: AuthorityTransition::OfferIssued, + mode: None, + offer_outcome: None, + authority_before: AuthorityStatus::Unclaimed, + authority_after: AuthorityStatus::Unclaimed, + }, + ) + .await?; + tx.commit().await.map_err(AppError::from)?; + self.broadcast_issued_offer_state(&server.id, AuthorityStatus::Unclaimed, &issued); + Ok(issued) + } + + pub async fn claim(&self, input: ClaimAgent) -> Result<ClaimReceipt, ClaimError> { + let Some(candidate) = find_offer_for_code(&self.db, &input.code).await? else { + return Err(ClaimError::Rejected); + }; + let token_hash = AuthService::hash_password(input.proposed_run_token.expose())?; + let token_prefix = input.proposed_run_token.expose()[..8].to_string(); + let server_lock = self + .agent_manager + .server_lifecycle_lock(&candidate.target_server_id); + let _guard = server_lock.lock().await; + let tx = self.db.begin().await.map_err(AppError::from)?; + + let Some(offer) = enrollment_offer::Entity::find_by_id(&candidate.id) + .one(&tx) + .await + .map_err(AppError::from)? + else { + return Err(ClaimError::Rejected); + }; + if offer.outcome.is_some() + || !AuthService::verify_password(input.code.expose(), &offer.code_hash)? + { + return Err(ClaimError::Rejected); + } + let server = server::Entity::find_by_id(&offer.target_server_id) + .one(&tx) + .await + .map_err(AppError::from)? + .ok_or_else(|| AppError::Internal("enrollment offer target vanished".to_string()))?; + let before = authority_status(&server); + if offer.expires_at <= Utc::now() { + terminalize_offer( + &tx, + offer, + OfferOutcome::Expired, + None, + &server, + &Actor::Agent, + &input.source, + before, + before, + ) + .await?; + tx.commit().await.map_err(AppError::from)?; + return Err(ClaimError::Rejected); + } + + let server_id = server.id.clone(); + self.agent_manager.remove_connection(&server_id); + let now = Utc::now(); + let offer_id = offer.id.clone(); + let mut active_offer: enrollment_offer::ActiveModel = offer.into(); + active_offer.outcome = Set(Some(OfferOutcome::Consumed.as_str().to_string())); + active_offer.terminal_at = Set(Some(now)); + active_offer.successor_offer_id = Set(None); + active_offer.update(&tx).await.map_err(AppError::from)?; + + let mut active_server: server::ActiveModel = server.clone().into(); + active_server.token_hash = Set(Some(token_hash)); + active_server.token_prefix = Set(Some(token_prefix)); + active_server.last_remote_addr = Set(input.remote_addr.clone()); + active_server.updated_at = Set(now); + active_server.update(&tx).await.map_err(AppError::from)?; + + insert_event( + &tx, + EventInput { + server: &server, + actor: &Actor::Agent, + source: &input.source, + offer_id: Some(&offer_id), + transition: AuthorityTransition::OfferConsumed, + mode: None, + offer_outcome: Some(OfferOutcome::Consumed), + authority_before: before, + authority_after: AuthorityStatus::Claimed, + }, + ) + .await?; + tx.commit().await.map_err(AppError::from)?; + self.broadcast_authority_state(&server_id, AuthorityStatus::Claimed, None); + + Ok(ClaimReceipt { + server_id: ServerId::parse(server_id).map_err(|error| { + AppError::Internal(format!("invalid stored server id: {error}")) + })?, + }) + } + + pub async fn begin_reenrollment( + &self, + input: BeginReenrollment, + ) -> Result<IssuedOffer, ReenrollmentError> { + let server_lock = self + .agent_manager + .server_lifecycle_lock(input.server_id.as_str()); + let _guard = server_lock.lock().await; + let tx = self.db.begin().await.map_err(AppError::from)?; + let server = server::Entity::find_by_id(input.server_id.as_str()) + .one(&tx) + .await + .map_err(AppError::from)? + .ok_or(ReenrollmentError::NotFound)?; + if authority_status(&server) == AuthorityStatus::Unclaimed { + return Err(ReenrollmentError::Unclaimed); + } + + expire_elapsed_outstanding(&tx, &server, &input.actor, &input.source).await?; + if let Some(current) = find_outstanding(&tx, &server.id, Utc::now()).await? { + return Err(ReenrollmentError::OutstandingExists(to_outstanding( + current, + )?)); + } + + let authority_after = match input.mode { + ReenrollmentMode::Graceful => AuthorityStatus::Claimed, + ReenrollmentMode::Emergency => { + self.agent_manager.remove_connection(&server.id); + let mut active: server::ActiveModel = server.clone().into(); + active.token_hash = Set(None); + active.token_prefix = Set(None); + active.updated_at = Set(Utc::now()); + active.update(&tx).await.map_err(AppError::from)?; + AuthorityStatus::Unclaimed + } + }; + let issued = mint_offer(&tx, &server.id, &input.actor, input.ttl).await?; + insert_event( + &tx, + EventInput { + server: &server, + actor: &input.actor, + source: &input.source, + offer_id: Some(issued.id.as_str()), + transition: AuthorityTransition::ReenrollmentStarted, + mode: Some(input.mode), + offer_outcome: None, + authority_before: AuthorityStatus::Claimed, + authority_after, + }, + ) + .await?; + tx.commit().await.map_err(AppError::from)?; + self.broadcast_issued_offer_state(&server.id, authority_after, &issued); + Ok(issued) + } + + pub async fn replace_offer( + &self, + input: ReplaceOffer, + ) -> Result<IssuedOffer, ReplaceOfferError> { + let server_lock = self + .agent_manager + .server_lifecycle_lock(input.server_id.as_str()); + let _guard = server_lock.lock().await; + let tx = self.db.begin().await.map_err(AppError::from)?; + let server = server::Entity::find_by_id(input.server_id.as_str()) + .one(&tx) + .await + .map_err(AppError::from)? + .ok_or(ReplaceOfferError::ServerNotFound)?; + let offer = enrollment_offer::Entity::find_by_id(input.offer_id.as_str()) + .one(&tx) + .await + .map_err(AppError::from)? + .filter(|offer| offer.target_server_id == server.id) + .ok_or(ReplaceOfferError::OfferNotFound)?; + + if let Some(outcome) = offer.outcome.as_deref() { + let current = find_outstanding(&tx, &server.id, Utc::now()) + .await? + .map(to_outstanding) + .transpose()?; + return Err(ReplaceOfferError::NotOutstanding { + outcome: parse_outcome(outcome)?, + current, + }); + } + if offer.expires_at <= Utc::now() { + let before = authority_status(&server); + terminalize_offer( + &tx, + offer, + OfferOutcome::Expired, + None, + &server, + &input.actor, + &input.source, + before, + before, + ) + .await?; + tx.commit().await.map_err(AppError::from)?; + return Err(ReplaceOfferError::NotOutstanding { + outcome: OfferOutcome::Expired, + current: None, + }); + } + + let current = find_outstanding(&tx, &server.id, Utc::now()).await?; + if current.as_ref().map(|current| current.id.as_str()) != Some(input.offer_id.as_str()) { + return Err(ReplaceOfferError::Stale { + current: current.map(to_outstanding).transpose()?, + }); + } + + let successor = prepare_offer(&server.id, &input.actor, input.ttl)?; + let now = Utc::now(); + let old_offer_id = offer.id.clone(); + let mut active: enrollment_offer::ActiveModel = offer.into(); + active.outcome = Set(Some(OfferOutcome::Replaced.as_str().to_string())); + active.terminal_at = Set(Some(now)); + active.successor_offer_id = Set(Some(successor.id.clone())); + active.update(&tx).await.map_err(AppError::from)?; + let issued = insert_prepared_offer(&tx, successor).await?; + let status = authority_status(&server); + insert_event( + &tx, + EventInput { + server: &server, + actor: &input.actor, + source: &input.source, + offer_id: Some(&old_offer_id), + transition: AuthorityTransition::OfferReplaced, + mode: None, + offer_outcome: Some(OfferOutcome::Replaced), + authority_before: status, + authority_after: status, + }, + ) + .await?; + tx.commit().await.map_err(AppError::from)?; + self.broadcast_issued_offer_state(&server.id, status, &issued); + Ok(issued) + } + + pub async fn revoke_offer(&self, input: RevokeOffer) -> Result<RevokedOffer, RevokeOfferError> { + let server_lock = self + .agent_manager + .server_lifecycle_lock(input.server_id.as_str()); + let _guard = server_lock.lock().await; + let tx = self.db.begin().await.map_err(AppError::from)?; + let server = server::Entity::find_by_id(input.server_id.as_str()) + .one(&tx) + .await + .map_err(AppError::from)? + .ok_or(RevokeOfferError::ServerNotFound)?; + let offer = enrollment_offer::Entity::find_by_id(input.offer_id.as_str()) + .one(&tx) + .await + .map_err(AppError::from)? + .filter(|offer| offer.target_server_id == server.id) + .ok_or(RevokeOfferError::OfferNotFound)?; + + if let Some(outcome) = offer.outcome.as_deref() { + let outcome = parse_outcome(outcome)?; + if outcome == OfferOutcome::Revoked { + return Ok(RevokedOffer { + offer_id: input.offer_id, + already_revoked: true, + }); + } + return Err(RevokeOfferError::Terminal(outcome)); + } + + let status = authority_status(&server); + if offer.expires_at <= Utc::now() { + terminalize_offer( + &tx, + offer, + OfferOutcome::Expired, + None, + &server, + &input.actor, + &input.source, + status, + status, + ) + .await?; + tx.commit().await.map_err(AppError::from)?; + self.broadcast_authority_state(&server.id, status, None); + return Err(RevokeOfferError::Terminal(OfferOutcome::Expired)); + } + + terminalize_offer( + &tx, + offer, + OfferOutcome::Revoked, + None, + &server, + &input.actor, + &input.source, + status, + status, + ) + .await?; + tx.commit().await.map_err(AppError::from)?; + self.broadcast_authority_state(&server.id, status, None); + Ok(RevokedOffer { + offer_id: input.offer_id, + already_revoked: false, + }) + } + + pub async fn revoke_authority( + &self, + input: RevokeAuthority, + ) -> Result<RevocationReceipt, RevokeAuthorityError> { + let server_lock = self + .agent_manager + .server_lifecycle_lock(input.server_id.as_str()); + let _guard = server_lock.lock().await; + let tx = self.db.begin().await.map_err(AppError::from)?; + let server = server::Entity::find_by_id(input.server_id.as_str()) + .one(&tx) + .await + .map_err(AppError::from)? + .ok_or(RevokeAuthorityError::NotFound)?; + let before = authority_status(&server); + let open_offer = find_open_offer(&tx, &server.id).await?; + if before == AuthorityStatus::Unclaimed && open_offer.is_none() { + self.agent_manager.remove_connection(&server.id); + self.broadcast_authority_state(&server.id, AuthorityStatus::Unclaimed, None); + return Ok(RevocationReceipt { + server_id: input.server_id, + changed: false, + }); + } + + self.agent_manager.remove_connection(&server.id); + if let Some(offer) = open_offer { + let outcome = if offer.expires_at <= Utc::now() { + OfferOutcome::Expired + } else { + OfferOutcome::Revoked + }; + terminalize_offer( + &tx, + offer, + outcome, + None, + &server, + &input.actor, + &input.source, + before, + before, + ) + .await?; + } + if before == AuthorityStatus::Claimed { + let mut active: server::ActiveModel = server.clone().into(); + active.token_hash = Set(None); + active.token_prefix = Set(None); + active.updated_at = Set(Utc::now()); + active.update(&tx).await.map_err(AppError::from)?; + insert_event( + &tx, + EventInput { + server: &server, + actor: &input.actor, + source: &input.source, + offer_id: None, + transition: AuthorityTransition::AuthorityRevoked, + mode: None, + offer_outcome: None, + authority_before: AuthorityStatus::Claimed, + authority_after: AuthorityStatus::Unclaimed, + }, + ) + .await?; + } + tx.commit().await.map_err(AppError::from)?; + self.broadcast_authority_state(&server.id, AuthorityStatus::Unclaimed, None); + Ok(RevocationReceipt { + server_id: input.server_id, + changed: true, + }) + } + + pub async fn state(&self, server_id: ServerId) -> Result<AuthorityState, StateError> { + self.states(std::slice::from_ref(&server_id)) + .await? + .into_iter() + .next() + .ok_or(StateError::NotFound) + } + + pub async fn states(&self, server_ids: &[ServerId]) -> Result<Vec<AuthorityState>, StateError> { + if server_ids.is_empty() { + return Ok(Vec::new()); + } + let ids: Vec<String> = server_ids + .iter() + .map(|server_id| server_id.as_str().to_string()) + .collect(); + let servers = server::Entity::find() + .filter(server::Column::Id.is_in(ids.iter().cloned())) + .all(&self.db) + .await + .map_err(AppError::from)?; + let offers = enrollment_offer::Entity::find() + .filter(enrollment_offer::Column::TargetServerId.is_in(ids)) + .filter(enrollment_offer::Column::Outcome.is_null()) + .filter(enrollment_offer::Column::ExpiresAt.gt(Utc::now())) + .all(&self.db) + .await + .map_err(AppError::from)?; + let mut offers_by_server: HashMap<String, OutstandingOffer> = offers + .into_iter() + .map(|offer| { + let server_id = offer.target_server_id.clone(); + to_outstanding(offer).map(|offer| (server_id, offer)) + }) + .collect::<Result<_, _>>()?; + + servers + .into_iter() + .map(|server| { + Ok(AuthorityState { + server_id: ServerId::parse(server.id.clone()).map_err(|error| { + AppError::Internal(format!("invalid stored server id: {error}")) + })?, + authority: authority_status(&server), + outstanding_offer: offers_by_server.remove(&server.id), + }) + }) + .collect() + } + + pub async fn history(&self, query: HistoryQuery) -> Result<Vec<AuthorityEvent>, HistoryError> { + let limit = query.limit.clamp(1, 500); + let rows = agent_authority_event::Entity::find() + .filter(agent_authority_event::Column::ServerId.eq(query.server_id.as_str())) + .order_by_desc(agent_authority_event::Column::CreatedAt) + .limit(limit) + .all(&self.db) + .await + .map_err(AppError::from)?; + rows.into_iter().map(to_authority_event).collect() + } + + pub async fn preflight_connection( + &self, + token: PresentedRunToken, + ) -> Result<PendingAdmission, AdmissionError> { + let server = AuthService::validate_agent_token(&self.db, token.expose()) + .await? + .ok_or(AdmissionError::Rejected)?; + Ok(PendingAdmission { + authority: self.clone(), + expected_server_id: ServerId::parse(server.id).map_err(|error| { + AppError::Internal(format!("invalid stored server id: {error}")) + })?, + token, + }) + } + + pub(crate) async fn issue_initial_offer_tx( + &self, + tx: &DatabaseTransaction, + server: &server::Model, + actor: &Actor, + source: &RequestSource, + ttl: OfferTtl, + ) -> Result<IssuedOffer, AppError> { + let issued = mint_offer(tx, &server.id, actor, ttl).await?; + insert_event( + tx, + EventInput { + server, + actor, + source, + offer_id: Some(issued.id.as_str()), + transition: AuthorityTransition::InitialOfferIssued, + mode: None, + offer_outcome: None, + authority_before: AuthorityStatus::Unclaimed, + authority_after: AuthorityStatus::Unclaimed, + }, + ) + .await?; + Ok(issued) + } + + pub(crate) fn broadcast_issued_offer_state( + &self, + server_id: &str, + status: AuthorityStatus, + offer: &IssuedOffer, + ) { + self.broadcast_authority_state( + server_id, + status, + Some(OutstandingEnrollmentSummary { + id: offer.id.as_str().to_string(), + code_prefix: offer.code_prefix.clone(), + expires_at: offer.expires_at.to_rfc3339(), + created_at: offer.created_at.to_rfc3339(), + }), + ); + } + + fn broadcast_authority_state( + &self, + server_id: &str, + status: AuthorityStatus, + outstanding_offer: Option<OutstandingEnrollmentSummary>, + ) { + self.agent_manager.broadcast_agent_authority_changed( + server_id.to_string(), + AgentAuthorityStateSummary { + status: match status { + AuthorityStatus::Claimed => AgentAuthorityStatus::Claimed, + AuthorityStatus::Unclaimed => AgentAuthorityStatus::Unclaimed, + }, + outstanding_offer, + }, + ); + } + + pub(crate) async fn record_server_deleted_tx( + &self, + tx: &DatabaseTransaction, + server: &server::Model, + actor: &Actor, + source: &RequestSource, + ) -> Result<(), AppError> { + let status = authority_status(server); + insert_event( + tx, + EventInput { + server, + actor, + source, + offer_id: None, + transition: AuthorityTransition::ServerDeleted, + mode: None, + offer_outcome: None, + authority_before: status, + authority_after: AuthorityStatus::Unclaimed, + }, + ) + .await + } + + pub(crate) async fn delete_servers( + &self, + server_ids: &[ServerId], + actor: &Actor, + source: &RequestSource, + ) -> Result<Vec<server::Model>, AppError> { + let mut ids: Vec<String> = server_ids + .iter() + .map(|server_id| server_id.as_str().to_string()) + .collect(); + ids.sort(); + ids.dedup(); + if ids.is_empty() { + return Ok(Vec::new()); + } + + let locks: Vec<_> = ids + .iter() + .map(|server_id| self.agent_manager.server_lifecycle_lock(server_id)) + .collect(); + let mut guards = Vec::with_capacity(locks.len()); + for lock in &locks { + guards.push(lock.lock().await); + } + + let tx = self.db.begin().await?; + let rows = server::Entity::find() + .filter(server::Column::Id.is_in(ids.iter().cloned())) + .all(&tx) + .await?; + for row in &rows { + self.agent_manager.remove_connection(&row.id); + } + for row in &rows { + self.record_server_deleted_tx(&tx, row, actor, source) + .await?; + } + let existing_ids: Vec<String> = rows.iter().map(|row| row.id.clone()).collect(); + if !existing_ids.is_empty() { + ServerService::delete_server_scoped_rows(&tx, &existing_ids).await?; + server::Entity::delete_many() + .filter(server::Column::Id.is_in(existing_ids.iter().cloned())) + .exec(&tx) + .await?; + } + tx.commit().await?; + + for server_id in &existing_ids { + self.agent_manager.remove_cached_report(server_id); + } + drop(guards); + Ok(rows) + } +} + +pub struct PendingAdmission { + authority: AgentAuthority, + expected_server_id: ServerId, + token: PresentedRunToken, +} + +impl std::fmt::Debug for PendingAdmission { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PendingAdmission") + .field("expected_server_id", &self.expected_server_id) + .field("token", &"<redacted>") + .finish() + } +} + +impl PendingAdmission { + pub async fn admit( + self, + connection: NewConnection, + ) -> Result<AdmittedConnection, AdmissionError> { + let server_lock = self + .authority + .agent_manager + .server_lifecycle_lock(self.expected_server_id.as_str()); + let _guard = server_lock.lock().await; + let server = AuthService::validate_agent_token(&self.authority.db, self.token.expose()) + .await? + .filter(|server| server.id == self.expected_server_id.as_str()) + .ok_or(AdmissionError::Rejected)?; + let connection_id = self.authority.agent_manager.add_connection( + server.id.clone(), + server.name.clone(), + connection.tx, + connection.remote_addr, + ); + Ok(AdmittedConnection { + server_id: self.expected_server_id, + server_name: server.name, + server_capabilities: server.capabilities, + connection_id, + }) + } +} + +struct PreparedOffer { + id: String, + model: enrollment_offer::ActiveModel, + code: EnrollmentCode, +} + +fn prepare_offer(server_id: &str, actor: &Actor, ttl: OfferTtl) -> Result<PreparedOffer, AppError> { + let now = Utc::now(); + let plaintext = AuthService::generate_session_token(); + let code = EnrollmentCode::parse(plaintext.clone()).map_err(|error| { + AppError::Internal(format!("generated invalid enrollment code: {error}")) + })?; + let code_hash = AuthService::hash_password(&plaintext)?; + let code_prefix = plaintext[..8].to_string(); + let id = Uuid::new_v4().to_string(); + Ok(PreparedOffer { + id: id.clone(), + model: enrollment_offer::ActiveModel { + id: Set(id), + code_hash: Set(code_hash), + code_prefix: Set(code_prefix), + target_server_id: Set(server_id.to_string()), + created_by: Set(actor.offer_creator()), + expires_at: Set(now + Duration::seconds(ttl.value())), + outcome: Set(None), + terminal_at: Set(None), + successor_offer_id: Set(None), + created_at: Set(now), + }, + code, + }) +} + +async fn insert_prepared_offer<C: ConnectionTrait>( + conn: &C, + prepared: PreparedOffer, +) -> Result<IssuedOffer, AppError> { + let row = prepared.model.insert(conn).await?; + Ok(IssuedOffer { + id: OfferId::parse(row.id) + .map_err(|error| AppError::Internal(format!("invalid stored offer id: {error}")))?, + code: prepared.code, + code_prefix: row.code_prefix, + expires_at: row.expires_at, + created_at: row.created_at, + }) +} + +async fn mint_offer<C: ConnectionTrait>( + conn: &C, + server_id: &str, + actor: &Actor, + ttl: OfferTtl, +) -> Result<IssuedOffer, AppError> { + insert_prepared_offer(conn, prepare_offer(server_id, actor, ttl)?).await +} + +async fn find_offer_for_code<C: ConnectionTrait>( + conn: &C, + code: &EnrollmentCode, +) -> Result<Option<enrollment_offer::Model>, AppError> { + let candidates = enrollment_offer::Entity::find() + .filter(enrollment_offer::Column::CodePrefix.eq(&code.expose()[..8])) + .filter(enrollment_offer::Column::Outcome.is_null()) + .all(conn) + .await?; + for candidate in candidates { + if AuthService::verify_password(code.expose(), &candidate.code_hash)? { + return Ok(Some(candidate)); + } + } + Ok(None) +} + +async fn find_outstanding<C: ConnectionTrait>( + conn: &C, + server_id: &str, + now: chrono::DateTime<Utc>, +) -> Result<Option<enrollment_offer::Model>, AppError> { + Ok(enrollment_offer::Entity::find() + .filter(enrollment_offer::Column::TargetServerId.eq(server_id)) + .filter(enrollment_offer::Column::Outcome.is_null()) + .filter(enrollment_offer::Column::ExpiresAt.gt(now)) + .one(conn) + .await?) +} + +async fn find_open_offer<C: ConnectionTrait>( + conn: &C, + server_id: &str, +) -> Result<Option<enrollment_offer::Model>, AppError> { + Ok(enrollment_offer::Entity::find() + .filter(enrollment_offer::Column::TargetServerId.eq(server_id)) + .filter(enrollment_offer::Column::Outcome.is_null()) + .one(conn) + .await?) +} + +async fn expire_elapsed_outstanding( + tx: &DatabaseTransaction, + server: &server::Model, + actor: &Actor, + source: &RequestSource, +) -> Result<(), AppError> { + let Some(offer) = find_open_offer(tx, &server.id).await? else { + return Ok(()); + }; + if offer.expires_at > Utc::now() { + return Ok(()); + } + let status = authority_status(server); + terminalize_offer( + tx, + offer, + OfferOutcome::Expired, + None, + server, + actor, + source, + status, + status, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +async fn terminalize_offer( + tx: &DatabaseTransaction, + offer: enrollment_offer::Model, + outcome: OfferOutcome, + successor_offer_id: Option<String>, + server: &server::Model, + actor: &Actor, + source: &RequestSource, + authority_before: AuthorityStatus, + authority_after: AuthorityStatus, +) -> Result<(), AppError> { + let offer_id = offer.id.clone(); + let mut active: enrollment_offer::ActiveModel = offer.into(); + active.outcome = Set(Some(outcome.as_str().to_string())); + active.terminal_at = Set(Some(Utc::now())); + active.successor_offer_id = Set(successor_offer_id); + active.update(tx).await?; + insert_event( + tx, + EventInput { + server, + actor, + source, + offer_id: Some(&offer_id), + transition: match outcome { + OfferOutcome::Consumed => AuthorityTransition::OfferConsumed, + OfferOutcome::Revoked => AuthorityTransition::OfferRevoked, + OfferOutcome::Replaced => AuthorityTransition::OfferReplaced, + OfferOutcome::Expired => AuthorityTransition::OfferExpired, + }, + mode: None, + offer_outcome: Some(outcome), + authority_before, + authority_after, + }, + ) + .await +} + +struct EventInput<'a> { + server: &'a server::Model, + actor: &'a Actor, + source: &'a RequestSource, + offer_id: Option<&'a str>, + transition: AuthorityTransition, + mode: Option<ReenrollmentMode>, + offer_outcome: Option<OfferOutcome>, + authority_before: AuthorityStatus, + authority_after: AuthorityStatus, +} + +async fn insert_event(tx: &DatabaseTransaction, input: EventInput<'_>) -> Result<(), AppError> { + agent_authority_event::ActiveModel { + id: Set(Uuid::new_v4().to_string()), + server_id: Set(input.server.id.clone()), + server_name: Set(input.server.name.clone()), + actor_kind: Set(input.actor.kind().as_str().to_string()), + actor_id: Set(input.actor.id().map(ToOwned::to_owned)), + request_source: Set(input.source.as_str().to_string()), + offer_id: Set(input.offer_id.map(ToOwned::to_owned)), + transition: Set(input.transition.as_str().to_string()), + mode: Set(input.mode.map(|mode| mode.as_str().to_string())), + offer_outcome: Set(input + .offer_outcome + .map(|outcome| outcome.as_str().to_string())), + authority_before: Set(input.authority_before.as_str().to_string()), + authority_after: Set(input.authority_after.as_str().to_string()), + created_at: Set(Utc::now()), + } + .insert(tx) + .await?; + Ok(()) +} + +fn authority_status(server: &server::Model) -> AuthorityStatus { + if server.token_hash.is_some() { + AuthorityStatus::Claimed + } else { + AuthorityStatus::Unclaimed + } +} + +fn parse_outcome(value: &str) -> Result<OfferOutcome, AppError> { + OfferOutcome::parse(value) + .ok_or_else(|| AppError::Internal(format!("invalid stored offer outcome: {value}"))) +} + +fn parse_authority(value: &str) -> Result<AuthorityStatus, AppError> { + match value { + "claimed" => Ok(AuthorityStatus::Claimed), + "unclaimed" => Ok(AuthorityStatus::Unclaimed), + _ => Err(AppError::Internal(format!( + "invalid stored authority status: {value}" + ))), + } +} + +fn to_outstanding(row: enrollment_offer::Model) -> Result<OutstandingOffer, AppError> { + Ok(OutstandingOffer { + id: OfferId::parse(row.id) + .map_err(|error| AppError::Internal(format!("invalid stored offer id: {error}")))?, + code_prefix: row.code_prefix, + expires_at: row.expires_at, + created_at: row.created_at, + }) +} + +fn to_authority_event(row: agent_authority_event::Model) -> Result<AuthorityEvent, HistoryError> { + Ok(AuthorityEvent { + id: row.id, + server_id: ServerId::parse(row.server_id) + .map_err(|error| AppError::Internal(format!("invalid stored server id: {error}")))?, + server_name: row.server_name, + actor_kind: ActorKind::parse(&row.actor_kind).ok_or_else(|| { + AppError::Internal(format!("invalid stored actor kind: {}", row.actor_kind)) + })?, + actor_id: row.actor_id, + request_source: row.request_source, + offer_id: row + .offer_id + .map(OfferId::parse) + .transpose() + .map_err(|error| AppError::Internal(format!("invalid stored offer id: {error}")))?, + transition: AuthorityTransition::parse(&row.transition).ok_or_else(|| { + AppError::Internal(format!( + "invalid stored authority transition: {}", + row.transition + )) + })?, + mode: match row.mode.as_deref() { + Some(value) => Some(ReenrollmentMode::parse(value).ok_or_else(|| { + AppError::Internal(format!("invalid stored re-enrollment mode: {value}")) + })?), + None => None, + }, + offer_outcome: row + .offer_outcome + .as_deref() + .map(parse_outcome) + .transpose()?, + authority_before: parse_authority(&row.authority_before)?, + authority_after: parse_authority(&row.authority_after)?, + created_at: row.created_at, + }) +} + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + use chrono::{Duration, Utc}; + use sea_orm::{ + ActiveModelTrait, ActiveValue::Set, ColumnTrait, ConnectionTrait, DatabaseConnection, + EntityTrait, PaginatorTrait, QueryFilter, + }; + use serverbee_common::constants::CAP_DEFAULT; + use tokio::sync::{broadcast, mpsc}; + + use super::*; + use crate::entity::{agent_authority_event, enrollment_offer, server}; + use crate::test_utils::setup_test_db; + + const FIRST_TOKEN: &str = "first-token-0123456789abcdefghijklmnop"; + const SECOND_TOKEN: &str = "second-token-0123456789abcdefghijklmno"; + + struct Fixture { + authority: AgentAuthority, + db: DatabaseConnection, + agent_manager: Arc<AgentManager>, + server_id: ServerId, + _tmp: tempfile::TempDir, + } + + impl Fixture { + async fn issue(&self) -> IssuedOffer { + self.authority + .issue_offer_for_unclaimed(IssueOfferForUnclaimed { + server_id: self.server_id.clone(), + actor: user_actor(), + source: api_source(), + ttl: OfferTtl::default(), + }) + .await + .expect("issue offer") + } + + async fn claim(&self, code: EnrollmentCode, token: &str) -> ClaimReceipt { + self.authority + .claim(ClaimAgent { + code, + proposed_run_token: ProposedRunToken::parse(token).expect("run token"), + source: agent_source(), + remote_addr: Some("127.0.0.1".to_string()), + }) + .await + .expect("claim") + } + + async fn claim_initial(&self, token: &str) -> IssuedOffer { + let offer = self.issue().await; + self.claim(offer.code.clone(), token).await; + offer + } + + fn add_connection(&self) -> u64 { + let (tx, _rx) = mpsc::channel(1); + self.agent_manager.add_connection( + self.server_id.as_str().to_string(), + "Server One".to_string(), + tx, + loopback(), + ) + } + } + + async fn authority_with_unclaimed_server() -> Fixture { + let (db, tmp) = setup_test_db().await; + let now = Utc::now(); + let server_id = ServerId::parse("server-1").expect("server id"); + server::ActiveModel { + id: Set(server_id.as_str().to_string()), + token_hash: Set(None), + token_prefix: Set(None), + name: Set("Server One".to_string()), + weight: Set(0), + hidden: Set(false), + capabilities: Set(CAP_DEFAULT as i32), + protocol_version: Set(1), + features: Set("[]".to_string()), + geo_manual: Set(false), + created_at: Set(now), + updated_at: Set(now), + ..Default::default() + } + .insert(&db) + .await + .expect("seed server"); + let (browser_tx, _) = broadcast::channel(8); + let agent_manager = Arc::new(AgentManager::new(browser_tx)); + Fixture { + authority: AgentAuthority::new(db.clone(), agent_manager.clone()), + db, + agent_manager, + server_id, + _tmp: tmp, + } + } + + fn user_actor() -> Actor { + Actor::User { + id: "user-1".to_string(), + } + } + + fn api_source() -> RequestSource { + RequestSource::parse("api:test").expect("source") + } + + fn agent_source() -> RequestSource { + RequestSource::parse("agent:register").expect("source") + } + + fn loopback() -> SocketAddr { + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9527) + } + + #[tokio::test] + async fn issue_offer_for_unclaimed_exposes_one_outstanding_offer() { + let fixture = authority_with_unclaimed_server().await; + + let issued = fixture + .authority + .issue_offer_for_unclaimed(IssueOfferForUnclaimed { + server_id: fixture.server_id.clone(), + actor: user_actor(), + source: api_source(), + ttl: OfferTtl::default(), + }) + .await + .expect("issue offer"); + + assert_eq!(issued.code_prefix, &issued.code.expose()[..8]); + assert_eq!(issued.id.as_str().len(), 36); + + let state = fixture + .authority + .state(fixture.server_id.clone()) + .await + .expect("state"); + assert_eq!(state.authority, AuthorityStatus::Unclaimed); + assert_eq!( + state.outstanding_offer.as_ref().map(|offer| &offer.id), + Some(&issued.id) + ); + + let duplicate = fixture + .authority + .issue_offer_for_unclaimed(IssueOfferForUnclaimed { + server_id: fixture.server_id.clone(), + actor: user_actor(), + source: api_source(), + ttl: OfferTtl::default(), + }) + .await; + assert!(matches!( + duplicate, + Err(IssueOfferError::OutstandingExists(_)) + )); + } + + #[tokio::test] + async fn claim_consumes_offer_and_hashes_agent_proposed_token() { + let fixture = authority_with_unclaimed_server().await; + let offer = fixture.issue().await; + + let receipt = fixture.claim(offer.code.clone(), FIRST_TOKEN).await; + + assert_eq!(receipt.server_id, fixture.server_id); + let stored = server::Entity::find_by_id(fixture.server_id.as_str()) + .one(&fixture.db) + .await + .expect("read server") + .expect("server"); + assert_eq!(stored.token_prefix.as_deref(), Some(&FIRST_TOKEN[..8])); + assert_ne!(stored.token_hash.as_deref(), Some(FIRST_TOKEN)); + assert!( + AuthService::verify_password(FIRST_TOKEN, stored.token_hash.as_deref().expect("hash")) + .expect("verify token") + ); + let stored_offer = enrollment_offer::Entity::find_by_id(offer.id.as_str()) + .one(&fixture.db) + .await + .expect("read offer") + .expect("offer"); + assert_eq!(stored_offer.outcome.as_deref(), Some("consumed")); + assert!(stored_offer.terminal_at.is_some()); + + let history = fixture + .authority + .history(HistoryQuery { + server_id: fixture.server_id.clone(), + limit: 10, + }) + .await + .expect("history"); + assert_eq!(history.len(), 2); + assert_eq!(history[0].transition, AuthorityTransition::OfferConsumed); + assert_eq!(history[0].request_source, "agent:register"); + assert_eq!(history[0].authority_before, AuthorityStatus::Unclaimed); + assert_eq!(history[0].authority_after, AuthorityStatus::Claimed); + } + + #[tokio::test] + async fn invalid_claim_does_not_change_authority_offer_or_history() { + let fixture = authority_with_unclaimed_server().await; + let offer = fixture.issue().await; + let before = fixture + .authority + .history(HistoryQuery { + server_id: fixture.server_id.clone(), + limit: 10, + }) + .await + .expect("history"); + + let result = fixture + .authority + .claim(ClaimAgent { + code: EnrollmentCode::parse("wrong-code-0123456789").expect("wrong code"), + proposed_run_token: ProposedRunToken::parse(FIRST_TOKEN).expect("token"), + source: agent_source(), + remote_addr: None, + }) + .await; + + assert!(matches!(result, Err(ClaimError::Rejected))); + let state = fixture + .authority + .state(fixture.server_id.clone()) + .await + .expect("state"); + assert_eq!(state.authority, AuthorityStatus::Unclaimed); + assert_eq!( + state.outstanding_offer.map(|current| current.id), + Some(offer.id) + ); + let after = fixture + .authority + .history(HistoryQuery { + server_id: fixture.server_id.clone(), + limit: 10, + }) + .await + .expect("history"); + assert_eq!(after, before); + } + + #[tokio::test] + async fn graceful_reenrollment_preserves_authority_until_claim_then_fences_old_connection() { + let fixture = authority_with_unclaimed_server().await; + fixture.claim_initial(FIRST_TOKEN).await; + fixture.add_connection(); + + let offer = fixture + .authority + .begin_reenrollment(BeginReenrollment { + server_id: fixture.server_id.clone(), + mode: ReenrollmentMode::Graceful, + actor: user_actor(), + source: api_source(), + ttl: OfferTtl::default(), + }) + .await + .expect("begin graceful re-enrollment"); + + let pending = fixture + .authority + .state(fixture.server_id.clone()) + .await + .expect("state"); + assert_eq!(pending.authority, AuthorityStatus::Claimed); + assert!(fixture.agent_manager.is_online(fixture.server_id.as_str())); + assert!( + AuthService::validate_agent_token(&fixture.db, FIRST_TOKEN) + .await + .expect("validate old token") + .is_some() + ); + + fixture.claim(offer.code, SECOND_TOKEN).await; + + assert!(!fixture.agent_manager.is_online(fixture.server_id.as_str())); + assert!( + AuthService::validate_agent_token(&fixture.db, FIRST_TOKEN) + .await + .expect("validate old token") + .is_none() + ); + assert!( + AuthService::validate_agent_token(&fixture.db, SECOND_TOKEN) + .await + .expect("validate new token") + .is_some() + ); + } + + #[tokio::test] + async fn emergency_reenrollment_revokes_authority_and_fences_immediately() { + let fixture = authority_with_unclaimed_server().await; + fixture.claim_initial(FIRST_TOKEN).await; + fixture.add_connection(); + + let issued = fixture + .authority + .begin_reenrollment(BeginReenrollment { + server_id: fixture.server_id.clone(), + mode: ReenrollmentMode::Emergency, + actor: user_actor(), + source: api_source(), + ttl: OfferTtl::default(), + }) + .await + .expect("begin emergency re-enrollment"); + + let state = fixture + .authority + .state(fixture.server_id.clone()) + .await + .expect("state"); + assert_eq!(state.authority, AuthorityStatus::Unclaimed); + assert_eq!( + state.outstanding_offer.map(|offer| offer.id), + Some(issued.id) + ); + assert!(!fixture.agent_manager.is_online(fixture.server_id.as_str())); + assert!( + AuthService::validate_agent_token(&fixture.db, FIRST_TOKEN) + .await + .expect("validate old token") + .is_none() + ); + } + + #[tokio::test] + async fn exact_replacement_links_successor_and_terminal_outcomes_are_immutable() { + let fixture = authority_with_unclaimed_server().await; + let original = fixture.issue().await; + + let replacement = fixture + .authority + .replace_offer(ReplaceOffer { + server_id: fixture.server_id.clone(), + offer_id: original.id.clone(), + actor: user_actor(), + source: api_source(), + ttl: OfferTtl::default(), + }) + .await + .expect("replace offer"); + + let old_row = enrollment_offer::Entity::find_by_id(original.id.as_str()) + .one(&fixture.db) + .await + .expect("read old offer") + .expect("old offer"); + assert_eq!(old_row.outcome.as_deref(), Some("replaced")); + assert_eq!( + old_row.successor_offer_id.as_deref(), + Some(replacement.id.as_str()) + ); + + let stale = fixture + .authority + .replace_offer(ReplaceOffer { + server_id: fixture.server_id.clone(), + offer_id: original.id, + actor: user_actor(), + source: api_source(), + ttl: OfferTtl::default(), + }) + .await; + assert!(matches!( + stale, + Err(ReplaceOfferError::NotOutstanding { + outcome: OfferOutcome::Replaced, + current: Some(_) + }) + )); + + let first_revoke = fixture + .authority + .revoke_offer(RevokeOffer { + server_id: fixture.server_id.clone(), + offer_id: replacement.id.clone(), + actor: user_actor(), + source: api_source(), + }) + .await + .expect("revoke replacement"); + assert!(!first_revoke.already_revoked); + let second_revoke = fixture + .authority + .revoke_offer(RevokeOffer { + server_id: fixture.server_id, + offer_id: replacement.id, + actor: user_actor(), + source: api_source(), + }) + .await + .expect("repeat revoke"); + assert!(second_revoke.already_revoked); + } + + #[tokio::test] + async fn elapsed_offer_is_materialized_as_expired_before_successor_is_issued() { + let fixture = authority_with_unclaimed_server().await; + let expired = fixture.issue().await; + let mut row: enrollment_offer::ActiveModel = + enrollment_offer::Entity::find_by_id(expired.id.as_str()) + .one(&fixture.db) + .await + .expect("read offer") + .expect("offer") + .into(); + row.expires_at = Set(Utc::now() - Duration::seconds(1)); + row.update(&fixture.db).await.expect("expire offer"); + + let successor = fixture + .authority + .issue_offer_for_unclaimed(IssueOfferForUnclaimed { + server_id: fixture.server_id.clone(), + actor: user_actor(), + source: api_source(), + ttl: OfferTtl::default(), + }) + .await + .expect("issue successor"); + + let expired_row = enrollment_offer::Entity::find_by_id(expired.id.as_str()) + .one(&fixture.db) + .await + .expect("read expired offer") + .expect("expired offer"); + assert_eq!(expired_row.outcome.as_deref(), Some("expired")); + assert_eq!( + fixture + .authority + .state(fixture.server_id.clone()) + .await + .expect("state") + .outstanding_offer + .map(|offer| offer.id), + Some(successor.id) + ); + } + + #[tokio::test] + async fn authority_revocation_creates_no_offer_and_is_idempotent() { + let fixture = authority_with_unclaimed_server().await; + fixture.claim_initial(FIRST_TOKEN).await; + fixture.add_connection(); + + let first = fixture + .authority + .revoke_authority(RevokeAuthority { + server_id: fixture.server_id.clone(), + actor: user_actor(), + source: api_source(), + }) + .await + .expect("revoke authority"); + let second = fixture + .authority + .revoke_authority(RevokeAuthority { + server_id: fixture.server_id.clone(), + actor: user_actor(), + source: api_source(), + }) + .await + .expect("repeat revoke"); + + assert!(first.changed); + assert!(!second.changed); + let state = fixture + .authority + .state(fixture.server_id.clone()) + .await + .expect("state"); + assert_eq!(state.authority, AuthorityStatus::Unclaimed); + assert!(state.outstanding_offer.is_none()); + assert!(!fixture.agent_manager.is_online(fixture.server_id.as_str())); + } + + #[tokio::test] + async fn authority_revocation_terminalizes_graceful_reenrollment_offer() { + let fixture = authority_with_unclaimed_server().await; + fixture.claim_initial(FIRST_TOKEN).await; + let offer = fixture + .authority + .begin_reenrollment(BeginReenrollment { + server_id: fixture.server_id.clone(), + actor: user_actor(), + source: api_source(), + mode: ReenrollmentMode::Graceful, + ttl: OfferTtl::default(), + }) + .await + .expect("begin graceful re-enrollment"); + let code = offer.code.clone(); + fixture.add_connection(); + + fixture + .authority + .revoke_authority(RevokeAuthority { + server_id: fixture.server_id.clone(), + actor: user_actor(), + source: api_source(), + }) + .await + .expect("revoke authority"); + + let state = fixture + .authority + .state(fixture.server_id.clone()) + .await + .expect("state"); + assert_eq!(state.authority, AuthorityStatus::Unclaimed); + assert!(state.outstanding_offer.is_none()); + let row = enrollment_offer::Entity::find_by_id(offer.id.as_str()) + .one(&fixture.db) + .await + .expect("read offer") + .expect("offer"); + assert_eq!(row.outcome.as_deref(), Some("revoked")); + assert!(matches!( + fixture + .authority + .claim(ClaimAgent { + code, + proposed_run_token: ProposedRunToken::parse(SECOND_TOKEN).expect("run token"), + source: agent_source(), + remote_addr: Some("127.0.0.1".to_string()), + }) + .await, + Err(ClaimError::Rejected) + )); + assert!(!fixture.agent_manager.is_online(fixture.server_id.as_str())); + } + + #[tokio::test] + async fn failed_event_write_rolls_back_credential_transition() { + let fixture = authority_with_unclaimed_server().await; + fixture.claim_initial(FIRST_TOKEN).await; + fixture.add_connection(); + fixture + .db + .execute_unprepared( + "CREATE TRIGGER reject_authority_events BEFORE INSERT ON agent_authority_events \ + BEGIN SELECT RAISE(ABORT, 'forced authority event failure'); END", + ) + .await + .expect("create failure trigger"); + + let result = fixture + .authority + .revoke_authority(RevokeAuthority { + server_id: fixture.server_id.clone(), + actor: user_actor(), + source: api_source(), + }) + .await; + + assert!(matches!(result, Err(RevokeAuthorityError::Store(_)))); + assert!(!fixture.agent_manager.is_online(fixture.server_id.as_str())); + assert!( + AuthService::validate_agent_token(&fixture.db, FIRST_TOKEN) + .await + .expect("validate token") + .is_some() + ); + let pending = fixture + .authority + .preflight_connection(PresentedRunToken::parse(FIRST_TOKEN).expect("token")) + .await + .expect("old authority remains durable"); + let (tx, _rx) = mpsc::channel(1); + pending + .admit(NewConnection { + tx, + remote_addr: loopback(), + }) + .await + .expect("old authority reconnects after failed transition"); + assert!(fixture.agent_manager.is_online(fixture.server_id.as_str())); + } + + #[tokio::test] + async fn preflight_does_not_survive_authority_revocation() { + let fixture = authority_with_unclaimed_server().await; + fixture.claim_initial(FIRST_TOKEN).await; + let pending = fixture + .authority + .preflight_connection(PresentedRunToken::parse(FIRST_TOKEN).expect("token")) + .await + .expect("preflight"); + fixture + .authority + .revoke_authority(RevokeAuthority { + server_id: fixture.server_id.clone(), + actor: user_actor(), + source: api_source(), + }) + .await + .expect("revoke authority"); + let (tx, _rx) = mpsc::channel(1); + + let result = pending + .admit(NewConnection { + tx, + remote_addr: loopback(), + }) + .await; + + assert!(matches!(result, Err(AdmissionError::Rejected))); + assert!(!fixture.agent_manager.is_online(fixture.server_id.as_str())); + } + + #[tokio::test] + async fn concurrent_claims_and_replacements_each_have_exactly_one_winner() { + let fixture = authority_with_unclaimed_server().await; + let original = fixture.issue().await; + let replace_input = ReplaceOffer { + server_id: fixture.server_id.clone(), + offer_id: original.id, + actor: user_actor(), + source: api_source(), + ttl: OfferTtl::default(), + }; + + let (first_replace, second_replace) = tokio::join!( + fixture.authority.replace_offer(replace_input.clone()), + fixture.authority.replace_offer(replace_input) + ); + let replacements = [first_replace, second_replace]; + assert_eq!( + replacements.iter().filter(|result| result.is_ok()).count(), + 1 + ); + let replacement = replacements + .into_iter() + .find_map(Result::ok) + .expect("replacement winner"); + + let first_claim = ClaimAgent { + code: replacement.code.clone(), + proposed_run_token: ProposedRunToken::parse(FIRST_TOKEN).expect("token"), + source: agent_source(), + remote_addr: None, + }; + let second_claim = ClaimAgent { + code: replacement.code, + proposed_run_token: ProposedRunToken::parse(SECOND_TOKEN).expect("token"), + source: agent_source(), + remote_addr: None, + }; + let (first_claim, second_claim) = tokio::join!( + fixture.authority.claim(first_claim), + fixture.authority.claim(second_claim) + ); + assert_eq!( + [first_claim, second_claim] + .iter() + .filter(|result| result.is_ok()) + .count(), + 1 + ); + } + + #[test] + fn authority_secrets_are_redacted_from_debug() { + let enrollment = EnrollmentCode::parse("0123456789abcdef").expect("code"); + let proposed = ProposedRunToken::parse("x".repeat(32)).expect("proposed token"); + let presented = PresentedRunToken::parse("abcdefgh-token").expect("presented token"); + + assert_eq!(format!("{enrollment:?}"), "EnrollmentCode(<redacted>)"); + assert_eq!(format!("{proposed:?}"), "ProposedRunToken(<redacted>)"); + assert_eq!(format!("{presented:?}"), "PresentedRunToken(<redacted>)"); + } + + #[test] + fn authority_secrets_reject_non_ascii_prefixes() { + assert!(EnrollmentCode::parse("密钥密钥密钥密钥密钥密钥").is_err()); + assert!(ProposedRunToken::parse("密".repeat(32)).is_err()); + assert!(PresentedRunToken::parse("密钥密钥密钥").is_err()); + } + + #[tokio::test] + async fn history_query_survives_server_deletion() { + let fixture = authority_with_unclaimed_server().await; + fixture.issue().await; + let server = server::Entity::find_by_id(fixture.server_id.as_str()) + .one(&fixture.db) + .await + .expect("read server") + .expect("server"); + let tx = fixture.db.begin().await.expect("transaction"); + fixture + .authority + .record_server_deleted_tx(&tx, &server, &user_actor(), &api_source()) + .await + .expect("record deletion"); + server::Entity::delete_by_id(fixture.server_id.as_str()) + .exec(&tx) + .await + .expect("delete server"); + tx.commit().await.expect("commit deletion"); + + let offers = enrollment_offer::Entity::find() + .filter(enrollment_offer::Column::TargetServerId.eq(fixture.server_id.as_str())) + .count(&fixture.db) + .await + .expect("count offers"); + let events = agent_authority_event::Entity::find() + .filter(agent_authority_event::Column::ServerId.eq(fixture.server_id.as_str())) + .count(&fixture.db) + .await + .expect("count events"); + assert_eq!(offers, 0); + assert_eq!(events, 2); + } +} diff --git a/crates/server/src/service/agent_authority/model.rs b/crates/server/src/service/agent_authority/model.rs new file mode 100644 index 00000000..90e062f7 --- /dev/null +++ b/crates/server/src/service/agent_authority/model.rs @@ -0,0 +1,549 @@ +use std::fmt; +use std::net::SocketAddr; + +use chrono::{DateTime, Utc}; +use serverbee_common::protocol::ServerMessage; +use tokio::sync::mpsc; + +use crate::error::AppError; + +macro_rules! identifier { + ($name:ident, $label:literal) => { + #[derive(Clone, Debug, Eq, Hash, PartialEq)] + pub struct $name(String); + + impl $name { + pub fn parse(value: impl Into<String>) -> Result<Self, String> { + let value = value.into(); + if value.trim().is_empty() { + return Err(concat!($label, " must not be empty").to_string()); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } + } + }; +} + +identifier!(ServerId, "server id"); +identifier!(OfferId, "offer id"); + +#[derive(Clone, Eq, PartialEq)] +pub struct EnrollmentCode(String); + +impl EnrollmentCode { + pub fn parse(value: impl Into<String>) -> Result<Self, String> { + let value = value.into(); + if value.len() < 16 || value.len() > 512 || !value.is_ascii() { + return Err("enrollment code must be 16-512 ASCII characters".to_string()); + } + Ok(Self(value)) + } + + pub(crate) fn expose(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for EnrollmentCode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("EnrollmentCode(<redacted>)") + } +} + +#[derive(Clone, Eq, PartialEq)] +pub struct ProposedRunToken(String); + +impl ProposedRunToken { + pub fn parse(value: impl Into<String>) -> Result<Self, String> { + let value = value.into(); + if value.len() < 32 + || value.len() > 512 + || !value.is_ascii() + || value.chars().any(char::is_whitespace) + { + return Err( + "proposed run token must be 32-512 non-whitespace ASCII characters".to_string(), + ); + } + Ok(Self(value)) + } + + pub(crate) fn expose(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for ProposedRunToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("ProposedRunToken(<redacted>)") + } +} + +#[derive(Clone, Eq, PartialEq)] +pub struct PresentedRunToken(String); + +impl PresentedRunToken { + pub fn parse(value: impl Into<String>) -> Result<Self, String> { + let value = value.into(); + if value.len() < 8 || value.len() > 512 || !value.is_ascii() { + return Err("presented run token must be 8-512 ASCII characters".to_string()); + } + Ok(Self(value)) + } + + pub(crate) fn expose(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for PresentedRunToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("PresentedRunToken(<redacted>)") + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Actor { + User { id: String }, + System, + Agent, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ActorKind { + User, + System, + Agent, +} + +impl ActorKind { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::User => "user", + Self::System => "system", + Self::Agent => "agent", + } + } + + pub(crate) fn parse(value: &str) -> Option<Self> { + match value { + "user" => Some(Self::User), + "system" => Some(Self::System), + "agent" => Some(Self::Agent), + _ => None, + } + } +} + +impl Actor { + pub(crate) fn kind(&self) -> ActorKind { + match self { + Self::User { .. } => ActorKind::User, + Self::System => ActorKind::System, + Self::Agent => ActorKind::Agent, + } + } + + pub(crate) fn id(&self) -> Option<&str> { + match self { + Self::User { id } => Some(id), + Self::System | Self::Agent => None, + } + } + + pub(crate) fn offer_creator(&self) -> String { + self.id() + .unwrap_or_else(|| self.kind().as_str()) + .to_string() + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RequestSource(String); + +impl RequestSource { + pub fn parse(value: impl Into<String>) -> Result<Self, String> { + let value = value.into(); + if value.trim().is_empty() || value.len() > 256 { + return Err("request source must be 1-256 characters".to_string()); + } + Ok(Self(value)) + } + + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct OfferTtl(i64); + +impl OfferTtl { + pub const DEFAULT_SECONDS: i64 = 600; + + pub fn seconds(value: i64) -> Result<Self, String> { + if !(1..=86_400).contains(&value) { + return Err("offer ttl must be between 1 and 86400 seconds".to_string()); + } + Ok(Self(value)) + } + + pub fn default_ttl() -> Self { + Self(Self::DEFAULT_SECONDS) + } + + pub(crate) fn value(self) -> i64 { + self.0 + } +} + +impl Default for OfferTtl { + fn default() -> Self { + Self::default_ttl() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AuthorityStatus { + Claimed, + Unclaimed, +} + +impl AuthorityStatus { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Claimed => "claimed", + Self::Unclaimed => "unclaimed", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OfferOutcome { + Consumed, + Revoked, + Replaced, + Expired, +} + +impl OfferOutcome { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Consumed => "consumed", + Self::Revoked => "revoked", + Self::Replaced => "replaced", + Self::Expired => "expired", + } + } + + pub(crate) fn parse(value: &str) -> Option<Self> { + match value { + "consumed" => Some(Self::Consumed), + "revoked" => Some(Self::Revoked), + "replaced" => Some(Self::Replaced), + "expired" => Some(Self::Expired), + _ => None, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ReenrollmentMode { + Graceful, + Emergency, +} + +impl ReenrollmentMode { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Graceful => "graceful", + Self::Emergency => "emergency", + } + } + + pub(crate) fn parse(value: &str) -> Option<Self> { + match value { + "graceful" => Some(Self::Graceful), + "emergency" => Some(Self::Emergency), + _ => None, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AuthorityTransition { + InitialOfferIssued, + OfferIssued, + ReenrollmentStarted, + OfferConsumed, + OfferRevoked, + OfferReplaced, + OfferExpired, + AuthorityRevoked, + ServerDeleted, +} + +impl AuthorityTransition { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::InitialOfferIssued => "initial_offer_issued", + Self::OfferIssued => "offer_issued", + Self::ReenrollmentStarted => "reenrollment_started", + Self::OfferConsumed => "offer_consumed", + Self::OfferRevoked => "offer_revoked", + Self::OfferReplaced => "offer_replaced", + Self::OfferExpired => "offer_expired", + Self::AuthorityRevoked => "authority_revoked", + Self::ServerDeleted => "server_deleted", + } + } + + pub(crate) fn parse(value: &str) -> Option<Self> { + match value { + "initial_offer_issued" => Some(Self::InitialOfferIssued), + "offer_issued" => Some(Self::OfferIssued), + "reenrollment_started" => Some(Self::ReenrollmentStarted), + "offer_consumed" => Some(Self::OfferConsumed), + "offer_revoked" => Some(Self::OfferRevoked), + "offer_replaced" => Some(Self::OfferReplaced), + "offer_expired" => Some(Self::OfferExpired), + "authority_revoked" => Some(Self::AuthorityRevoked), + "server_deleted" => Some(Self::ServerDeleted), + _ => None, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OutstandingOffer { + pub id: OfferId, + pub code_prefix: String, + pub expires_at: DateTime<Utc>, + pub created_at: DateTime<Utc>, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct IssuedOffer { + pub id: OfferId, + pub code: EnrollmentCode, + pub code_prefix: String, + pub expires_at: DateTime<Utc>, + pub created_at: DateTime<Utc>, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AuthorityState { + pub server_id: ServerId, + pub authority: AuthorityStatus, + pub outstanding_offer: Option<OutstandingOffer>, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClaimAgent { + pub code: EnrollmentCode, + pub proposed_run_token: ProposedRunToken, + pub source: RequestSource, + pub remote_addr: Option<String>, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClaimReceipt { + pub server_id: ServerId, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct IssueOfferForUnclaimed { + pub server_id: ServerId, + pub actor: Actor, + pub source: RequestSource, + pub ttl: OfferTtl, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BeginReenrollment { + pub server_id: ServerId, + pub mode: ReenrollmentMode, + pub actor: Actor, + pub source: RequestSource, + pub ttl: OfferTtl, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReplaceOffer { + pub server_id: ServerId, + pub offer_id: OfferId, + pub actor: Actor, + pub source: RequestSource, + pub ttl: OfferTtl, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RevokeOffer { + pub server_id: ServerId, + pub offer_id: OfferId, + pub actor: Actor, + pub source: RequestSource, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RevokedOffer { + pub offer_id: OfferId, + pub already_revoked: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RevokeAuthority { + pub server_id: ServerId, + pub actor: Actor, + pub source: RequestSource, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RevocationReceipt { + pub server_id: ServerId, + pub changed: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HistoryQuery { + pub server_id: ServerId, + pub limit: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AuthorityEvent { + pub id: String, + pub server_id: ServerId, + pub server_name: String, + pub actor_kind: ActorKind, + pub actor_id: Option<String>, + pub request_source: String, + pub offer_id: Option<OfferId>, + pub transition: AuthorityTransition, + pub mode: Option<ReenrollmentMode>, + pub offer_outcome: Option<OfferOutcome>, + pub authority_before: AuthorityStatus, + pub authority_after: AuthorityStatus, + pub created_at: DateTime<Utc>, +} + +pub struct NewConnection { + pub tx: mpsc::Sender<ServerMessage>, + pub remote_addr: SocketAddr, +} + +impl fmt::Debug for NewConnection { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("NewConnection") + .field("tx", &"<channel>") + .field("remote_addr", &self.remote_addr) + .finish() + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AdmittedConnection { + pub server_id: ServerId, + pub server_name: String, + pub server_capabilities: i32, + pub connection_id: u64, +} + +#[derive(Debug, thiserror::Error)] +pub enum ClaimError { + #[error("enrollment claim rejected")] + Rejected, + #[error("agent authority store failed: {0}")] + Store(#[from] AppError), +} + +#[derive(Debug, thiserror::Error)] +pub enum IssueOfferError { + #[error("server not found")] + NotFound, + #[error("server is already claimed")] + AlreadyClaimed, + #[error("an Outstanding enrollment offer already exists")] + OutstandingExists(OutstandingOffer), + #[error("agent authority store failed: {0}")] + Store(#[from] AppError), +} + +#[derive(Debug, thiserror::Error)] +pub enum ReenrollmentError { + #[error("server not found")] + NotFound, + #[error("server is Unclaimed")] + Unclaimed, + #[error("an Outstanding enrollment offer already exists")] + OutstandingExists(OutstandingOffer), + #[error("agent authority store failed: {0}")] + Store(#[from] AppError), +} + +#[derive(Debug, thiserror::Error)] +pub enum ReplaceOfferError { + #[error("server not found")] + ServerNotFound, + #[error("enrollment offer not found")] + OfferNotFound, + #[error("enrollment offer is no longer Outstanding: {outcome:?}")] + NotOutstanding { + outcome: OfferOutcome, + current: Option<OutstandingOffer>, + }, + #[error("the exact offer is not the current Outstanding offer")] + Stale { current: Option<OutstandingOffer> }, + #[error("agent authority store failed: {0}")] + Store(#[from] AppError), +} + +#[derive(Debug, thiserror::Error)] +pub enum RevokeOfferError { + #[error("server not found")] + ServerNotFound, + #[error("enrollment offer not found")] + OfferNotFound, + #[error("enrollment offer is terminal and cannot be revoked: {0:?}")] + Terminal(OfferOutcome), + #[error("agent authority store failed: {0}")] + Store(#[from] AppError), +} + +#[derive(Debug, thiserror::Error)] +pub enum RevokeAuthorityError { + #[error("server not found")] + NotFound, + #[error("agent authority store failed: {0}")] + Store(#[from] AppError), +} + +#[derive(Debug, thiserror::Error)] +pub enum StateError { + #[error("server not found")] + NotFound, + #[error("agent authority store failed: {0}")] + Store(#[from] AppError), +} + +#[derive(Debug, thiserror::Error)] +pub enum HistoryError { + #[error("agent authority store failed: {0}")] + Store(#[from] AppError), +} + +#[derive(Debug, thiserror::Error)] +pub enum AdmissionError { + #[error("agent run token rejected")] + Rejected, + #[error("agent authority store failed: {0}")] + Store(#[from] AppError), +} diff --git a/crates/server/src/service/agent_manager.rs b/crates/server/src/service/agent_manager.rs index 26597440..5b964c9e 100644 --- a/crates/server/src/service/agent_manager.rs +++ b/crates/server/src/service/agent_manager.rs @@ -230,6 +230,17 @@ impl AgentManager { } } + pub fn broadcast_agent_authority_changed( + &self, + server_id: String, + agent_authority: serverbee_common::types::AgentAuthorityStateSummary, + ) { + let _ = self.browser_tx.send(BrowserMessage::AgentAuthorityChanged { + server_id, + agent_authority, + }); + } + pub fn is_current_connection(&self, server_id: &str, expected_connection_id: u64) -> bool { self.connections .get(server_id) @@ -294,7 +305,7 @@ impl AgentManager { self.connections.contains_key(server_id) } - pub fn server_cleanup_lock(&self, server_id: &str) -> Arc<Mutex<()>> { + pub fn server_lifecycle_lock(&self, server_id: &str) -> Arc<Mutex<()>> { self.server_lifecycle_locks .entry(server_id.to_string()) .or_insert_with(|| Arc::new(Mutex::new(()))) diff --git a/crates/server/src/service/auth.rs b/crates/server/src/service/auth.rs index 845a7b4d..19cc611f 100644 --- a/crates/server/src/service/auth.rs +++ b/crates/server/src/service/auth.rs @@ -1244,7 +1244,6 @@ mod tests { protocol_version: Set(1), features: Set("[]".into()), last_remote_addr: Set(None), - fingerprint: Set(None), created_at: Set(now), updated_at: Set(now), } @@ -1306,7 +1305,6 @@ mod tests { protocol_version: Set(1), features: Set("[]".into()), last_remote_addr: Set(None), - fingerprint: Set(None), created_at: Set(now), updated_at: Set(now), } @@ -1781,7 +1779,6 @@ mod tests { protocol_version: Set(1), features: Set("[]".into()), last_remote_addr: Set(None), - fingerprint: Set(None), created_at: Set(now), updated_at: Set(now), } diff --git a/crates/server/src/service/enrollment.rs b/crates/server/src/service/enrollment.rs deleted file mode 100644 index 0369c1c9..00000000 --- a/crates/server/src/service/enrollment.rs +++ /dev/null @@ -1,354 +0,0 @@ -use chrono::{Duration, Utc}; -use sea_orm::{ - ActiveModelTrait, ActiveValue::Set, ColumnTrait, ConnectionTrait, DatabaseConnection, - EntityTrait, QueryFilter, QueryOrder, -}; -use uuid::Uuid; - -use crate::entity::agent_enrollment; -use crate::error::AppError; -use crate::service::auth::AuthService; - -pub const DEFAULT_TTL_SECS: i64 = 600; - -pub struct EnrollmentService; - -impl EnrollmentService { - /// Mint an enrollment bound to a specific server. May run inside a tx so - /// that callers (T7 server-create, T9 recover, T10 regenerate) can make - /// the mint atomic with surrounding state changes. - /// - /// Returns the stored row and the plaintext code (shown to the operator - /// exactly once). The DB enforces at most one outstanding (not consumed, - /// not revoked) enrollment per server via partial unique index - /// `idx_enrollments_active_per_server`; a second concurrent mint without - /// first revoking the outstanding one will surface as a DB error. - pub async fn mint_for_server<C: ConnectionTrait>( - conn: &C, - target_server_id: &str, - created_by: &str, - ttl_secs: i64, - ) -> Result<(agent_enrollment::Model, String), AppError> { - let now = Utc::now(); - let plaintext = AuthService::generate_session_token(); - let hash = AuthService::hash_password(&plaintext)?; - let prefix = plaintext[..8.min(plaintext.len())].to_string(); - let id = Uuid::new_v4().to_string(); - - let model = agent_enrollment::ActiveModel { - id: Set(id), - code_hash: Set(hash), - code_prefix: Set(prefix), - target_server_id: Set(target_server_id.to_string()), - created_by: Set(created_by.to_string()), - expires_at: Set(now + Duration::seconds(ttl_secs)), - consumed_at: Set(None), - revoked_at: Set(None), - created_at: Set(now), - } - .insert(conn) - .await?; - - Ok((model, plaintext)) - } - - /// Verify a bearer code and consume it atomically. - /// - /// Accepts rows where `consumed_at IS NULL AND revoked_at IS NULL AND - /// expires_at > now()`. On match, sets `consumed_at = now()` in the same - /// connection (which the caller is expected to run inside a tx so the - /// consume is committed atomically with the surrounding registration - /// flow). On no match — wrong code, expired, revoked, or already consumed - /// — returns `Ok(None)`. - pub async fn verify_and_consume_tx<C: ConnectionTrait>( - tx: &C, - code: &str, - ) -> Result<Option<agent_enrollment::Model>, AppError> { - if code.len() < 8 { - return Ok(None); - } - let prefix = &code[..8]; - let candidates = agent_enrollment::Entity::find() - .filter(agent_enrollment::Column::CodePrefix.eq(prefix)) - .filter(agent_enrollment::Column::ConsumedAt.is_null()) - .filter(agent_enrollment::Column::RevokedAt.is_null()) - .all(tx) - .await?; - - let now = Utc::now(); - for cand in candidates { - if cand.expires_at <= now { - continue; - } - if AuthService::verify_password(code, &cand.code_hash)? { - let mut active: agent_enrollment::ActiveModel = cand.clone().into(); - active.consumed_at = Set(Some(now)); - let updated = active.update(tx).await?; - return Ok(Some(updated)); - } - } - Ok(None) - } - - /// List all enrollments (admin UI). Oldest first; callers sort if needed. - pub async fn list( - db: &DatabaseConnection, - ) -> Result<Vec<agent_enrollment::Model>, AppError> { - Ok(agent_enrollment::Entity::find() - .order_by_asc(agent_enrollment::Column::CreatedAt) - .all(db) - .await?) - } - - /// Mark an enrollment revoked. Idempotent: a missing row or an already- - /// revoked row both succeed without error. - pub async fn revoke(db: &DatabaseConnection, id: &str) -> Result<(), AppError> { - let row = agent_enrollment::Entity::find_by_id(id).one(db).await?; - let Some(row) = row else { - return Ok(()); - }; - if row.revoked_at.is_some() { - return Ok(()); - } - let mut active: agent_enrollment::ActiveModel = row.into(); - active.revoked_at = Set(Some(Utc::now())); - active.update(db).await?; - Ok(()) - } - - /// Revoke any outstanding enrollment for a server. Used by recover / - /// regenerate flows so a fresh mint won't trip the partial unique index. - /// Returns the id of the revoked row, if any. - pub async fn revoke_outstanding_tx<C: ConnectionTrait>( - tx: &C, - server_id: &str, - ) -> Result<Option<String>, AppError> { - let outstanding = agent_enrollment::Entity::find() - .filter(agent_enrollment::Column::TargetServerId.eq(server_id)) - .filter(agent_enrollment::Column::ConsumedAt.is_null()) - .filter(agent_enrollment::Column::RevokedAt.is_null()) - .one(tx) - .await?; - let Some(row) = outstanding else { - return Ok(None); - }; - let id = row.id.clone(); - let mut active: agent_enrollment::ActiveModel = row.into(); - active.revoked_at = Set(Some(Utc::now())); - active.update(tx).await?; - Ok(Some(id)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::entity::{server, user}; - use crate::test_utils::setup_test_db; - use sea_orm::TransactionTrait; - use serverbee_common::constants::CAP_DEFAULT; - - async fn seed_user(db: &DatabaseConnection) -> String { - let id = Uuid::new_v4().to_string(); - let now = Utc::now(); - user::ActiveModel { - id: Set(id.clone()), - username: Set(format!("user-{id}")), - password_hash: Set("$argon2id$v=19$m=19456,t=2,p=1$x$x".to_string()), - role: Set("admin".to_string()), - totp_secret: Set(None), - must_change_password: Set(false), - password_changed_at: Set(None), - created_at: Set(now), - updated_at: Set(now), - } - .insert(db) - .await - .expect("seed user"); - id - } - - async fn seed_pending_server(db: &DatabaseConnection) -> String { - let id = Uuid::new_v4().to_string(); - let now = Utc::now(); - server::ActiveModel { - id: Set(id.clone()), - token_hash: Set(None), - token_prefix: Set(None), - name: Set("t".to_string()), - cpu_name: Set(None), - cpu_cores: Set(None), - cpu_arch: Set(None), - os: Set(None), - kernel_version: Set(None), - mem_total: Set(None), - swap_total: Set(None), - disk_total: Set(None), - ipv4: Set(None), - ipv6: Set(None), - region: Set(None), - country_code: Set(None), - geo_manual: Set(false), - virtualization: Set(None), - agent_version: Set(None), - group_id: Set(None), - weight: Set(0), - hidden: Set(false), - remark: Set(None), - public_remark: Set(None), - price: Set(None), - billing_cycle: Set(None), - currency: Set(None), - expired_at: Set(None), - traffic_limit: Set(None), - traffic_limit_type: Set(None), - billing_start_day: Set(None), - capabilities: Set(CAP_DEFAULT as i32), - protocol_version: Set(1), - features: Set("[]".to_string()), - last_remote_addr: Set(None), - fingerprint: Set(None), - created_at: Set(now), - updated_at: Set(now), - } - .insert(db) - .await - .expect("seed pending server"); - id - } - - #[tokio::test] - async fn mint_for_server_returns_plaintext_once() { - let (db, _t) = setup_test_db().await; - let u = seed_user(&db).await; - let s = seed_pending_server(&db).await; - let (model, code) = EnrollmentService::mint_for_server(&db, &s, &u, 600) - .await - .expect("mint"); - assert_eq!(model.target_server_id, s); - assert_eq!(model.code_prefix, code[..8]); - assert!(model.consumed_at.is_none()); - assert!(model.revoked_at.is_none()); - } - - #[tokio::test] - async fn verify_and_consume_accepts_usable_code() { - let (db, _t) = setup_test_db().await; - let u = seed_user(&db).await; - let s = seed_pending_server(&db).await; - let (_m, code) = EnrollmentService::mint_for_server(&db, &s, &u, 600) - .await - .expect("mint"); - - let row = db - .transaction::<_, _, AppError>(|tx| { - Box::pin(async move { EnrollmentService::verify_and_consume_tx(tx, &code).await }) - }) - .await - .expect("tx ok"); - assert!(row.is_some()); - assert!(row.unwrap().consumed_at.is_some()); - } - - #[tokio::test] - async fn verify_and_consume_rejects_revoked_code() { - let (db, _t) = setup_test_db().await; - let u = seed_user(&db).await; - let s = seed_pending_server(&db).await; - let (m, code) = EnrollmentService::mint_for_server(&db, &s, &u, 600) - .await - .expect("mint"); - EnrollmentService::revoke(&db, &m.id).await.expect("revoke"); - - let row = db - .transaction::<_, _, AppError>(|tx| { - Box::pin(async move { EnrollmentService::verify_and_consume_tx(tx, &code).await }) - }) - .await - .expect("tx ok"); - assert!(row.is_none(), "revoked code must not consume"); - } - - #[tokio::test] - async fn verify_and_consume_rejects_expired_code() { - let (db, _t) = setup_test_db().await; - let u = seed_user(&db).await; - let s = seed_pending_server(&db).await; - // ttl = -1 to make it already expired - let (_m, code) = EnrollmentService::mint_for_server(&db, &s, &u, -1) - .await - .expect("mint"); - let row = db - .transaction::<_, _, AppError>(|tx| { - Box::pin(async move { EnrollmentService::verify_and_consume_tx(tx, &code).await }) - }) - .await - .expect("tx ok"); - assert!(row.is_none(), "expired code must not consume"); - } - - #[tokio::test] - async fn verify_and_consume_single_use() { - let (db, _t) = setup_test_db().await; - let u = seed_user(&db).await; - let s = seed_pending_server(&db).await; - let (_m, code) = EnrollmentService::mint_for_server(&db, &s, &u, 600) - .await - .expect("mint"); - - let c1 = code.clone(); - let first = db - .transaction::<_, _, AppError>(|tx| { - Box::pin(async move { EnrollmentService::verify_and_consume_tx(tx, &c1).await }) - }) - .await - .expect("tx ok"); - assert!(first.is_some()); - - let c2 = code.clone(); - let second = db - .transaction::<_, _, AppError>(|tx| { - Box::pin(async move { EnrollmentService::verify_and_consume_tx(tx, &c2).await }) - }) - .await - .expect("tx ok"); - assert!(second.is_none(), "second use must be rejected"); - } - - #[tokio::test] - async fn partial_index_blocks_two_outstanding() { - let (db, _t) = setup_test_db().await; - let u = seed_user(&db).await; - let s = seed_pending_server(&db).await; - EnrollmentService::mint_for_server(&db, &s, &u, 600) - .await - .expect("first mint"); - let second = EnrollmentService::mint_for_server(&db, &s, &u, 600).await; - assert!( - second.is_err(), - "second mint must violate the partial unique index" - ); - } - - #[tokio::test] - async fn revoke_outstanding_then_mint_succeeds() { - let (db, _t) = setup_test_db().await; - let u = seed_user(&db).await; - let s = seed_pending_server(&db).await; - let (_first, _code) = EnrollmentService::mint_for_server(&db, &s, &u, 600) - .await - .expect("first mint"); - - let sid = s.clone(); - let uid = u.clone(); - let (_second, _code2) = db - .transaction::<_, _, AppError>(|tx| { - Box::pin(async move { - EnrollmentService::revoke_outstanding_tx(tx, &sid).await?; - EnrollmentService::mint_for_server(tx, &sid, &uid, 600).await - }) - }) - .await - .expect("revoke + remint tx"); - } -} diff --git a/crates/server/src/service/mod.rs b/crates/server/src/service/mod.rs index 25315109..d0dd321f 100644 --- a/crates/server/src/service/mod.rs +++ b/crates/server/src/service/mod.rs @@ -1,3 +1,4 @@ +pub mod agent_authority; pub mod agent_manager; pub mod agent_reconcile; pub mod alert; @@ -13,7 +14,6 @@ pub mod dashboard; pub mod db_error; pub mod docker; pub mod docker_viewer; -pub mod enrollment; pub mod file_transfer; pub mod firewall; pub mod geoip; @@ -33,6 +33,7 @@ pub mod record; pub mod rollup; pub mod security; pub mod server; +pub mod server_onboarding; pub mod server_tag; pub mod service_monitor; pub mod status_page; diff --git a/crates/server/src/service/public_status.rs b/crates/server/src/service/public_status.rs index 10428649..19398773 100644 --- a/crates/server/src/service/public_status.rs +++ b/crates/server/src/service/public_status.rs @@ -1267,7 +1267,7 @@ mod db_tests { // Redaction: serialize the summary and prove no IP/identity leak fields. let json = serde_json::to_value(s).unwrap(); - for leaked in ["ipv4", "ipv6", "hostname", "remark", "last_remote_addr", "fingerprint"] { + for leaked in ["ipv4", "ipv6", "hostname", "remark", "last_remote_addr"] { assert!( json.get(leaked).is_none(), "public summary must not expose `{leaked}`" @@ -1374,7 +1374,7 @@ mod db_tests { // Redaction at the detail level too. let json = serde_json::to_value(&detail).unwrap(); - for leaked in ["ipv4", "ipv6", "remark", "fingerprint", "last_remote_addr"] { + for leaked in ["ipv4", "ipv6", "remark", "last_remote_addr"] { assert!(json.get(leaked).is_none(), "detail leaks `{leaked}`"); } } diff --git a/crates/server/src/service/server_onboarding.rs b/crates/server/src/service/server_onboarding.rs new file mode 100644 index 00000000..84af96ec --- /dev/null +++ b/crates/server/src/service/server_onboarding.rs @@ -0,0 +1,584 @@ +use std::sync::Arc; + +use base64::Engine; +use chrono::{DateTime, Utc}; +use dashmap::DashMap; +use sea_orm::{ + ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, + PaginatorTrait, QueryFilter, TransactionTrait, +}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use tokio::sync::Mutex; +use uuid::Uuid; + +use crate::entity::{server, server_onboarding_request, server_tag}; +use crate::error::AppError; +use crate::service::agent_authority::{ + Actor, AgentAuthority, IssuedOffer, OfferTtl, OutstandingOffer, RequestSource, ServerId, + StateError, +}; +use crate::service::network_probe::NetworkProbeService; +use crate::service::server_tag as server_tag_service; + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct OnboardingRequestId(String); + +impl OnboardingRequestId { + pub fn parse(value: impl Into<String>) -> Result<Self, String> { + let value = value.into(); + if value.trim().is_empty() || value.len() > 128 || value.chars().any(char::is_whitespace) { + return Err( + "onboarding request id must be 1-128 non-whitespace characters".to_string(), + ); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct ServerProfile { + pub name: String, + pub group_id: Option<String>, + pub tags: Vec<String>, + pub remark: Option<String>, + pub public_remark: Option<String>, + pub price: Option<f64>, + pub currency: Option<String>, + pub billing_cycle: Option<String>, + pub billing_start_day: Option<i32>, + pub expired_at: Option<DateTime<Utc>>, + pub traffic_limit: Option<i64>, + pub traffic_limit_type: Option<String>, +} + +#[derive(Clone, Debug)] +pub struct OnboardServer { + pub actor_id: String, + pub request_id: OnboardingRequestId, + pub source: RequestSource, + pub profile: ServerProfile, + pub offer_ttl: OfferTtl, +} + +#[derive(Clone, Debug)] +pub enum OnboardingResult { + Created { + server_id: ServerId, + enrollment: IssuedOffer, + }, + Replayed { + server_id: ServerId, + outstanding_offer: Option<OutstandingOffer>, + }, +} + +#[derive(Debug, thiserror::Error)] +pub enum OnboardingError { + #[error("invalid onboarding input: {0}")] + Invalid(String), + #[error("onboarding validation failed: {0}")] + Validation(String), + #[error("server limit reached ({0})")] + LimitReached(u32), + #[error("onboarding request id was already used with different input")] + IdempotencyConflict, + #[error("server onboarding store failed: {0}")] + Store(#[from] AppError), +} + +#[derive(Clone)] +pub struct ServerOnboarding { + db: DatabaseConnection, + authority: Arc<AgentAuthority>, + max_servers: u32, + request_locks: Arc<DashMap<String, Arc<Mutex<()>>>>, +} + +struct RequestLockCleanup { + key: String, + lock: std::sync::Weak<Mutex<()>>, + registry: Arc<DashMap<String, Arc<Mutex<()>>>>, +} + +impl Drop for RequestLockCleanup { + fn drop(&mut self) { + self.registry.remove_if(&self.key, |_, current| { + std::sync::Weak::ptr_eq(&Arc::downgrade(current), &self.lock) + && self.lock.strong_count() == 2 + }); + } +} + +impl ServerOnboarding { + pub fn new(db: DatabaseConnection, authority: Arc<AgentAuthority>, max_servers: u32) -> Self { + Self { + db, + authority, + max_servers, + request_locks: Arc::new(DashMap::new()), + } + } + + pub async fn onboard(&self, input: OnboardServer) -> Result<OnboardingResult, OnboardingError> { + let normalized = NormalizedProfile::from_input(input.profile)?; + let input_hash = normalized.hash(input.offer_ttl)?; + if input.actor_id.trim().is_empty() { + return Err(OnboardingError::Invalid( + "actor id must not be empty".to_string(), + )); + } + + let lock_key = format!("{}:{}", input.actor_id, input.request_id.as_str()); + let request_lock = self + .request_locks + .entry(lock_key.clone()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone(); + let _cleanup = RequestLockCleanup { + key: lock_key, + lock: Arc::downgrade(&request_lock), + registry: self.request_locks.clone(), + }; + let _guard = request_lock.lock().await; + + if let Some(existing) = self + .find_request(&input.actor_id, input.request_id.as_str()) + .await? + { + if existing.normalized_input_hash != input_hash { + return Err(OnboardingError::IdempotencyConflict); + } + let server_id = ServerId::parse(existing.server_id).map_err(|error| { + AppError::Internal(format!("invalid stored server id: {error}")) + })?; + let state = self + .authority + .state(server_id.clone()) + .await + .map_err(map_state_error)?; + return Ok(OnboardingResult::Replayed { + server_id, + outstanding_offer: state.outstanding_offer, + }); + } + + let default_target_ids = NetworkProbeService::get_setting(&self.db) + .await? + .default_target_ids; + let tx = self.db.begin().await.map_err(AppError::from)?; + if self.max_servers > 0 { + let count = server::Entity::find().count(&tx).await?; + if count >= u64::from(self.max_servers) { + tx.rollback().await.map_err(AppError::from)?; + return Err(OnboardingError::LimitReached(self.max_servers)); + } + } + + let server_id = ServerId::parse(Uuid::new_v4().to_string()) + .map_err(|error| AppError::Internal(format!("generated invalid server id: {error}")))?; + let now = Utc::now(); + let row = server::ActiveModel { + id: Set(server_id.as_str().to_string()), + token_hash: Set(None), + token_prefix: Set(None), + name: Set(normalized.name.clone()), + cpu_name: Set(None), + cpu_cores: Set(None), + cpu_arch: Set(None), + os: Set(None), + kernel_version: Set(None), + mem_total: Set(None), + swap_total: Set(None), + disk_total: Set(None), + ipv4: Set(None), + ipv6: Set(None), + region: Set(None), + country_code: Set(None), + geo_manual: Set(false), + virtualization: Set(None), + agent_version: Set(None), + group_id: Set(normalized.group_id.clone()), + weight: Set(0), + hidden: Set(false), + remark: Set(normalized.remark.clone()), + public_remark: Set(normalized.public_remark.clone()), + price: Set(normalized.price), + billing_cycle: Set(normalized.billing_cycle.clone()), + currency: Set(normalized.currency.clone()), + expired_at: Set(normalized.expired_at), + traffic_limit: Set(normalized.traffic_limit), + traffic_limit_type: Set(normalized.traffic_limit_type.clone()), + billing_start_day: Set(normalized.billing_start_day), + capabilities: Set(serverbee_common::constants::CAP_DEFAULT as i32), + protocol_version: Set(1), + features: Set("[]".to_string()), + last_remote_addr: Set(None), + created_at: Set(now), + updated_at: Set(now), + } + .insert(&tx) + .await?; + + for tag in &normalized.tags { + server_tag::ActiveModel { + server_id: Set(server_id.as_str().to_string()), + tag: Set(tag.clone()), + } + .insert(&tx) + .await?; + } + NetworkProbeService::apply_defaults_tx(&tx, server_id.as_str(), &default_target_ids) + .await?; + let actor = Actor::User { + id: input.actor_id.clone(), + }; + let enrollment = self + .authority + .issue_initial_offer_tx(&tx, &row, &actor, &input.source, input.offer_ttl) + .await?; + server_onboarding_request::ActiveModel { + id: Set(Uuid::new_v4().to_string()), + actor_id: Set(input.actor_id), + request_id: Set(input.request_id.as_str().to_string()), + normalized_input_hash: Set(input_hash), + server_id: Set(server_id.as_str().to_string()), + created_at: Set(now), + } + .insert(&tx) + .await?; + tx.commit().await.map_err(AppError::from)?; + self.authority.broadcast_issued_offer_state( + server_id.as_str(), + crate::service::agent_authority::AuthorityStatus::Unclaimed, + &enrollment, + ); + + Ok(OnboardingResult::Created { + server_id, + enrollment, + }) + } + + async fn find_request( + &self, + actor_id: &str, + request_id: &str, + ) -> Result<Option<server_onboarding_request::Model>, AppError> { + Ok(server_onboarding_request::Entity::find() + .filter(server_onboarding_request::Column::ActorId.eq(actor_id)) + .filter(server_onboarding_request::Column::RequestId.eq(request_id)) + .one(&self.db) + .await?) + } +} + +#[derive(Serialize)] +struct NormalizedProfile { + name: String, + group_id: Option<String>, + tags: Vec<String>, + remark: Option<String>, + public_remark: Option<String>, + price: Option<f64>, + currency: Option<String>, + billing_cycle: Option<String>, + billing_start_day: Option<i32>, + expired_at: Option<DateTime<Utc>>, + traffic_limit: Option<i64>, + traffic_limit_type: Option<String>, +} + +impl NormalizedProfile { + fn from_input(profile: ServerProfile) -> Result<Self, OnboardingError> { + let name = profile.name.trim().to_string(); + if name.is_empty() { + return Err(OnboardingError::Invalid("name is required".to_string())); + } + if profile + .price + .is_some_and(|price| !price.is_finite() || price < 0.0) + { + return Err(OnboardingError::Invalid( + "price must be finite and greater than or equal to 0".to_string(), + )); + } + if profile + .billing_cycle + .as_deref() + .is_some_and(|cycle| !matches!(cycle, "monthly" | "quarterly" | "yearly")) + { + return Err(OnboardingError::Invalid( + "billing_cycle must be monthly, quarterly, or yearly".to_string(), + )); + } + if profile + .traffic_limit_type + .as_deref() + .is_some_and(|kind| !matches!(kind, "sum" | "up" | "down")) + { + return Err(OnboardingError::Invalid( + "traffic_limit_type must be sum, up, or down".to_string(), + )); + } + if profile + .billing_start_day + .is_some_and(|day| !(1..=28).contains(&day)) + { + return Err(OnboardingError::Invalid( + "billing_start_day must be between 1 and 28".to_string(), + )); + } + + Ok(Self { + name, + group_id: normalize_optional(profile.group_id), + tags: server_tag_service::validate_tags(&profile.tags) + .map_err(|error| OnboardingError::Validation(error.to_string()))?, + remark: normalize_optional(profile.remark), + public_remark: normalize_optional(profile.public_remark), + price: profile.price, + currency: normalize_optional(profile.currency), + billing_cycle: normalize_optional(profile.billing_cycle), + billing_start_day: profile.billing_start_day, + expired_at: profile.expired_at, + traffic_limit: profile.traffic_limit, + traffic_limit_type: normalize_optional(profile.traffic_limit_type), + }) + } + + fn hash(&self, offer_ttl: OfferTtl) -> Result<String, AppError> { + let canonical = serde_json::to_vec(&NormalizedRequest { + profile: self, + offer_ttl_seconds: offer_ttl.value(), + }) + .map_err(|error| AppError::Internal(format!("serialize onboarding input: {error}")))?; + Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(Sha256::digest(canonical))) + } +} + +#[derive(Serialize)] +struct NormalizedRequest<'a> { + profile: &'a NormalizedProfile, + offer_ttl_seconds: i64, +} + +fn normalize_optional(value: Option<String>) -> Option<String> { + value.and_then(|value| { + let value = value.trim().to_string(); + (!value.is_empty()).then_some(value) + }) +} + +fn map_state_error(error: StateError) -> OnboardingError { + match error { + StateError::NotFound => OnboardingError::Store(AppError::Internal( + "idempotent onboarding target no longer exists".to_string(), + )), + StateError::Store(error) => OnboardingError::Store(error), + } +} + +impl From<sea_orm::DbErr> for OnboardingError { + fn from(error: sea_orm::DbErr) -> Self { + Self::Store(AppError::from(error)) + } +} + +#[cfg(test)] +mod tests { + use sea_orm::{ConnectionTrait, EntityTrait, PaginatorTrait}; + use tokio::sync::broadcast; + + use super::*; + use crate::entity::{agent_authority_event, enrollment_offer}; + use crate::service::agent_manager::AgentManager; + use crate::test_utils::setup_test_db; + + struct Fixture { + onboarding: ServerOnboarding, + db: DatabaseConnection, + _tmp: tempfile::TempDir, + } + + async fn fixture() -> Fixture { + let (db, tmp) = setup_test_db().await; + let (browser_tx, _) = broadcast::channel(8); + let manager = Arc::new(AgentManager::new(browser_tx)); + let authority = Arc::new(AgentAuthority::new(db.clone(), manager)); + Fixture { + onboarding: ServerOnboarding::new(db.clone(), authority, 0), + db, + _tmp: tmp, + } + } + + fn input(request_id: &str, name: &str) -> OnboardServer { + OnboardServer { + actor_id: "user-1".to_string(), + request_id: OnboardingRequestId::parse(request_id).expect("request id"), + source: RequestSource::parse("api:create-server").expect("source"), + profile: ServerProfile { + name: name.to_string(), + group_id: None, + tags: vec!["edge".to_string(), "prod".to_string()], + remark: None, + public_remark: None, + price: None, + currency: None, + billing_cycle: None, + billing_start_day: None, + expired_at: None, + traffic_limit: None, + traffic_limit_type: None, + }, + offer_ttl: OfferTtl::default(), + } + } + + #[tokio::test] + async fn serial_replay_returns_same_server_without_plaintext() { + let fixture = fixture().await; + let first = fixture + .onboarding + .onboard(input("request-1", " Server One ")) + .await + .expect("create"); + let second = fixture + .onboarding + .onboard(input("request-1", "Server One")) + .await + .expect("replay"); + + let (created_id, offer_id) = match first { + OnboardingResult::Created { + server_id, + enrollment, + } => (server_id, enrollment.id), + OnboardingResult::Replayed { .. } => panic!("first result must create"), + }; + match second { + OnboardingResult::Replayed { + server_id, + outstanding_offer, + } => { + assert_eq!(server_id, created_id); + assert_eq!(outstanding_offer.map(|offer| offer.id), Some(offer_id)); + } + OnboardingResult::Created { .. } => panic!("replay must not create"), + } + assert_eq!( + server::Entity::find() + .count(&fixture.db) + .await + .expect("count"), + 1 + ); + } + + #[tokio::test] + async fn same_request_with_different_normalized_input_conflicts() { + let fixture = fixture().await; + fixture + .onboarding + .onboard(input("request-1", "Server One")) + .await + .expect("create"); + + let result = fixture + .onboarding + .onboard(input("request-1", "Server Two")) + .await; + + assert!(matches!(result, Err(OnboardingError::IdempotencyConflict))); + assert_eq!( + server::Entity::find() + .count(&fixture.db) + .await + .expect("count"), + 1 + ); + } + + #[tokio::test] + async fn concurrent_replay_creates_exactly_one_server() { + let fixture = fixture().await; + let first = fixture.onboarding.clone(); + let second = fixture.onboarding.clone(); + + let (first, second) = tokio::join!( + first.onboard(input("request-1", "Server One")), + second.onboard(input("request-1", "Server One")) + ); + + assert!(first.is_ok()); + assert!(second.is_ok()); + assert_eq!( + server::Entity::find() + .count(&fixture.db) + .await + .expect("count"), + 1 + ); + assert_eq!( + server_onboarding_request::Entity::find() + .count(&fixture.db) + .await + .expect("count requests"), + 1 + ); + assert!(fixture.onboarding.request_locks.is_empty()); + } + + #[tokio::test] + async fn authority_event_failure_rolls_back_entire_onboarding() { + let fixture = fixture().await; + fixture + .db + .execute_unprepared( + "CREATE TRIGGER reject_authority_events BEFORE INSERT ON agent_authority_events \ + BEGIN SELECT RAISE(ABORT, 'forced authority event failure'); END", + ) + .await + .expect("create failure trigger"); + + let result = fixture + .onboarding + .onboard(input("request-1", "Server One")) + .await; + + assert!(matches!(result, Err(OnboardingError::Store(_)))); + assert_eq!( + server::Entity::find() + .count(&fixture.db) + .await + .expect("servers"), + 0 + ); + assert_eq!( + enrollment_offer::Entity::find() + .count(&fixture.db) + .await + .expect("offers"), + 0 + ); + assert_eq!( + agent_authority_event::Entity::find() + .count(&fixture.db) + .await + .expect("events"), + 0 + ); + assert_eq!( + server_onboarding_request::Entity::find() + .count(&fixture.db) + .await + .expect("requests"), + 0 + ); + } +} diff --git a/crates/server/src/service/task_scheduler.rs b/crates/server/src/service/task_scheduler.rs index c2aea0de..afa82d37 100644 --- a/crates/server/src/service/task_scheduler.rs +++ b/crates/server/src/service/task_scheduler.rs @@ -1795,7 +1795,6 @@ mod tests { protocol_version: Set(1), features: Set("[]".to_string()), last_remote_addr: Set(None), - fingerprint: Set(None), created_at: Set(now), updated_at: Set(now), } diff --git a/crates/server/src/state.rs b/crates/server/src/state.rs index c568a455..f6ba951a 100644 --- a/crates/server/src/state.rs +++ b/crates/server/src/state.rs @@ -8,6 +8,7 @@ use tokio::sync::broadcast; use crate::config::AppConfig; use crate::error::AppError; +use crate::service::agent_authority::AgentAuthority; use crate::service::agent_manager::AgentManager; use crate::service::agent_reconcile::AgentDesiredStateReconciler; use crate::service::alert::AlertStateManager; @@ -21,6 +22,7 @@ use crate::service::high_risk_audit::{ }; use crate::service::monitor_check::MonitorCheckRunner; use crate::service::security::SecurityService; +use crate::service::server_onboarding::ServerOnboarding; use crate::service::task_scheduler::TaskScheduler; use crate::service::upgrade_release::UpgradeReleaseService; use crate::service::upgrade_tracker::UpgradeJobTracker; @@ -72,6 +74,8 @@ pub struct RateLimitEntry { pub struct AppState { pub db: DatabaseConnection, pub agent_manager: Arc<AgentManager>, + pub agent_authority: Arc<AgentAuthority>, + pub server_onboarding: Arc<ServerOnboarding>, pub browser_tx: broadcast::Sender<BrowserMessage>, pub config: AppConfig, pub upgrade_tracker: UpgradeJobTracker, @@ -183,6 +187,12 @@ impl AppState { pub async fn new(db: DatabaseConnection, config: AppConfig) -> Result<Arc<Self>, AppError> { let (browser_tx, _) = broadcast::channel(256); let agent_manager = Arc::new(AgentManager::new(browser_tx.clone())); + let agent_authority = Arc::new(AgentAuthority::new(db.clone(), agent_manager.clone())); + let server_onboarding = Arc::new(ServerOnboarding::new( + db.clone(), + agent_authority.clone(), + config.auth.max_servers, + )); let upgrade_tracker = UpgradeJobTracker::new(browser_tx.clone()); let upgrade_release_service = UpgradeReleaseService::new(&config.upgrade); let geoip = if !config.geoip.mmdb_path.is_empty() { @@ -250,6 +260,8 @@ impl AppState { Ok(Arc::new(Self { db, agent_manager, + agent_authority, + server_onboarding, browser_tx, config, upgrade_tracker, diff --git a/crates/server/src/task/offline_checker.rs b/crates/server/src/task/offline_checker.rs index e39ca02e..edf1b796 100644 --- a/crates/server/src/task/offline_checker.rs +++ b/crates/server/src/task/offline_checker.rs @@ -14,7 +14,7 @@ pub async fn run(state: Arc<AppState>) { let offline_candidates = state.agent_manager.stale_connection_candidates(30); for (server_id, connection_id) in offline_candidates { - let server_lock = state.agent_manager.server_cleanup_lock(&server_id); + let server_lock = state.agent_manager.server_lifecycle_lock(&server_id); let _guard = server_lock.lock().await; if state @@ -83,7 +83,7 @@ mod tests { server_name: &str, tx: mpsc::Sender<serverbee_common::protocol::ServerMessage>, ) { - let server_lock = state.agent_manager.server_cleanup_lock(server_id); + let server_lock = state.agent_manager.server_lifecycle_lock(server_id); let _guard = server_lock.lock().await; state.agent_manager.add_connection( server_id.to_string(), @@ -113,7 +113,7 @@ mod tests { let (candidate_server_id, candidate_connection_id) = stale_candidates[0].clone(); assert_eq!(candidate_server_id, "s1"); - let server_lock = state.agent_manager.server_cleanup_lock("s1"); + let server_lock = state.agent_manager.server_lifecycle_lock("s1"); let held_guard = server_lock.lock().await; let mut rx = state.browser_tx.subscribe(); @@ -129,7 +129,7 @@ mod tests { reconnect_task.await.unwrap(); - let server_lock = state.agent_manager.server_cleanup_lock("s1"); + let server_lock = state.agent_manager.server_lifecycle_lock("s1"); let _guard = server_lock.lock().await; if state .agent_manager diff --git a/crates/server/tests/agent_registration_integration.rs b/crates/server/tests/agent_registration_integration.rs index b09a283f..4328a4e7 100644 --- a/crates/server/tests/agent_registration_integration.rs +++ b/crates/server/tests/agent_registration_integration.rs @@ -1,1375 +1,366 @@ -use std::time::Duration; +mod common; -use sea_orm::{ConnectOptions, ConnectionTrait, Database}; -use sea_orm_migration::MigratorTrait; +use futures_util::{SinkExt, StreamExt}; use serde_json::{Value, json}; +use tokio_tungstenite::tungstenite; -use serverbee_server::config::{AppConfig, AuthConfig, DatabaseConfig, ServerConfig}; -use serverbee_server::migration::Migrator; -use serverbee_server::router::create_router; -use serverbee_server::service::auth::AuthService; -use serverbee_server::state::AppState; +use common::{connect_agent, http_client, login_admin, start_test_server}; -/// Build the absolute path to the test SQLite file for direct DB access in -/// tests that need to mutate state the public API does not expose (e.g. -/// expiring an enrollment). -fn db_path_for(tmp: &tempfile::TempDir) -> String { - format!("{}/test.db", tmp.path().to_str().unwrap()) +fn onboarding_body(request_id: &str, name: &str) -> Value { + json!({ + "onboarding_request_id": request_id, + "name": name + }) } -async fn start_test_server_with_cap(max_servers: u32) -> (String, tempfile::TempDir) { - let tmp = tempfile::tempdir().expect("Failed to create temp dir"); - let data_dir = tmp.path().to_str().unwrap().to_string(); - - let config = AppConfig { - server: ServerConfig { - listen: "127.0.0.1:0".to_string(), - data_dir: data_dir.clone(), - trusted_proxies: Vec::new(), - }, - database: DatabaseConfig { - path: "test.db".to_string(), - max_connections: 5, - }, - auth: AuthConfig { - session_ttl: 86400, - secure_cookie: false, - max_servers, - }, - ..AppConfig::default() - }; - - let db_path = format!("{}/test.db", data_dir); - let db_url = format!("sqlite://{}?mode=rwc", db_path); - let mut opt = ConnectOptions::new(&db_url); - opt.max_connections(5); - opt.sqlx_logging(false); - - let db = Database::connect(opt) - .await - .expect("Failed to connect to test database"); - - db.execute_unprepared("PRAGMA journal_mode=WAL") - .await - .expect("Failed to set journal mode"); - db.execute_unprepared("PRAGMA foreign_keys=ON") - .await - .expect("Failed to enable foreign keys"); - - Migrator::up(&db, None) - .await - .expect("Failed to run migrations"); - - AuthService::create_user(&db, "admin", "testpass", "admin") - .await - .expect("Failed to seed admin"); - - let state = AppState::new(db, config) - .await - .expect("Failed to create AppState"); - let app = create_router(state); - - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("Failed to bind listener"); - let addr = listener.local_addr().expect("Failed to read listener addr"); - let base_url = format!("http://{}", addr); - - tokio::spawn(async move { - axum::serve( - listener, - app.into_make_service_with_connect_info::<std::net::SocketAddr>(), - ) - .await - .expect("Test server failed"); - }); - - tokio::time::sleep(Duration::from_millis(50)).await; - - (base_url, tmp) -} - -async fn start_test_server() -> (String, tempfile::TempDir) { - start_test_server_with_cap(0).await -} - -fn http_client() -> reqwest::Client { - reqwest::Client::builder() - .cookie_store(true) - .timeout(Duration::from_secs(10)) - .build() - .expect("Failed to build HTTP client") -} - -async fn login_admin(client: &reqwest::Client, base_url: &str) { - let resp = client - .post(format!("{}/api/auth/login", base_url)) - .json(&json!({ - "username": "admin", - "password": "testpass" - })) - .send() - .await - .expect("Login request failed"); - - assert_eq!(resp.status(), 200, "Login should succeed"); -} - -#[tokio::test] -async fn post_servers_creates_pending_with_bound_enrollment() { - let (base_url, _tmp) = start_test_server().await; - let client = http_client(); - login_admin(&client, &base_url).await; - - let resp = client - .post(format!("{}/api/servers", base_url)) - .json(&json!({"name": "vps-1"})) +async fn onboard(client: &reqwest::Client, base_url: &str, request_id: &str, name: &str) -> Value { + let response = client + .post(format!("{base_url}/api/servers")) + .json(&onboarding_body(request_id, name)) .send() .await - .expect("create server request failed"); - assert_eq!(resp.status(), 200, "POST /api/servers should succeed"); - - let body: Value = resp.json().await.expect("Failed to parse response"); - let data = &body["data"]; - assert!(data["server_id"].is_string(), "server_id must be a string"); - - let enrollment = &data["enrollment"]; - assert!(enrollment["id"].is_string(), "enrollment.id must be a string"); - - let code = enrollment["code"] - .as_str() - .expect("enrollment.code must be a string"); - assert!( - code.len() >= 16, - "enrollment code length should be >= 16, got {}", - code.len() - ); - - let code_prefix = enrollment["code_prefix"] - .as_str() - .expect("enrollment.code_prefix must be a string"); - assert_eq!(&code[..8], code_prefix, "code prefix mismatch"); - - let expires_at = enrollment["expires_at"] - .as_str() - .expect("expires_at must be a string"); - chrono::DateTime::parse_from_rfc3339(expires_at) - .expect("expires_at must parse as RFC3339"); + .expect("onboard request"); + assert_eq!(response.status(), 200); + response.json().await.expect("onboard response") } -#[tokio::test] -async fn post_servers_with_full_metadata_persists_tags_and_billing() { - let (base_url, _tmp) = start_test_server().await; - let client = http_client(); - login_admin(&client, &base_url).await; - - let resp = client - .post(format!("{}/api/servers", base_url)) - .json(&json!({ - "name": "vps-prod", - "tags": ["db", "prod"], - "remark": "primary", - "public_remark": "edge", - "price": 5.0, - "currency": "USD", - "billing_cycle": "monthly", - "billing_start_day": 1, - "traffic_limit": 1099511627776_i64, - "traffic_limit_type": "sum" - })) - .send() - .await - .expect("create server request failed"); - assert_eq!(resp.status(), 200, "POST /api/servers should succeed"); - let body: Value = resp.json().await.expect("Failed to parse response"); - let server_id = body["data"]["server_id"] - .as_str() - .expect("server_id") - .to_string(); - - let get_resp = client - .get(format!("{}/api/servers/{}", base_url, server_id)) - .send() - .await - .expect("get server request failed"); - assert_eq!(get_resp.status(), 200); - let get_body: Value = get_resp.json().await.expect("Failed to parse get response"); - let data = &get_body["data"]; - assert_eq!(data["price"], 5.0); - assert_eq!(data["currency"], "USD"); - assert_eq!(data["billing_cycle"], "monthly"); - assert_eq!(data["billing_start_day"], 1); - assert_eq!(data["traffic_limit"], 1099511627776_i64); - assert_eq!(data["traffic_limit_type"], "sum"); - assert_eq!(data["remark"], "primary"); - assert_eq!(data["public_remark"], "edge"); - - let tags_resp = client - .get(format!("{}/api/servers/{}/tags", base_url, server_id)) - .send() - .await - .expect("get tags request failed"); - assert_eq!(tags_resp.status(), 200); - let tags_body: Value = tags_resp.json().await.expect("Failed to parse tags"); - let mut tags: Vec<String> = tags_body["data"] - .as_array() - .expect("tags should be array") - .iter() - .map(|t| t.as_str().expect("tag should be string").to_string()) - .collect(); - tags.sort(); - assert_eq!(tags, vec!["db".to_string(), "prod".to_string()]); -} - -#[tokio::test] -async fn post_servers_respects_max_servers_cap() { - let (base_url, _tmp) = start_test_server_with_cap(1).await; - let client = http_client(); - login_admin(&client, &base_url).await; - - let first = client - .post(format!("{}/api/servers", base_url)) - .json(&json!({"name": "first"})) - .send() - .await - .expect("first create failed"); - assert_eq!(first.status(), 200, "first create should succeed"); - - let second = client - .post(format!("{}/api/servers", base_url)) - .json(&json!({"name": "second"})) - .send() - .await - .expect("second create failed"); - assert_eq!( - second.status(), - 400, - "second create should hit max_servers cap" - ); -} - -#[tokio::test] -async fn get_server_returns_has_token_and_outstanding_enrollment() { - let (base_url, _tmp) = start_test_server().await; - let client = http_client(); - login_admin(&client, &base_url).await; - - let resp = client - .post(format!("{}/api/servers", base_url)) - .json(&json!({"name": "vps-pending"})) - .send() - .await - .expect("create server request failed"); - assert_eq!(resp.status(), 200); - let body: Value = resp.json().await.expect("Failed to parse response"); - let server_id = body["data"]["server_id"] - .as_str() - .expect("server_id") - .to_string(); - let returned_code_prefix = body["data"]["enrollment"]["code_prefix"] - .as_str() - .expect("code_prefix") - .to_string(); - let returned_enrollment_id = body["data"]["enrollment"]["id"] - .as_str() - .expect("enrollment id") - .to_string(); - - let get_resp = client - .get(format!("{}/api/servers/{}", base_url, server_id)) - .send() - .await - .expect("get server request failed"); - assert_eq!(get_resp.status(), 200); - let get_body: Value = get_resp.json().await.expect("Failed to parse get response"); - let data = &get_body["data"]; - - assert_eq!(data["has_token"], false, "pending server must have has_token=false"); - - let outstanding = &data["outstanding_enrollment"]; - assert!( - outstanding.is_object(), - "outstanding_enrollment must be present for pending server" - ); - assert_eq!(outstanding["id"], returned_enrollment_id); - assert_eq!(outstanding["code_prefix"], returned_code_prefix); - assert!(outstanding["expires_at"].is_string()); - assert!(outstanding["created_at"].is_string()); - - // Plaintext code must NOT leak through the GET endpoint. - assert!( - outstanding.get("code").is_none(), - "GET response must not include plaintext code" - ); -} - -/// Helper: POST /api/servers and return (server_id, enrollment_id, code). -async fn create_pending_server( +async fn claim( client: &reqwest::Client, base_url: &str, - name: &str, -) -> (String, String, String) { - let resp = client - .post(format!("{}/api/servers", base_url)) - .json(&json!({"name": name})) + code: &str, + token: &str, +) -> reqwest::Response { + client + .post(format!("{base_url}/api/agent/register")) + .bearer_auth(code) + .json(&json!({ "proposed_run_token": token })) .send() .await - .expect("create server request failed"); - assert_eq!(resp.status(), 200, "POST /api/servers should succeed"); - let body: Value = resp.json().await.expect("Failed to parse response"); - let server_id = body["data"]["server_id"] - .as_str() - .expect("server_id") - .to_string(); - let enrollment_id = body["data"]["enrollment"]["id"] - .as_str() - .expect("enrollment.id") - .to_string(); - let code = body["data"]["enrollment"]["code"] - .as_str() - .expect("enrollment.code") - .to_string(); - (server_id, enrollment_id, code) + .expect("claim request") } -#[tokio::test] -async fn agent_register_updates_bound_server_does_not_create() { - let (base_url, _tmp) = start_test_server().await; - let client = http_client(); - login_admin(&client, &base_url).await; - - let (server_id, _enrollment_id, code) = - create_pending_server(&client, &base_url, "vps-bound").await; - - // Count servers before register. - let list_before: Value = client - .get(format!("{}/api/servers", base_url)) - .send() - .await - .expect("list before failed") - .json() - .await - .expect("parse list before"); - let count_before = list_before["data"] - .as_array() - .expect("list data is array") - .len(); - - // Anonymous client (no admin cookies) using the enrollment as Bearer. - let anon = reqwest::Client::builder() - .timeout(Duration::from_secs(10)) - .build() - .expect("anon client"); - let resp = anon - .post(format!("{}/api/agent/register", base_url)) - .bearer_auth(&code) - .json(&json!({"fingerprint": ""})) - .send() - .await - .expect("register request failed"); - assert_eq!(resp.status(), 200, "register should succeed"); - let body: Value = resp.json().await.expect("parse register response"); - assert_eq!( - body["data"]["server_id"].as_str().expect("server_id"), - server_id, - "register must return the bound server_id, not create a new one", - ); - assert!( - body["data"]["token"] - .as_str() - .map(|t| !t.is_empty()) - .unwrap_or(false), - "register must return a non-empty token", - ); - - // No new server row was created. - let list_after: Value = client - .get(format!("{}/api/servers", base_url)) - .send() - .await - .expect("list after failed") - .json() - .await - .expect("parse list after"); - let count_after = list_after["data"] - .as_array() - .expect("list data is array") - .len(); - assert_eq!( - count_after, count_before, - "register must NOT create a new server row", +async fn assert_ws_unauthorized(base_url: &str, token: &str) { + let url = format!( + "{}/api/agent/ws?token={token}", + base_url.replace("http://", "ws://") ); - - // The bound server now has a token; outstanding_enrollment is gone. - let get_resp: Value = client - .get(format!("{}/api/servers/{}", base_url, server_id)) - .send() - .await - .expect("get server failed") - .json() + let error = tokio_tungstenite::connect_async(url) .await - .expect("parse get server"); - assert_eq!( - get_resp["data"]["has_token"], true, - "registered server must have_token=true", - ); - assert!( - get_resp["data"]["outstanding_enrollment"].is_null(), - "consumed enrollment should not show as outstanding: {:?}", - get_resp["data"]["outstanding_enrollment"], - ); + .expect_err("credential must be rejected"); + assert!(matches!( + error, + tungstenite::Error::Http(response) if response.status().as_u16() == 401 + )); } #[tokio::test] -async fn agent_register_with_revoked_code_returns_401_and_does_not_set_token() { +async fn onboarding_request_replay_returns_same_server_without_plaintext_code() { let (base_url, _tmp) = start_test_server().await; let client = http_client(); login_admin(&client, &base_url).await; - let (server_id, enrollment_id, code) = - create_pending_server(&client, &base_url, "vps-revoked").await; - - // Revoke via the existing admin DELETE endpoint (T6 maps DELETE to revoke). - let revoke_resp = client - .delete(format!( - "{}/api/agent/enrollments/{}", - base_url, enrollment_id - )) - .send() - .await - .expect("revoke request failed"); - assert_eq!(revoke_resp.status(), 200, "revoke should succeed"); - - let anon = reqwest::Client::builder() - .timeout(Duration::from_secs(10)) - .build() - .expect("anon client"); - let resp = anon - .post(format!("{}/api/agent/register", base_url)) - .bearer_auth(&code) - .json(&json!({"fingerprint": ""})) - .send() - .await - .expect("register request failed"); - assert_eq!( - resp.status(), - 401, - "register with revoked code must return 401", - ); + let first = onboard(&client, &base_url, "request-1", " Server One ").await; + let second = onboard(&client, &base_url, "request-1", "Server One").await; - // Server stays pending: no token, outstanding stays missing (we revoked it). - let get_resp: Value = client - .get(format!("{}/api/servers/{}", base_url, server_id)) - .send() - .await - .expect("get server failed") - .json() - .await - .expect("parse get server"); + assert_eq!(first["data"]["replayed"], false); + assert!(first["data"]["enrollment"]["code"].is_string()); + assert_eq!(second["data"]["replayed"], true); + assert_eq!(second["data"]["server_id"], first["data"]["server_id"]); + assert!(second["data"]["enrollment"].is_null()); assert_eq!( - get_resp["data"]["has_token"], false, - "revoked-code register must not stamp a token onto the bound server", - ); -} - -#[tokio::test] -async fn agent_register_with_expired_code_returns_401() { - let (base_url, tmp) = start_test_server().await; - let client = http_client(); - login_admin(&client, &base_url).await; - - let (_server_id, enrollment_id, code) = - create_pending_server(&client, &base_url, "vps-expired").await; - - // Flip the enrollment's expires_at to the epoch directly via SQLite, since - // no public endpoint can force expiry. - let db_url = format!("sqlite://{}?mode=rw", db_path_for(&tmp)); - let mut opt = ConnectOptions::new(&db_url); - opt.max_connections(2); - opt.sqlx_logging(false); - let db = Database::connect(opt).await.expect("connect test db"); - let stmt = sea_orm::Statement::from_sql_and_values( - sea_orm::DatabaseBackend::Sqlite, - "UPDATE agent_enrollments SET expires_at = ? WHERE id = ?", - [ - "1970-01-01T00:00:00+00:00".into(), - enrollment_id.clone().into(), - ], + second["data"]["outstanding_offer"]["id"], + first["data"]["enrollment"]["id"] ); - db.execute(stmt).await.expect("force-expire enrollment"); - drop(db); - let anon = reqwest::Client::builder() - .timeout(Duration::from_secs(10)) - .build() - .expect("anon client"); - let resp = anon - .post(format!("{}/api/agent/register", base_url)) - .bearer_auth(&code) - .json(&json!({"fingerprint": ""})) + let conflict = client + .post(format!("{base_url}/api/servers")) + .json(&onboarding_body("request-1", "Different Server")) .send() .await - .expect("register request failed"); - assert_eq!( - resp.status(), - 401, - "register with expired code must return 401", - ); + .expect("conflicting replay"); + assert_eq!(conflict.status(), 409); } #[tokio::test] -async fn agent_register_records_fingerprint_does_not_dedup() { - let (base_url, tmp) = start_test_server().await; - let client = http_client(); - login_admin(&client, &base_url).await; - - // Two pending servers, each with its own bound enrollment. - let (server_a, _, code_a) = create_pending_server(&client, &base_url, "vps-a").await; - let (server_b, _, code_b) = create_pending_server(&client, &base_url, "vps-b").await; - - let fp: String = "a".repeat(64); - let anon = reqwest::Client::builder() - .timeout(Duration::from_secs(10)) - .build() - .expect("anon client"); - - let resp_a = anon - .post(format!("{}/api/agent/register", base_url)) - .bearer_auth(&code_a) - .json(&json!({"fingerprint": fp})) - .send() - .await - .expect("register A failed"); - assert_eq!(resp_a.status(), 200, "register A must succeed"); - let body_a: Value = resp_a.json().await.expect("parse A"); - assert_eq!(body_a["data"]["server_id"].as_str().unwrap(), server_a); - - // Register B with the SAME fingerprint — must NOT be silently mapped to A. - let resp_b = anon - .post(format!("{}/api/agent/register", base_url)) - .bearer_auth(&code_b) - .json(&json!({"fingerprint": fp})) - .send() - .await - .expect("register B failed"); - assert_eq!(resp_b.status(), 200, "register B must succeed"); - let body_b: Value = resp_b.json().await.expect("parse B"); - assert_eq!( - body_b["data"]["server_id"].as_str().unwrap(), - server_b, - "B must return B's id, not be deduped onto A", - ); - - // Both rows present and both have tokens via the REST list. - let list: Value = client - .get(format!("{}/api/servers", base_url)) - .send() - .await - .expect("list failed") - .json() - .await - .expect("parse list"); - let arr = list["data"].as_array().expect("list array"); - assert_eq!(arr.len(), 2, "both servers must remain in the list"); - for s in arr { - assert_eq!(s["has_token"], true, "both rows must have_token=true"); - } - - // Verify both rows persist the supplied fingerprint at the DB level — - // the public ServerResponse intentionally does not expose `fingerprint`, - // so we read it directly. The key invariant: no fingerprint dedup, so - // BOTH rows carry the same fingerprint value. - let db_url = format!("sqlite://{}?mode=ro", db_path_for(&tmp)); - let mut opt = ConnectOptions::new(&db_url); - opt.max_connections(2); - opt.sqlx_logging(false); - let db = Database::connect(opt).await.expect("connect test db"); - for sid in [&server_a, &server_b] { - let stmt = sea_orm::Statement::from_sql_and_values( - sea_orm::DatabaseBackend::Sqlite, - "SELECT fingerprint FROM servers WHERE id = ?", - [sid.clone().into()], - ); - let row = db - .query_one(stmt) - .await - .expect("query") - .expect("row must exist"); - let stored: Option<String> = row.try_get("", "fingerprint").expect("fingerprint col"); - assert_eq!( - stored.as_deref(), - Some(fp.as_str()), - "server {sid} must record the supplied fingerprint (no dedup)", - ); - } -} - -#[tokio::test] -async fn agent_register_with_invalid_fingerprint_format_returns_400() { +async fn claim_uses_agent_proposed_token_and_returns_no_secret() { let (base_url, _tmp) = start_test_server().await; let client = http_client(); login_admin(&client, &base_url).await; - - let (server_id, enrollment_id, code) = - create_pending_server(&client, &base_url, "vps-badfp").await; - - let anon = reqwest::Client::builder() - .timeout(Duration::from_secs(10)) - .build() - .expect("anon client"); - let resp = anon - .post(format!("{}/api/agent/register", base_url)) - .bearer_auth(&code) - .json(&json!({"fingerprint": "short"})) - .send() - .await - .expect("register request failed"); - assert_eq!( - resp.status(), - 400, - "invalid fingerprint format must be rejected", - ); - - // Server stays pending: no token. - let get_resp: Value = client - .get(format!("{}/api/servers/{}", base_url, server_id)) - .send() - .await - .expect("get server failed") - .json() - .await - .expect("parse get server"); - assert_eq!( - get_resp["data"]["has_token"], false, - "invalid fingerprint must not stamp a token onto the bound server", - ); - let outstanding = &get_resp["data"]["outstanding_enrollment"]; - assert!( - outstanding.is_object(), - "invalid fingerprint must NOT burn the code: outstanding enrollment must remain", - ); - assert_eq!( - outstanding["id"].as_str().unwrap(), - enrollment_id, - "outstanding enrollment should still be the original one", - ); -} - -/// Helper: enroll a pending server so it becomes online (has_token=true, -/// no outstanding enrollment). Returns the agent token returned by register. -async fn enroll_pending_server(base_url: &str, code: &str) -> String { - let anon = reqwest::Client::builder() - .timeout(Duration::from_secs(10)) - .build() - .expect("anon client"); - let resp = anon - .post(format!("{}/api/agent/register", base_url)) - .bearer_auth(code) - .json(&json!({"fingerprint": ""})) - .send() - .await - .expect("register request failed"); - assert_eq!(resp.status(), 200, "register should succeed"); - let body: Value = resp.json().await.expect("parse register response"); - body["data"]["token"] + let created = onboard(&client, &base_url, "request-1", "Server One").await; + let code = created["data"]["enrollment"]["code"] .as_str() - .expect("register response missing token") - .to_string() -} - -#[tokio::test] -async fn recover_with_revoke_immediately_clears_token() { - let (base_url, _tmp) = start_test_server().await; - let client = http_client(); - login_admin(&client, &base_url).await; + .expect("code"); + let server_id = created["data"]["server_id"].as_str().expect("server id"); + let token = "agent-proposed-token-0123456789abcdef"; - let (server_id, _enrollment_id, code) = - create_pending_server(&client, &base_url, "vps-recover-revoke").await; - let _token = enroll_pending_server(&base_url, &code).await; + let response = claim(&client, &base_url, code, token).await; + assert_eq!(response.status(), 200); + let body: Value = response.json().await.expect("claim response"); + assert_eq!(body["data"]["server_id"], server_id); + assert!(body["data"].get("token").is_none()); - // Sanity: server has token now. - let pre: Value = client - .get(format!("{}/api/servers/{}", base_url, server_id)) - .send() - .await - .expect("pre get failed") - .json() - .await - .expect("parse pre get"); - assert_eq!( - pre["data"]["has_token"], true, - "server should have token after registration" - ); - assert!( - pre["data"]["outstanding_enrollment"].is_null(), - "no outstanding enrollment after consume" - ); - - // POST recover with revoke_immediately=true. - let resp = client - .post(format!("{}/api/servers/{}/recover", base_url, server_id)) - .json(&json!({"revoke_immediately": true})) - .send() - .await - .expect("recover request failed"); - assert_eq!(resp.status(), 200, "recover should succeed"); - let body: Value = resp.json().await.expect("parse recover response"); - let enrollment = &body["data"]["enrollment"]; - let new_id = enrollment["id"] - .as_str() - .expect("enrollment.id must be string"); - let new_code = enrollment["code"] - .as_str() - .expect("enrollment.code must be string"); - let new_prefix = enrollment["code_prefix"] - .as_str() - .expect("enrollment.code_prefix must be string"); - assert!(new_code.len() >= 16); - assert_eq!(&new_code[..8], new_prefix); - assert!( - chrono::DateTime::parse_from_rfc3339( - enrollment["expires_at"] - .as_str() - .expect("expires_at string") - ) - .is_ok() - ); - - // Server is now pending again (has_token=false), and the new enrollment is outstanding. - let post: Value = client - .get(format!("{}/api/servers/{}", base_url, server_id)) - .send() + let (mut sink, mut reader) = connect_agent(&base_url, token).await; + let welcome = reader + .next() .await - .expect("post get failed") - .json() - .await - .expect("parse post get"); - assert_eq!( - post["data"]["has_token"], false, - "revoke_immediately=true must clear token" - ); - let outstanding = &post["data"]["outstanding_enrollment"]; - assert!( - outstanding.is_object(), - "outstanding_enrollment must be set after recover" - ); - assert_eq!(outstanding["id"].as_str().unwrap(), new_id); - assert_eq!(outstanding["code_prefix"].as_str().unwrap(), new_prefix); -} - -#[tokio::test] -async fn recover_on_pending_server_returns_400() { - let (base_url, _tmp) = start_test_server().await; - let client = http_client(); - login_admin(&client, &base_url).await; + .expect("welcome frame") + .expect("welcome read"); + assert!(matches!(welcome, tungstenite::Message::Text(_))); + sink.close().await.expect("close socket"); - let (server_id, _enrollment_id, _code) = - create_pending_server(&client, &base_url, "vps-recover-pending").await; - - let resp = client - .post(format!("{}/api/servers/{}/recover", base_url, server_id)) - .json(&json!({"revoke_immediately": false})) - .send() - .await - .expect("recover request failed"); - assert_eq!( - resp.status(), - 400, - "recover on pending server must return 400" - ); + let replay = claim(&client, &base_url, code, token).await; + assert_eq!(replay.status(), 401); } #[tokio::test] -async fn recover_with_outstanding_enrollment_returns_409() { +async fn registration_requires_a_proposed_run_token_body() { let (base_url, _tmp) = start_test_server().await; let client = http_client(); login_admin(&client, &base_url).await; + let created = onboard(&client, &base_url, "request-1", "Server One").await; + let code = created["data"]["enrollment"]["code"] + .as_str() + .expect("code"); - let (server_id, _enrollment_id, code) = - create_pending_server(&client, &base_url, "vps-recover-409").await; - let _token = enroll_pending_server(&base_url, &code).await; - - // First recover: succeeds, mints a new enrollment without revoking the token. - let resp1 = client - .post(format!("{}/api/servers/{}/recover", base_url, server_id)) - .json(&json!({"revoke_immediately": false})) - .send() - .await - .expect("recover #1 request failed"); - assert_eq!(resp1.status(), 200, "first recover should succeed"); - - // Second recover: must return 409 (recover never auto-supersedes). - let resp2 = client - .post(format!("{}/api/servers/{}/recover", base_url, server_id)) - .json(&json!({"revoke_immediately": false})) + let missing = client + .post(format!("{base_url}/api/agent/register")) + .bearer_auth(code) .send() .await - .expect("recover #2 request failed"); - assert_eq!( - resp2.status(), - 409, - "second recover must return 409 when an outstanding enrollment exists" - ); -} - -#[tokio::test] -async fn recover_without_revoke_immediately_keeps_token() { - let (base_url, _tmp) = start_test_server().await; - let client = http_client(); - login_admin(&client, &base_url).await; + .expect("missing-body request"); + assert!(!missing.status().is_success()); - let (server_id, _enrollment_id, code) = - create_pending_server(&client, &base_url, "vps-recover-keep").await; - let _token = enroll_pending_server(&base_url, &code).await; + let malformed = claim(&client, &base_url, code, "too-short").await; + assert_eq!(malformed.status(), 400); - let resp = client - .post(format!("{}/api/servers/{}/recover", base_url, server_id)) - .json(&json!({"revoke_immediately": false})) - .send() - .await - .expect("recover request failed"); - assert_eq!(resp.status(), 200, "recover should succeed"); - - // Token stays (has_token=true), outstanding enrollment is set. - let get_resp: Value = client - .get(format!("{}/api/servers/{}", base_url, server_id)) - .send() - .await - .expect("get server failed") - .json() - .await - .expect("parse get server"); + let valid = claim( + &client, + &base_url, + code, + "valid-token-0123456789abcdefghijkl", + ) + .await; assert_eq!( - get_resp["data"]["has_token"], true, - "revoke_immediately=false must NOT clear token" - ); - assert!( - get_resp["data"]["outstanding_enrollment"].is_object(), - "outstanding_enrollment must be set after recover" + valid.status(), + 200, + "invalid bodies must not consume the offer" ); } #[tokio::test] -async fn recover_on_unknown_server_returns_404() { +async fn graceful_reenrollment_preserves_old_authority_until_new_claim() { let (base_url, _tmp) = start_test_server().await; let client = http_client(); login_admin(&client, &base_url).await; - - let resp = client - .post(format!( - "{}/api/servers/{}/recover", - base_url, "non-existent-id" - )) - .json(&json!({"revoke_immediately": false})) - .send() - .await - .expect("recover request failed"); + let created = onboard(&client, &base_url, "request-1", "Server One").await; + let server_id = created["data"]["server_id"].as_str().expect("server id"); + let initial_code = created["data"]["enrollment"]["code"] + .as_str() + .expect("code"); + let old_token = "old-agent-token-0123456789abcdefghijk"; assert_eq!( - resp.status(), - 404, - "recover on unknown server must return 404" + claim(&client, &base_url, initial_code, old_token) + .await + .status(), + 200 ); -} - -#[tokio::test] -async fn regenerate_supersedes_outstanding_on_pending() { - let (base_url, _tmp) = start_test_server().await; - let client = http_client(); - login_admin(&client, &base_url).await; - - let (server_id, enrollment_id_1, _code) = - create_pending_server(&client, &base_url, "vps-regen-cas").await; - let resp = client + let response = client .post(format!( - "{}/api/servers/{}/regenerate-code", - base_url, server_id + "{base_url}/api/servers/{server_id}/agent-authority/re-enrollment" )) - .json(&json!({"expected_enrollment_id": enrollment_id_1})) + .json(&json!({ "mode": "graceful" })) .send() .await - .expect("regenerate request failed"); - assert_eq!(resp.status(), 200, "regenerate should succeed"); - let body: Value = resp.json().await.expect("parse regenerate response"); - let new_id = body["data"]["enrollment"]["id"] + .expect("begin graceful"); + assert_eq!(response.status(), 200); + let body: Value = response.json().await.expect("graceful response"); + let new_code = body["data"]["enrollment"]["code"] .as_str() - .expect("new enrollment id"); - assert_ne!( - new_id, enrollment_id_1, - "regenerate must return a new enrollment id" - ); + .expect("new code"); - let get_resp: Value = client - .get(format!("{}/api/servers/{}", base_url, server_id)) + let state: Value = client + .get(format!( + "{base_url}/api/servers/{server_id}/agent-authority" + )) .send() .await - .expect("get server failed") + .expect("authority state") .json() .await - .expect("parse get server"); - let outstanding = &get_resp["data"]["outstanding_enrollment"]; - assert!( - outstanding.is_object(), - "outstanding_enrollment must be set after regenerate" - ); - assert_eq!( - outstanding["id"].as_str().unwrap(), - new_id, - "outstanding_enrollment must be the new id, not the superseded one" - ); -} - -#[tokio::test] -async fn regenerate_with_no_expected_id_supersedes_last_writer_wins() { - let (base_url, _tmp) = start_test_server().await; - let client = http_client(); - login_admin(&client, &base_url).await; - - let (server_id, enrollment_id_1, _code) = - create_pending_server(&client, &base_url, "vps-regen-noexpect").await; + .expect("state body"); + assert_eq!(state["data"]["status"], "claimed"); + let (mut old_sink, _) = connect_agent(&base_url, old_token).await; + old_sink.close().await.expect("close old socket"); - let resp = client - .post(format!( - "{}/api/servers/{}/regenerate-code", - base_url, server_id - )) - .json(&json!({})) - .send() - .await - .expect("regenerate request failed"); + let new_token = "new-agent-token-0123456789abcdefghijk"; assert_eq!( - resp.status(), - 200, - "regenerate without expected_enrollment_id must succeed (last-writer-wins)" + claim(&client, &base_url, new_code, new_token) + .await + .status(), + 200 ); - let body: Value = resp.json().await.expect("parse regenerate response"); - let new_id = body["data"]["enrollment"]["id"] - .as_str() - .expect("new enrollment id"); - assert_ne!(new_id, enrollment_id_1); + assert_ws_unauthorized(&base_url, old_token).await; + let (mut new_sink, _) = connect_agent(&base_url, new_token).await; + new_sink.close().await.expect("close new socket"); } #[tokio::test] -async fn regenerate_with_stale_expected_id_returns_409() { +async fn emergency_reenrollment_revokes_old_authority_immediately() { let (base_url, _tmp) = start_test_server().await; let client = http_client(); login_admin(&client, &base_url).await; + let created = onboard(&client, &base_url, "request-1", "Server One").await; + let server_id = created["data"]["server_id"].as_str().expect("server id"); + let code = created["data"]["enrollment"]["code"] + .as_str() + .expect("code"); + let token = "old-agent-token-0123456789abcdefghijk"; + assert_eq!(claim(&client, &base_url, code, token).await.status(), 200); - let (server_id, _enrollment_id, _code) = - create_pending_server(&client, &base_url, "vps-regen-stale").await; - - let resp = client + let response = client .post(format!( - "{}/api/servers/{}/regenerate-code", - base_url, server_id + "{base_url}/api/servers/{server_id}/agent-authority/re-enrollment" )) - .json(&json!({"expected_enrollment_id": "this-does-not-match"})) + .json(&json!({ "mode": "emergency" })) .send() .await - .expect("regenerate request failed"); - assert_eq!( - resp.status(), - 409, - "stale expected_enrollment_id must return 409" - ); -} + .expect("begin emergency"); + assert_eq!(response.status(), 200); + assert_ws_unauthorized(&base_url, token).await; -#[tokio::test] -async fn regenerate_when_no_outstanding_and_expected_id_provided_returns_409() { - let (base_url, _tmp) = start_test_server().await; - let client = http_client(); - login_admin(&client, &base_url).await; - - let (server_id, enrollment_id_1, _code) = - create_pending_server(&client, &base_url, "vps-regen-revoked").await; - - let revoke_resp = client - .delete(format!( - "{}/api/agent/enrollments/{}", - base_url, enrollment_id_1 + let state: Value = client + .get(format!( + "{base_url}/api/servers/{server_id}/agent-authority" )) .send() .await - .expect("revoke request failed"); - assert_eq!(revoke_resp.status(), 200, "revoke should succeed"); - - let resp = client - .post(format!( - "{}/api/servers/{}/regenerate-code", - base_url, server_id - )) - .json(&json!({"expected_enrollment_id": enrollment_id_1})) - .send() + .expect("authority state") + .json() .await - .expect("regenerate request failed"); - assert_eq!( - resp.status(), - 409, - "expected_enrollment_id pointing to a revoked row must return 409" - ); + .expect("state body"); + assert_eq!(state["data"]["status"], "unclaimed"); + assert!(state["data"]["outstanding_offer"].is_object()); } #[tokio::test] -async fn regenerate_when_no_outstanding_and_no_expected_id_succeeds() { +async fn offer_replacement_is_exact_and_revocation_is_idempotent() { let (base_url, _tmp) = start_test_server().await; let client = http_client(); login_admin(&client, &base_url).await; + let created = onboard(&client, &base_url, "request-1", "Server One").await; + let server_id = created["data"]["server_id"].as_str().expect("server id"); + let offer_id = created["data"]["enrollment"]["id"] + .as_str() + .expect("offer id"); - let (server_id, enrollment_id_1, _code) = - create_pending_server(&client, &base_url, "vps-regen-fresh").await; - - let revoke_resp = client - .delete(format!( - "{}/api/agent/enrollments/{}", - base_url, enrollment_id_1 - )) - .send() - .await - .expect("revoke request failed"); - assert_eq!(revoke_resp.status(), 200, "revoke should succeed"); - - let resp = client + let missing = client .post(format!( - "{}/api/servers/{}/regenerate-code", - base_url, server_id + "{base_url}/api/servers/{server_id}/agent-authority/offers/missing/replace" )) - .json(&json!({})) .send() .await - .expect("regenerate request failed"); - assert_eq!( - resp.status(), - 200, - "regenerate with no outstanding and no expected id must succeed" - ); - let body: Value = resp.json().await.expect("parse regenerate response"); - let new_id = body["data"]["enrollment"]["id"] - .as_str() - .expect("new enrollment id"); - assert_ne!(new_id, enrollment_id_1); -} + .expect("missing replacement"); + assert_eq!(missing.status(), 404); -#[tokio::test] -async fn regenerate_on_non_pending_returns_400() { - let (base_url, _tmp) = start_test_server().await; - let client = http_client(); - login_admin(&client, &base_url).await; - - let (server_id, _enrollment_id, code) = - create_pending_server(&client, &base_url, "vps-regen-online").await; - let _token = enroll_pending_server(&base_url, &code).await; - - let resp = client + let replacement = client .post(format!( - "{}/api/servers/{}/regenerate-code", - base_url, server_id + "{base_url}/api/servers/{server_id}/agent-authority/offers/{offer_id}/replace" )) - .json(&json!({})) .send() .await - .expect("regenerate request failed"); - assert_eq!( - resp.status(), - 400, - "regenerate on non-pending server must return 400" - ); -} - -#[tokio::test] -async fn regenerate_on_unknown_server_returns_404() { - let (base_url, _tmp) = start_test_server().await; - let client = http_client(); - login_admin(&client, &base_url).await; + .expect("replace offer"); + assert_eq!(replacement.status(), 200); + let replacement: Value = replacement.json().await.expect("replacement body"); + let new_offer_id = replacement["data"]["enrollment"]["id"] + .as_str() + .expect("new offer id"); - let resp = client + let stale = client .post(format!( - "{}/api/servers/{}/regenerate-code", - base_url, "non-existent-id" + "{base_url}/api/servers/{server_id}/agent-authority/offers/{offer_id}/replace" )) - .json(&json!({})) - .send() - .await - .expect("regenerate request failed"); - assert_eq!( - resp.status(), - 404, - "regenerate on unknown server must return 404" - ); -} - -#[tokio::test] -async fn list_enrollments_includes_id_and_target_server_id() { - let (base_url, _tmp) = start_test_server().await; - let client = http_client(); - login_admin(&client, &base_url).await; - - let (server_id, enrollment_id, code) = - create_pending_server(&client, &base_url, "vps-list").await; - - let resp = client - .get(format!("{}/api/agent/enrollments", base_url)) .send() .await - .expect("list enrollments request failed"); - assert_eq!(resp.status(), 200, "list enrollments should succeed"); - let body: Value = resp.json().await.expect("parse list response"); - let arr = body["data"].as_array().expect("data must be array"); - assert_eq!(arr.len(), 1, "exactly one enrollment exists"); - - let row = &arr[0]; - assert_eq!(row["id"].as_str().expect("id must be string"), enrollment_id); - assert_eq!( - row["target_server_id"] - .as_str() - .expect("target_server_id must be string"), - server_id, - ); - assert_eq!( - row["code_prefix"].as_str().expect("code_prefix must be string"), - &code[..8], - ); - assert!(row["expires_at"].is_string(), "expires_at must be present"); - assert!(row["created_at"].is_string(), "created_at must be present"); - assert!( - row["consumed_at"].is_null(), - "consumed_at must be null for a fresh enrollment" - ); - assert!( - row["revoked_at"].is_null(), - "revoked_at must be null for a fresh enrollment" - ); + .expect("stale replacement"); + assert_eq!(stale.status(), 409); - // The DTO must drop `label` and never expose the plaintext code. - assert!( - row.get("label").is_none(), - "EnrollmentSummary must not expose `label` after T11" - ); - assert!( - row.get("code").is_none(), - "EnrollmentSummary must never expose plaintext code" - ); + for expected_already_revoked in [false, true] { + let revoked: Value = client + .delete(format!( + "{base_url}/api/servers/{server_id}/agent-authority/offers/{new_offer_id}" + )) + .send() + .await + .expect("revoke offer") + .json() + .await + .expect("revoke body"); + assert_eq!(revoked["data"]["already_revoked"], expected_already_revoked); + } } #[tokio::test] -async fn delete_enrollment_revokes_only_does_not_delete_server() { +async fn authority_revocation_creates_no_offer_and_events_survive_server_delete() { let (base_url, _tmp) = start_test_server().await; let client = http_client(); login_admin(&client, &base_url).await; + let created = onboard(&client, &base_url, "request-1", "Server One").await; + let server_id = created["data"]["server_id"].as_str().expect("server id"); + let code = created["data"]["enrollment"]["code"] + .as_str() + .expect("code"); + let token = "agent-token-0123456789abcdefghijklmnop"; + assert_eq!(claim(&client, &base_url, code, token).await.status(), 200); - let (server_id, enrollment_id, _code) = - create_pending_server(&client, &base_url, "vps-revoke-only").await; - - let resp = client + let revoked = client .delete(format!( - "{}/api/agent/enrollments/{}", - base_url, enrollment_id + "{base_url}/api/servers/{server_id}/agent-authority" )) .send() .await - .expect("delete enrollment request failed"); - assert_eq!(resp.status(), 200, "DELETE enrollment should succeed"); - - // The bound server still exists (DELETE enrollment does NOT cascade). - let get_resp = client - .get(format!("{}/api/servers/{}", base_url, server_id)) - .send() - .await - .expect("get server request failed"); - assert_eq!( - get_resp.status(), - 200, - "server must still exist after enrollment revoke" - ); - - // The enrollment row remains in the list with a non-null revoked_at. - let list_resp: Value = client - .get(format!("{}/api/agent/enrollments", base_url)) - .send() - .await - .expect("list enrollments failed") - .json() - .await - .expect("parse list"); - let arr = list_resp["data"].as_array().expect("data array"); - let row = arr - .iter() - .find(|r| r["id"].as_str() == Some(enrollment_id.as_str())) - .expect("revoked enrollment must remain in the list"); - assert!( - row["revoked_at"].is_string(), - "revoked_at must be a string after revoke, got {:?}", - row["revoked_at"] - ); -} - -#[tokio::test] -async fn create_enrollment_route_is_gone() { - let (base_url, _tmp) = start_test_server().await; - let client = http_client(); - login_admin(&client, &base_url).await; + .expect("revoke authority"); + assert_eq!(revoked.status(), 200); + assert_ws_unauthorized(&base_url, token).await; - let resp = client - .post(format!("{}/api/agent/enrollments", base_url)) - .json(&json!({"label": "x", "ttl_secs": 600})) - .send() - .await - .expect("post enrollments request failed"); - let status = resp.status().as_u16(); - assert!( - status == 404 || status == 405, - "POST /api/agent/enrollments must be 404 or 405 (route removed), got {status}" - ); -} - -#[tokio::test] -async fn rotate_token_on_pending_returns_400() { - let (base_url, _tmp) = start_test_server().await; - let client = http_client(); - login_admin(&client, &base_url).await; - - let (server_id, _enrollment_id, _code) = - create_pending_server(&client, &base_url, "vps-rotate-pending").await; - - let resp = client - .post(format!( - "{}/api/agent/{}/rotate-token", - base_url, server_id + let state: Value = client + .get(format!( + "{base_url}/api/servers/{server_id}/agent-authority" )) .send() .await - .expect("rotate-token request failed"); - assert_eq!( - resp.status(), - 400, - "rotate-token on a pending server must return 400" - ); - - // Server is still pending: has_token=false, outstanding_enrollment still set. - let get_resp: Value = client - .get(format!("{}/api/servers/{}", base_url, server_id)) - .send() - .await - .expect("get server failed") + .expect("authority state") .json() .await - .expect("parse get server"); - assert_eq!( - get_resp["data"]["has_token"], false, - "pending server must remain pending after rejected rotate-token" - ); - assert!( - get_resp["data"]["outstanding_enrollment"].is_object(), - "outstanding enrollment must still be present after rejected rotate-token" - ); -} - -#[tokio::test] -async fn rotate_token_on_non_pending_succeeds_and_invalidates_old_token() { - let (base_url, _tmp) = start_test_server().await; - let client = http_client(); - login_admin(&client, &base_url).await; - - let (server_id, _enrollment_id, code) = - create_pending_server(&client, &base_url, "vps-rotate-ok").await; - let token_v1 = enroll_pending_server(&base_url, &code).await; + .expect("state body"); + assert_eq!(state["data"]["status"], "unclaimed"); + assert!(state["data"]["outstanding_offer"].is_null()); - // Rotate as admin. - let resp = client - .post(format!( - "{}/api/agent/{}/rotate-token", - base_url, server_id - )) - .send() - .await - .expect("rotate-token request failed"); - assert_eq!(resp.status(), 200, "rotate-token should succeed"); - let body: Value = resp.json().await.expect("parse rotate response"); - let token_v2 = body["data"]["token"] - .as_str() - .expect("rotate response missing token") - .to_string(); - assert_ne!(token_v1, token_v2, "rotation must mint a fresh token"); assert_eq!( - body["data"]["server_id"].as_str().unwrap(), - server_id, - "response server_id must echo input" - ); - - // The DB token_hash must no longer verify against the old plaintext. - // We can prove this by reaching into the DB directly because we still own - // the sqlite file backing the test server. - let db_url = format!("sqlite://{}?mode=ro", db_path_for(&_tmp)); - let mut opt = ConnectOptions::new(&db_url); - opt.max_connections(2); - opt.sqlx_logging(false); - let db = Database::connect(opt) - .await - .expect("connect to test db for verification"); - let stmt = sea_orm::Statement::from_sql_and_values( - sea_orm::DatabaseBackend::Sqlite, - "SELECT token_hash FROM servers WHERE id = ?", - [server_id.clone().into()], - ); - let row = db - .query_one(stmt) - .await - .expect("query token_hash") - .expect("server row missing"); - let stored_hash: String = row - .try_get("", "token_hash") - .expect("token_hash column must be a string"); - assert!( - !AuthService::verify_password(&token_v1, &stored_hash) - .expect("verify old token plaintext"), - "old token plaintext must NOT verify after rotation" - ); - assert!( - AuthService::verify_password(&token_v2, &stored_hash) - .expect("verify new token plaintext"), - "new token plaintext must verify against rotated hash" + client + .delete(format!("{base_url}/api/servers/{server_id}")) + .send() + .await + .expect("delete server") + .status(), + 200 ); -} - -#[tokio::test] -async fn delete_server_closes_ws_connection() { - let (base_url, _tmp) = start_test_server().await; - let client = http_client(); - login_admin(&client, &base_url).await; - - let (server_id, _enrollment_id, code) = - create_pending_server(&client, &base_url, "vps-delete-kicks-ws").await; - let _token = enroll_pending_server(&base_url, &code).await; - - // Sanity: server now exists and has a token. - let pre: Value = client - .get(format!("{}/api/servers/{}", base_url, server_id)) + let history: Value = client + .get(format!( + "{base_url}/api/agent-authority/events?server_id={server_id}" + )) .send() .await - .expect("pre-delete GET failed") + .expect("history") .json() .await - .expect("parse pre-delete GET"); - assert_eq!(pre["data"]["has_token"], true); - - // DELETE the server. The handler must (a) succeed and (b) call - // agent_manager.remove_connection(&id). The kick path is a no-op when no - // live WS is connected, but it must not error. - let resp = client - .delete(format!("{}/api/servers/{}", base_url, server_id)) - .send() - .await - .expect("delete request failed"); - assert_eq!(resp.status(), 200, "DELETE /api/servers/{{id}} should succeed"); - - // The server row should be gone. - let post = client - .get(format!("{}/api/servers/{}", base_url, server_id)) - .send() - .await - .expect("post-delete GET failed"); - assert_eq!( - post.status(), - 404, - "deleted server must return 404 on subsequent GET" - ); + .expect("history body"); + let transitions: Vec<&str> = history["data"] + .as_array() + .expect("events") + .iter() + .filter_map(|event| event["transition"].as_str()) + .collect(); + assert!(transitions.contains(&"authority_revoked")); + assert!(transitions.contains(&"server_deleted")); } diff --git a/crates/server/tests/common/mod.rs b/crates/server/tests/common/mod.rs index c268144b..527a7edd 100644 --- a/crates/server/tests/common/mod.rs +++ b/crates/server/tests/common/mod.rs @@ -144,9 +144,13 @@ pub async fn login_as_new_user( /// Create a pending server via the admin API and return its server id. pub async fn create_server(client: &reqwest::Client, base_url: &str, name: &str) -> String { + let onboarding_request_id = uuid::Uuid::new_v4().to_string(); let resp = client .post(format!("{}/api/servers", base_url)) - .json(&json!({ "name": name })) + .json(&json!({ + "onboarding_request_id": onboarding_request_id, + "name": name + })) .send() .await .expect("create server failed"); @@ -182,9 +186,13 @@ pub type AgentReader = futures_util::stream::SplitStream< /// Mint an enrollment code by creating a pending server as admin. pub async fn mint_enrollment_code(client: &reqwest::Client, base_url: &str, name: &str) -> String { login_admin(client, base_url).await; + let onboarding_request_id = uuid::Uuid::new_v4().to_string(); let resp = client .post(format!("{}/api/servers", base_url)) - .json(&json!({ "name": name })) + .json(&json!({ + "onboarding_request_id": onboarding_request_id, + "name": name + })) .send() .await .expect("create-server request failed"); @@ -199,16 +207,20 @@ pub async fn mint_enrollment_code(client: &reqwest::Client, base_url: &str, name /// Register an agent (enrollment → register) and return `(server_id, token)`. pub async fn register_agent(client: &reqwest::Client, base_url: &str) -> (String, String) { let code = mint_enrollment_code(client, base_url, "mock-agent-server").await; + let token = format!("test-token-{}", uuid::Uuid::new_v4()); let resp = client .post(format!("{}/api/agent/register", base_url)) .header("Authorization", format!("Bearer {code}")) + .json(&json!({ "proposed_run_token": token })) .send() .await .expect("register request failed"); assert_eq!(resp.status(), 200, "agent registration should succeed"); let body: Value = resp.json().await.expect("parse register response"); - let server_id = body["data"]["server_id"].as_str().expect("server_id missing").to_string(); - let token = body["data"]["token"].as_str().expect("token missing").to_string(); + let server_id = body["data"]["server_id"] + .as_str() + .expect("server_id missing") + .to_string(); (server_id, token) } diff --git a/crates/server/tests/cost_integration.rs b/crates/server/tests/cost_integration.rs index 18dcf19f..6535e9fb 100644 --- a/crates/server/tests/cost_integration.rs +++ b/crates/server/tests/cost_integration.rs @@ -108,7 +108,10 @@ async fn create_pending_server(client: &reqwest::Client, base_url: &str) -> (Str login_admin(client, base_url).await; let resp = client .post(format!("{}/api/servers", base_url)) - .json(&json!({ "name": "cost-integration-test" })) + .json(&json!({ + "onboarding_request_id": uuid::Uuid::new_v4().to_string(), + "name": "cost-integration-test" + })) .send() .await .expect("Create server request failed"); @@ -133,6 +136,9 @@ async fn register_agent(client: &reqwest::Client, base_url: &str) -> String { let register_resp = client .post(format!("{}/api/agent/register", base_url)) .header("Authorization", format!("Bearer {code}")) + .json(&json!({ + "proposed_run_token": format!("cost-token-{}", uuid::Uuid::new_v4()) + })) .send() .await .expect("Register request failed"); diff --git a/crates/server/tests/docker_integration.rs b/crates/server/tests/docker_integration.rs index 0aa028d9..aac42bbf 100644 --- a/crates/server/tests/docker_integration.rs +++ b/crates/server/tests/docker_integration.rs @@ -150,7 +150,10 @@ async fn mint_enrollment_code(client: &Client, base_url: &str) -> String { login_admin(client, base_url).await; let resp = client .post(format!("{}/api/servers", base_url)) - .json(&json!({ "name": "docker-integration-test" })) + .json(&json!({ + "onboarding_request_id": uuid::Uuid::new_v4().to_string(), + "name": "docker-integration-test" + })) .send() .await .expect("Create-server request failed"); @@ -167,9 +170,11 @@ async fn mint_enrollment_code(client: &Client, base_url: &str) -> String { async fn register_agent(client: &Client, base_url: &str) -> (String, String) { let code = mint_enrollment_code(client, base_url).await; + let token = format!("docker-token-{}", uuid::Uuid::new_v4()); let resp = client .post(format!("{}/api/agent/register", base_url)) .header("Authorization", format!("Bearer {code}")) + .json(&json!({ "proposed_run_token": token })) .send() .await .expect("Register request failed"); @@ -185,10 +190,7 @@ async fn register_agent(client: &Client, base_url: &str) -> (String, String) { .as_str() .expect("server_id missing") .to_string(), - body["data"]["token"] - .as_str() - .expect("token missing") - .to_string(), + token, ) } diff --git a/crates/server/tests/integration.rs b/crates/server/tests/integration.rs index 3bf7e6cc..0b01aeeb 100644 --- a/crates/server/tests/integration.rs +++ b/crates/server/tests/integration.rs @@ -171,7 +171,10 @@ async fn create_pending_server_named( login_admin(client, base_url).await; let resp = client .post(format!("{}/api/servers", base_url)) - .json(&json!({ "name": name })) + .json(&json!({ + "onboarding_request_id": uuid::Uuid::new_v4().to_string(), + "name": name + })) .send() .await .expect("Create-server request failed"); @@ -188,9 +191,11 @@ async fn create_pending_server_named( async fn register_agent(client: &reqwest::Client, base_url: &str) -> (String, String) { let code = mint_enrollment_code(client, base_url).await; + let token = format!("integration-token-{}", uuid::Uuid::new_v4()); let register_resp = client .post(format!("{}/api/agent/register", base_url)) .header("Authorization", format!("Bearer {code}")) + .json(&json!({ "proposed_run_token": token })) .send() .await .expect("Register request failed"); @@ -209,11 +214,6 @@ async fn register_agent(client: &reqwest::Client, base_url: &str) -> (String, St .as_str() .expect("server_id missing") .to_string(); - let token = register_body["data"]["token"] - .as_str() - .expect("token missing") - .to_string(); - (server_id, token) } @@ -321,9 +321,11 @@ async fn test_agent_register_connect_report() { // ── Step 1: Register agent ── let enrollment_code = mint_enrollment_code(&client, &base_url).await; + let token = format!("integration-token-{}", uuid::Uuid::new_v4()); let register_resp = client .post(format!("{}/api/agent/register", base_url)) .header("Authorization", format!("Bearer {enrollment_code}")) + .json(&json!({ "proposed_run_token": token })) .send() .await .expect("Register request failed"); @@ -341,10 +343,6 @@ async fn test_agent_register_connect_report() { let server_id = register_body["data"]["server_id"] .as_str() .expect("server_id missing"); - let token = register_body["data"]["token"] - .as_str() - .expect("token missing"); - assert!(!server_id.is_empty(), "server_id should not be empty"); assert!(!token.is_empty(), "token should not be empty"); @@ -352,7 +350,7 @@ async fn test_agent_register_connect_report() { let ws_url = format!( "{}/api/agent/ws?token={}", base_url.replace("http://", "ws://"), - token + &token ); let (ws_stream, _) = tokio_tungstenite::connect_async(&ws_url) .await @@ -567,6 +565,9 @@ async fn test_server_records_api_returns_disk_io_json() { let register_resp = client .post(format!("{}/api/agent/register", base_url)) .header("Authorization", format!("Bearer {enrollment_code}")) + .json(&json!({ + "proposed_run_token": format!("integration-token-{}", uuid::Uuid::new_v4()) + })) .send() .await .expect("Register request failed"); @@ -1286,9 +1287,7 @@ async fn test_temporary_capability_grant_gate_audit_and_expiry() { async fn connect_browser_ws( base_url: &str, api_key: &str, -) -> tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>, -> { +) -> tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>> { let ws_url = base_url.replace("http://", "ws://") + "/api/ws/servers"; let mut request = ws_url .into_client_request() @@ -1751,6 +1750,9 @@ async fn test_network_probe_server_targets() { let register_resp = client .post(format!("{}/api/agent/register", base_url)) .header("Authorization", format!("Bearer {enrollment_code}")) + .json(&json!({ + "proposed_run_token": format!("integration-token-{}", uuid::Uuid::new_v4()) + })) .send() .await .expect("Agent register failed"); @@ -2205,13 +2207,6 @@ async fn test_user_management_crud() { ); } -// Removed: test_agent_register_reuses_existing_server_for_same_fingerprint -// The agent-registration redesign (spec 2026-05-25) makes fingerprint -// informational only — it is never used for lookup or dedup. Two minted -// enrollments now MUST produce two distinct server rows even if their -// fingerprints match. Coverage for the new behaviour lives in -// `agent_registration_integration.rs::agent_register_records_fingerprint_does_not_dedup`. - #[tokio::test] async fn test_cleanup_orphans_skips_online_uninitialized_server() { let (base_url, _tmp) = start_test_server().await; @@ -2221,9 +2216,11 @@ async fn test_cleanup_orphans_skips_online_uninitialized_server() { // The cleanup heuristic matches `name == "New Server"` AND `os IS NULL`, // so the test must create rows with that name explicitly. let orphan_code = create_pending_server_named(&client, &base_url, "New Server").await; + let orphan_token = format!("orphan-token-{}", uuid::Uuid::new_v4()); let orphan_resp = client .post(format!("{}/api/agent/register", base_url)) .header("Authorization", format!("Bearer {orphan_code}")) + .json(&json!({ "proposed_run_token": orphan_token })) .send() .await .expect("Offline orphan registration failed"); @@ -2236,9 +2233,11 @@ async fn test_cleanup_orphans_skips_online_uninitialized_server() { .to_string(); let online_code = create_pending_server_named(&client, &base_url, "New Server").await; + let online_token = format!("online-token-{}", uuid::Uuid::new_v4()); let online_resp = client .post(format!("{}/api/agent/register", base_url)) .header("Authorization", format!("Bearer {online_code}")) + .json(&json!({ "proposed_run_token": online_token })) .send() .await .expect("Online placeholder registration failed"); @@ -2249,14 +2248,10 @@ async fn test_cleanup_orphans_skips_online_uninitialized_server() { .as_str() .expect("online server_id missing") .to_string(); - let online_token = online_body["data"]["token"] - .as_str() - .expect("online token missing"); - let ws_url = format!( "{}/api/agent/ws?token={}", base_url.replace("http://", "ws://"), - online_token + &online_token ); let (ws_stream, _) = tokio_tungstenite::connect_async(&ws_url) .await @@ -2459,6 +2454,9 @@ async fn test_file_capability_enforcement() { let register_resp = client .post(format!("{}/api/agent/register", base_url)) .header("Authorization", format!("Bearer {enrollment_code}")) + .json(&json!({ + "proposed_run_token": format!("integration-token-{}", uuid::Uuid::new_v4()) + })) .send() .await .expect("Register request failed"); @@ -2810,6 +2808,9 @@ async fn test_oneshot_task_backward_compat() { let register_resp = client .post(format!("{}/api/agent/register", base_url)) .header("Authorization", format!("Bearer {enrollment_code}")) + .json(&json!({ + "proposed_run_token": format!("integration-token-{}", uuid::Uuid::new_v4()) + })) .send() .await .expect("Register failed"); @@ -2846,6 +2847,9 @@ async fn test_traffic_api_returns_data() { let register_resp = client .post(format!("{}/api/agent/register", base_url)) .header("Authorization", format!("Bearer {enrollment_code}")) + .json(&json!({ + "proposed_run_token": format!("integration-token-{}", uuid::Uuid::new_v4()) + })) .send() .await .expect("Register failed"); @@ -3048,6 +3052,9 @@ async fn test_traffic_overview_api() { let register_resp = client .post(format!("{}/api/agent/register", base_url)) .header("Authorization", format!("Bearer {enrollment_code}")) + .json(&json!({ + "proposed_run_token": format!("integration-token-{}", uuid::Uuid::new_v4()) + })) .send() .await .expect("Register failed"); @@ -3116,6 +3123,9 @@ async fn test_server_billing_start_day() { let register_resp = client .post(format!("{}/api/agent/register", base_url)) .header("Authorization", format!("Bearer {enrollment_code}")) + .json(&json!({ + "proposed_run_token": format!("integration-token-{}", uuid::Uuid::new_v4()) + })) .send() .await .expect("Register failed"); @@ -3725,6 +3735,9 @@ async fn test_uptime_daily_returns_data() { let register_resp = client .post(format!("{}/api/agent/register", base_url)) .header("Authorization", format!("Bearer {enrollment_code}")) + .json(&json!({ + "proposed_run_token": format!("integration-token-{}", uuid::Uuid::new_v4()) + })) .send() .await .expect("Register failed"); @@ -4239,79 +4252,6 @@ async fn browser_ws_full_sync_includes_tags_and_cpu_cores() { ); } - -#[tokio::test] -async fn test_rotate_token_revokes_old_token_and_404_for_unknown_server() { - let (base_url, _tmp) = start_test_server().await; - let client = http_client(); - login_admin(&client, &base_url).await; - - let (server_id, old_token) = register_agent(&client, &base_url).await; - - // Rotate the run token via the admin endpoint (admin session on client). - let rotate_resp = client - .post(format!("{}/api/agent/{}/rotate-token", base_url, server_id)) - .send() - .await - .expect("rotate-token request failed"); - assert_eq!( - rotate_resp.status(), - 200, - "rotate-token should succeed for an existing server" - ); - let rotate_body: serde_json::Value = rotate_resp - .json() - .await - .expect("Failed to parse rotate-token response"); - - assert_eq!( - rotate_body["data"]["server_id"].as_str(), - Some(server_id.as_str()), - "response echoes the server id" - ); - let new_token = rotate_body["data"]["token"] - .as_str() - .expect("new token missing"); - assert!(!new_token.is_empty(), "new token must be non-empty"); - assert_ne!( - new_token, old_token, - "rotated token must differ from the old one" - ); - - // The OLD token must now be rejected on the agent WS upgrade (401). - let ws_url = format!( - "{}/api/agent/ws?token={}", - base_url.replace("http://", "ws://"), - old_token - ); - let err = tokio_tungstenite::connect_async(&ws_url) - .await - .expect_err("revoked token must be rejected on agent ws upgrade"); - match err { - tungstenite::Error::Http(resp) => { - assert_eq!( - resp.status(), - 401, - "revoked agent token should yield a 401 handshake response" - ); - } - other => panic!("expected http handshake failure, got {other:?}"), - } - - // Rotating a non-existent server id returns 404. - let unknown_id = uuid::Uuid::new_v4().to_string(); - let missing_resp = client - .post(format!("{}/api/agent/{}/rotate-token", base_url, unknown_id)) - .send() - .await - .expect("rotate-token request for unknown server failed"); - assert_eq!( - missing_resp.status(), - 404, - "rotating an unknown server id should return 404" - ); -} - mod firewall_tests { //! Integration tests for the firewall blocklist feature. Exercises the //! full REST + WS pipeline: guardrail, dedup, cap/proto gating, ack diff --git a/crates/server/tests/ip_quality_integration.rs b/crates/server/tests/ip_quality_integration.rs index 238e8db2..8dc915dc 100644 --- a/crates/server/tests/ip_quality_integration.rs +++ b/crates/server/tests/ip_quality_integration.rs @@ -127,7 +127,10 @@ async fn register_agent(client: &reqwest::Client, base_url: &str) -> (String, St // enrollment code as part of the same response). let enroll_resp = client .post(format!("{}/api/servers", base_url)) - .json(&json!({ "name": "ip-quality-test" })) + .json(&json!({ + "onboarding_request_id": uuid::Uuid::new_v4().to_string(), + "name": "ip-quality-test" + })) .send() .await .expect("Create-server request failed"); @@ -139,9 +142,11 @@ async fn register_agent(client: &reqwest::Client, base_url: &str) -> (String, St .to_string(); // Register + let token = format!("ip-quality-token-{}", uuid::Uuid::new_v4()); let register_resp = client .post(format!("{}/api/agent/register", base_url)) .header("Authorization", format!("Bearer {code}")) + .json(&json!({ "proposed_run_token": token })) .send() .await .expect("Register request failed"); @@ -151,7 +156,6 @@ async fn register_agent(client: &reqwest::Client, base_url: &str) -> (String, St .as_str() .unwrap() .to_string(); - let token = register_body["data"]["token"].as_str().unwrap().to_string(); (server_id, token) } diff --git a/crates/server/tests/public_status_anonymous.rs b/crates/server/tests/public_status_anonymous.rs index 6c98598e..7a7e7a91 100644 --- a/crates/server/tests/public_status_anonymous.rs +++ b/crates/server/tests/public_status_anonymous.rs @@ -145,7 +145,6 @@ pub(crate) async fn insert_server( protocol_version: Set(1), features: Set("[]".to_string()), last_remote_addr: Set(None), - fingerprint: Set(None), created_at: Set(now), updated_at: Set(now), }; diff --git a/crates/server/tests/public_status_gating.rs b/crates/server/tests/public_status_gating.rs index 575e5ac2..e86b4460 100644 --- a/crates/server/tests/public_status_gating.rs +++ b/crates/server/tests/public_status_gating.rs @@ -113,7 +113,6 @@ async fn insert_minimal_server(db: &sea_orm::DatabaseConnection, id: &str) { protocol_version: Set(1), features: Set("[]".to_string()), last_remote_addr: Set(None), - fingerprint: Set(None), created_at: Set(now), updated_at: Set(now), } diff --git a/crates/server/tests/public_status_ip_quality_redaction.rs b/crates/server/tests/public_status_ip_quality_redaction.rs index 5cfb6d5d..f5134895 100644 --- a/crates/server/tests/public_status_ip_quality_redaction.rs +++ b/crates/server/tests/public_status_ip_quality_redaction.rs @@ -146,7 +146,6 @@ async fn insert_minimal_server(db: &sea_orm::DatabaseConnection, id: &str) { protocol_version: Set(1), features: Set("[]".to_string()), last_remote_addr: Set(None), - fingerprint: Set(None), created_at: Set(now), updated_at: Set(now), } diff --git a/crates/server/tests/public_status_redaction.rs b/crates/server/tests/public_status_redaction.rs index 4646066d..34b8dc8e 100644 --- a/crates/server/tests/public_status_redaction.rs +++ b/crates/server/tests/public_status_redaction.rs @@ -136,7 +136,6 @@ async fn insert_server_with_ips( protocol_version: Set(1), features: Set("[]".to_string()), last_remote_addr: Set(None), - fingerprint: Set(None), created_at: Set(now), updated_at: Set(now), } diff --git a/crates/server/tests/public_status_redaction_authenticated.rs b/crates/server/tests/public_status_redaction_authenticated.rs index 928bf499..b8c2eabd 100644 --- a/crates/server/tests/public_status_redaction_authenticated.rs +++ b/crates/server/tests/public_status_redaction_authenticated.rs @@ -134,7 +134,6 @@ async fn insert_server_with_ips( protocol_version: Set(1), features: Set("[]".to_string()), last_remote_addr: Set(None), - fingerprint: Set(None), created_at: Set(now), updated_at: Set(now), } diff --git a/crates/server/tests/public_status_scope.rs b/crates/server/tests/public_status_scope.rs index a1d02717..d752d34b 100644 --- a/crates/server/tests/public_status_scope.rs +++ b/crates/server/tests/public_status_scope.rs @@ -123,7 +123,6 @@ async fn insert_server_with_hidden( protocol_version: Set(1), features: Set("[]".to_string()), last_remote_addr: Set(None), - fingerprint: Set(None), created_at: Set(now), updated_at: Set(now), } diff --git a/crates/server/tests/router_server_crud.rs b/crates/server/tests/router_server_crud.rs index 56fb55b0..3de8a672 100644 --- a/crates/server/tests/router_server_crud.rs +++ b/crates/server/tests/router_server_crud.rs @@ -5,7 +5,6 @@ //! Covered: POST /api/servers (create + enrollment), GET /api/servers (list), //! GET /api/servers/{id} (detail + 404), PUT /api/servers/{id} (update + 404), //! DELETE /api/servers/{id} (delete + 404), POST /api/servers/batch-delete, -//! POST /api/servers/{id}/recover, POST /api/servers/{id}/regenerate-code, //! plus authZ (member -> 403 on admin writes, reads OK), unauth -> 401, and //! validation (empty name -> 400, bad tags -> 422). //! @@ -29,7 +28,10 @@ async fn create_server_returns_id_and_enrollment() { let resp = admin .post(format!("{}/api/servers", base_url)) - .json(&json!({ "name": "create-host" })) + .json(&json!({ + "onboarding_request_id": uuid::Uuid::new_v4().to_string(), + "name": "create-host" + })) .send() .await .unwrap(); @@ -59,6 +61,7 @@ async fn create_server_with_tags_and_metadata() { let resp = admin .post(format!("{}/api/servers", base_url)) .json(&json!({ + "onboarding_request_id": uuid::Uuid::new_v4().to_string(), "name": "meta-host", "tags": ["web", "db"], "remark": "primary", @@ -100,7 +103,10 @@ async fn create_server_empty_name_400() { let resp = admin .post(format!("{}/api/servers", base_url)) - .json(&json!({ "name": " " })) + .json(&json!({ + "onboarding_request_id": uuid::Uuid::new_v4().to_string(), + "name": " " + })) .send() .await .unwrap(); @@ -116,7 +122,11 @@ async fn create_server_bad_tags_422() { let resp = admin .post(format!("{}/api/servers", base_url)) - .json(&json!({ "name": "bad-tag-host", "tags": ["bad space"] })) + .json(&json!({ + "onboarding_request_id": uuid::Uuid::new_v4().to_string(), + "name": "bad-tag-host", + "tags": ["bad space"] + })) .send() .await .unwrap(); @@ -133,7 +143,11 @@ async fn create_server_too_many_tags_422() { let too_many: Vec<String> = (0..9).map(|i| format!("t{i}")).collect(); let resp = admin .post(format!("{}/api/servers", base_url)) - .json(&json!({ "name": "many-tag-host", "tags": too_many })) + .json(&json!({ + "onboarding_request_id": uuid::Uuid::new_v4().to_string(), + "name": "many-tag-host", + "tags": too_many + })) .send() .await .unwrap(); @@ -150,7 +164,10 @@ async fn create_server_member_forbidden() { let resp = member .post(format!("{}/api/servers", base_url)) - .json(&json!({ "name": "nope" })) + .json(&json!({ + "onboarding_request_id": uuid::Uuid::new_v4().to_string(), + "name": "nope" + })) .send() .await .unwrap(); @@ -165,7 +182,10 @@ async fn create_server_unauthenticated_401() { let resp = anon .post(format!("{}/api/servers", base_url)) - .json(&json!({ "name": "nope" })) + .json(&json!({ + "onboarding_request_id": uuid::Uuid::new_v4().to_string(), + "name": "nope" + })) .send() .await .unwrap(); @@ -635,189 +655,3 @@ async fn batch_delete_unauthenticated_401() { .unwrap(); assert_eq!(resp.status(), 401); } - -// --------------------------------------------------------------------------- -// POST /api/servers/{id}/recover — recover enrollment (admin write) -// --------------------------------------------------------------------------- - -// Recover on a freshly created (pending) server is rejected with 400 — a pending -// server should use regenerate-code instead. -#[tokio::test] -async fn recover_pending_server_400() { - let (base_url, _tmp) = start_test_server().await; - let admin = http_client(); - login_admin(&admin, &base_url).await; - let server_id = create_server(&admin, &base_url, "recover-pending-host").await; - - let resp = admin - .post(format!("{}/api/servers/{}/recover", base_url, server_id)) - .json(&json!({ "revoke_immediately": false })) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 400, "recover on a pending server is rejected"); -} - -// Recover on a non-existent server returns 404. -#[tokio::test] -async fn recover_missing_server_404() { - let (base_url, _tmp) = start_test_server().await; - let admin = http_client(); - login_admin(&admin, &base_url).await; - - let resp = admin - .post(format!("{}/api/servers/{}/recover", base_url, "no-such-server")) - .json(&json!({ "revoke_immediately": false })) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 404, "unknown server id yields 404"); -} - -// POST recover is admin-only: a member gets 403. -#[tokio::test] -async fn recover_member_forbidden() { - let (base_url, _tmp) = start_test_server().await; - let admin = http_client(); - login_admin(&admin, &base_url).await; - let server_id = create_server(&admin, &base_url, "recover-authz-host").await; - let member = login_as_new_user(&admin, &base_url, "recover_member", "member").await; - - let resp = member - .post(format!("{}/api/servers/{}/recover", base_url, server_id)) - .json(&json!({ "revoke_immediately": false })) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 403, "members may not recover servers"); -} - -// POST recover requires authentication. -#[tokio::test] -async fn recover_unauthenticated_401() { - let (base_url, _tmp) = start_test_server().await; - let admin = http_client(); - login_admin(&admin, &base_url).await; - let server_id = create_server(&admin, &base_url, "recover-unauth-host").await; - - let anon = http_client(); - let resp = anon - .post(format!("{}/api/servers/{}/recover", base_url, server_id)) - .json(&json!({ "revoke_immediately": false })) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 401); -} - -// --------------------------------------------------------------------------- -// POST /api/servers/{id}/regenerate-code — regenerate enrollment (admin write) -// --------------------------------------------------------------------------- - -// Happy path: regenerate on a pending server mints a fresh enrollment, replacing -// the outstanding one (last-writer-wins when no expected id is supplied). -#[tokio::test] -async fn regenerate_code_pending_server_mints_new() { - let (base_url, _tmp) = start_test_server().await; - let admin = http_client(); - login_admin(&admin, &base_url).await; - - // Create returns the initial enrollment id; capture it to verify rotation. - let create_body: Value = admin - .post(format!("{}/api/servers", base_url)) - .json(&json!({ "name": "regen-host" })) - .send() - .await - .unwrap() - .json() - .await - .unwrap(); - let server_id = create_body["data"]["server_id"].as_str().unwrap().to_string(); - let original_enrollment_id = create_body["data"]["enrollment"]["id"].as_str().unwrap().to_string(); - - let resp = admin - .post(format!("{}/api/servers/{}/regenerate-code", base_url, server_id)) - .json(&json!({})) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 200); - let body: Value = resp.json().await.unwrap(); - let enrollment = &body["data"]["enrollment"]; - let new_code = enrollment["code"].as_str().expect("regenerate must return a fresh code"); - assert!(!new_code.is_empty(), "regenerated code must be non-empty"); - assert_ne!( - enrollment["id"].as_str(), - Some(original_enrollment_id.as_str()), - "regenerate must mint a fresh enrollment id" - ); -} - -// Optimistic concurrency: a stale expected_enrollment_id is rejected with 409. -#[tokio::test] -async fn regenerate_code_stale_expected_id_409() { - let (base_url, _tmp) = start_test_server().await; - let admin = http_client(); - login_admin(&admin, &base_url).await; - let server_id = create_server(&admin, &base_url, "regen-cas-host").await; - - let resp = admin - .post(format!("{}/api/servers/{}/regenerate-code", base_url, server_id)) - .json(&json!({ "expected_enrollment_id": "stale-id-that-does-not-match" })) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 409, "expected_enrollment_id mismatch yields 409"); -} - -// Regenerate on a non-existent server returns 404. -#[tokio::test] -async fn regenerate_code_missing_server_404() { - let (base_url, _tmp) = start_test_server().await; - let admin = http_client(); - login_admin(&admin, &base_url).await; - - let resp = admin - .post(format!("{}/api/servers/{}/regenerate-code", base_url, "no-such-server")) - .json(&json!({})) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 404, "unknown server id yields 404"); -} - -// POST regenerate-code is admin-only: a member gets 403. -#[tokio::test] -async fn regenerate_code_member_forbidden() { - let (base_url, _tmp) = start_test_server().await; - let admin = http_client(); - login_admin(&admin, &base_url).await; - let server_id = create_server(&admin, &base_url, "regen-authz-host").await; - let member = login_as_new_user(&admin, &base_url, "regen_member", "member").await; - - let resp = member - .post(format!("{}/api/servers/{}/regenerate-code", base_url, server_id)) - .json(&json!({})) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 403, "members may not regenerate enrollment codes"); -} - -// POST regenerate-code requires authentication. -#[tokio::test] -async fn regenerate_code_unauthenticated_401() { - let (base_url, _tmp) = start_test_server().await; - let admin = http_client(); - login_admin(&admin, &base_url).await; - let server_id = create_server(&admin, &base_url, "regen-unauth-host").await; - - let anon = http_client(); - let resp = anon - .post(format!("{}/api/servers/{}/regenerate-code", base_url, server_id)) - .json(&json!({})) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 401); -} diff --git a/crates/server/tests/router_server_extra.rs b/crates/server/tests/router_server_extra.rs index b1c17542..70eaff89 100644 --- a/crates/server/tests/router_server_extra.rs +++ b/crates/server/tests/router_server_extra.rs @@ -2,22 +2,10 @@ //! that cover branches `router_server_crud.rs` left untested. //! //! `router_server_crud.rs` already covers the basic create/list/get/update/ -//! delete/batch-delete happy paths plus their authZ (401/403) and validation -//! (400/422) arms, and the recover/regenerate error arms (pending->400, -//! stale-CAS->409, missing->404, member->403, unauth->401). This file targets -//! the remaining reachable branches: -//! -//! - POST /servers/{id}/recover happy path on an ENROLLED server -//! (revoke_immediately = false) — mints a fresh bound enrollment. -//! - POST /servers/{id}/recover with revoke_immediately = true — clears the -//! server token in the same tx (DB side effect: has_token -> false) and -//! kicks the live agent connection. -//! - POST /servers/{id}/recover 409 — an outstanding enrollment already -//! exists (recover never auto-supersedes). -//! - POST /servers/{id}/regenerate-code CAS pass — expected_enrollment_id -//! matches the current outstanding enrollment -> 200 with a rotated id. -//! - POST /servers/{id}/regenerate-code on an ENROLLED server -> 400 -//! ("not pending; use recover instead"). +//! delete/batch-delete happy paths plus their authZ and validation arms. The +//! Agent Authority lifecycle has focused coverage in +//! `agent_registration_integration.rs`; this file targets the remaining +//! general Server branches: //! - POST /servers/batch-delete with a MIX of known + unknown ids — partial //! delete count, and the known one is actually gone. //! - PUT /servers/{id} group MOVE (group A -> group B), not just assign+clear. @@ -29,19 +17,14 @@ //! (mock agent connected + SystemInfo handshake): is the row marked online //! via populated agent_local_capabilities / effective_capabilities, and is //! has_token = true after enrollment. -//! -//! Skipped (NOTE): a clean "recover with revoke_immediately = true returns the -//! server to a verifiable reconnect" loop is not asserted end-to-end — the post- -//! commit `remove_connection` kick is observed indirectly via has_token = false -//! and a re-list. There is no HTTP surface to assert the WS was dropped. mod common; use common::{ - connect_agent, create_server, http_client, login_admin, login_as_new_user, recv_agent_text, - register_agent, send_system_info, start_test_server, AgentSink, + AgentSink, connect_agent, create_server, http_client, login_admin, login_as_new_user, + recv_agent_text, register_agent, send_system_info, start_test_server, }; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use serverbee_common::constants::CAP_DEFAULT; // --------------------------------------------------------------------------- @@ -75,212 +58,6 @@ async fn bring_agent_online( (sink, drain) } -// --------------------------------------------------------------------------- -// POST /api/servers/{id}/recover — happy + side-effect branches -// --------------------------------------------------------------------------- - -// Recover on an already-enrolled server (token_hash IS NOT NULL) with -// revoke_immediately = false mints a fresh bound enrollment and leaves the -// token intact (has_token stays true). -#[tokio::test] -async fn recover_enrolled_server_mints_enrollment_keeps_token() { - let (base_url, _tmp) = start_test_server().await; - let admin = http_client(); - // register_agent logs `admin` in and enrolls a server (token set, the - // create-time enrollment is consumed during registration). - let (server_id, _token) = register_agent(&admin, &base_url).await; - - // Sanity: the enrolled server has a token and no outstanding enrollment. - let before: Value = admin - .get(format!("{}/api/servers/{}", base_url, server_id)) - .send() - .await - .unwrap() - .json() - .await - .unwrap(); - assert_eq!(before["data"]["has_token"].as_bool(), Some(true)); - assert!(before["data"]["outstanding_enrollment"].is_null(), "no outstanding before recover"); - - let resp = admin - .post(format!("{}/api/servers/{}/recover", base_url, server_id)) - .json(&json!({ "revoke_immediately": false })) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 200, "recover on an enrolled server should succeed"); - let body: Value = resp.json().await.unwrap(); - let enrollment = &body["data"]["enrollment"]; - assert!( - enrollment["code"].as_str().is_some_and(|s| !s.is_empty()), - "recover must return a fresh plaintext code" - ); - assert!(enrollment["code_prefix"].as_str().is_some()); - - // Token is untouched (revoke_immediately = false), and the new bound - // enrollment now surfaces as outstanding on the detail DTO. - let after: Value = admin - .get(format!("{}/api/servers/{}", base_url, server_id)) - .send() - .await - .unwrap() - .json() - .await - .unwrap(); - assert_eq!(after["data"]["has_token"].as_bool(), Some(true), "token preserved"); - assert_eq!( - after["data"]["outstanding_enrollment"]["id"].as_str(), - enrollment["id"].as_str(), - "the minted enrollment is the outstanding one" - ); -} - -// Recover with revoke_immediately = true clears the server token in the same -// transaction (DB side effect: has_token flips to false) so the server returns -// to pending until the new code is consumed. -#[tokio::test] -async fn recover_revoke_immediately_clears_token() { - let (base_url, _tmp) = start_test_server().await; - let admin = http_client(); - let (server_id, _token) = register_agent(&admin, &base_url).await; - - let resp = admin - .post(format!("{}/api/servers/{}/recover", base_url, server_id)) - .json(&json!({ "revoke_immediately": true })) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 200, "recover with revoke should succeed"); - - // The token was cleared inside the recover transaction. - let after: Value = admin - .get(format!("{}/api/servers/{}", base_url, server_id)) - .send() - .await - .unwrap() - .json() - .await - .unwrap(); - assert_eq!( - after["data"]["has_token"].as_bool(), - Some(false), - "revoke_immediately must clear the server token (back to pending)" - ); - // A new bound enrollment is outstanding for the (now pending) server. - assert!( - after["data"]["outstanding_enrollment"].is_object(), - "a fresh outstanding enrollment exists after revoke-recover" - ); -} - -// Recover never auto-supersedes: a second recover while an enrollment is still -// outstanding is rejected with 409. -#[tokio::test] -async fn recover_with_outstanding_enrollment_409() { - let (base_url, _tmp) = start_test_server().await; - let admin = http_client(); - let (server_id, _token) = register_agent(&admin, &base_url).await; - - // First recover (revoke = false) mints an outstanding enrollment. - let first = admin - .post(format!("{}/api/servers/{}/recover", base_url, server_id)) - .json(&json!({ "revoke_immediately": false })) - .send() - .await - .unwrap(); - assert_eq!(first.status(), 200, "first recover should succeed"); - - // Second recover sees the still-outstanding enrollment and refuses. - let second = admin - .post(format!("{}/api/servers/{}/recover", base_url, server_id)) - .json(&json!({ "revoke_immediately": false })) - .send() - .await - .unwrap(); - assert_eq!( - second.status(), - 409, - "recover must 409 while an enrollment is still outstanding" - ); -} - -// --------------------------------------------------------------------------- -// POST /api/servers/{id}/regenerate-code — CAS pass + not-pending branches -// --------------------------------------------------------------------------- - -// CAS pass: when expected_enrollment_id matches the current outstanding -// enrollment exactly, regenerate proceeds and rotates to a fresh id. -#[tokio::test] -async fn regenerate_code_matching_expected_id_rotates() { - let (base_url, _tmp) = start_test_server().await; - let admin = http_client(); - login_admin(&admin, &base_url).await; - - // Capture the create-time enrollment id; it is the current outstanding one. - let create_body: Value = admin - .post(format!("{}/api/servers", base_url)) - .json(&json!({ "name": "regen-cas-pass-host" })) - .send() - .await - .unwrap() - .json() - .await - .unwrap(); - let server_id = create_body["data"]["server_id"].as_str().unwrap().to_string(); - let outstanding_id = create_body["data"]["enrollment"]["id"].as_str().unwrap().to_string(); - - let resp = admin - .post(format!("{}/api/servers/{}/regenerate-code", base_url, server_id)) - .json(&json!({ "expected_enrollment_id": outstanding_id })) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 200, "matching expected id should pass the CAS check"); - let body: Value = resp.json().await.unwrap(); - let new_id = body["data"]["enrollment"]["id"].as_str().unwrap(); - assert_ne!(new_id, outstanding_id, "regenerate must mint a fresh enrollment id"); - assert!( - body["data"]["enrollment"]["code"].as_str().is_some_and(|s| !s.is_empty()), - "regenerate must return a non-empty code" - ); - - // The detail DTO now reports the rotated enrollment as outstanding. - let detail: Value = admin - .get(format!("{}/api/servers/{}", base_url, server_id)) - .send() - .await - .unwrap() - .json() - .await - .unwrap(); - assert_eq!( - detail["data"]["outstanding_enrollment"]["id"].as_str(), - Some(new_id), - "the rotated enrollment supersedes the previous one" - ); -} - -// regenerate-code on an already-enrolled server (token_hash IS NOT NULL) is -// rejected with 400 — that path must use recover instead. -#[tokio::test] -async fn regenerate_code_enrolled_server_400() { - let (base_url, _tmp) = start_test_server().await; - let admin = http_client(); - let (server_id, _token) = register_agent(&admin, &base_url).await; - - let resp = admin - .post(format!("{}/api/servers/{}/regenerate-code", base_url, server_id)) - .json(&json!({})) - .send() - .await - .unwrap(); - assert_eq!( - resp.status(), - 400, - "regenerate-code on an enrolled (non-pending) server is rejected" - ); -} - // --------------------------------------------------------------------------- // POST /api/servers/batch-delete — partial (known + unknown) ids // --------------------------------------------------------------------------- diff --git a/crates/server/tests/router_server_extra2.rs b/crates/server/tests/router_server_extra2.rs index 226d516a..30a3bc67 100644 --- a/crates/server/tests/router_server_extra2.rs +++ b/crates/server/tests/router_server_extra2.rs @@ -2,7 +2,7 @@ //! //! `router_server_crud.rs` and `router_server_extra.rs` already cover the //! create/list/get/update/delete/batch-delete happy paths, their authZ -//! (401/403) and validation (400/422) arms, the recover / regenerate-code +//! (401/403) and validation (400/422) arms, the Agent Authority lifecycle //! success + error arms, the group assign/clear/move + weight/hidden/geo edge //! updates, and the ONLINE-server DTO mapping. This file targets the branches //! those two leave uncovered: diff --git a/crates/server/tests/router_smoke.rs b/crates/server/tests/router_smoke.rs index 9f10db86..b35faa60 100644 --- a/crates/server/tests/router_smoke.rs +++ b/crates/server/tests/router_smoke.rs @@ -40,7 +40,10 @@ async fn harness_login_create_server_and_member_role() { let member = login_as_new_user(&client, &base_url, "member1", "member").await; let resp = member .post(format!("{}/api/servers", base_url)) - .json(&serde_json::json!({ "name": "nope" })) + .json(&serde_json::json!({ + "onboarding_request_id": uuid::Uuid::new_v4().to_string(), + "name": "nope" + })) .send() .await .unwrap(); diff --git a/deploy/install.sh b/deploy/install.sh index 97d739af..6daf2868 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -1674,8 +1674,9 @@ services: - SERVERBEE_ADMIN__USERNAME=admin - SERVERBEE_AUTH__SECURE_COOKIE=false restart: unless-stopped + # Alpine BusyBox wget prefers ::1 for localhost, while the server listens on IPv4. healthcheck: - test: ["CMD", "wget", "--spider", "-q", "http://localhost:9527/healthz"] + test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:9527/healthz"] interval: 30s timeout: 5s retries: 3 @@ -1737,7 +1738,6 @@ services: volumes: - /proc:/host/proc:ro - /sys:/host/sys:ro - - /etc/machine-id:/etc/machine-id:ro - ${conf_dir}:/etc/serverbee restart: unless-stopped YAML diff --git a/deploy/railway/README.md b/deploy/railway/README.md index 67c528d7..c18d20d3 100644 --- a/deploy/railway/README.md +++ b/deploy/railway/README.md @@ -146,7 +146,7 @@ After deployment, configure your agents to connect: ```bash SERVERBEE_SERVER_URL=https://your-railway-app.up.railway.app -SERVERBEE_ENROLLMENT_CODE=<one-time code from Settings> +SERVERBEE_ENROLLMENT_CODE=<one-time code from Add Server> ``` -Sign in to the deployed server as an admin, open **Settings**, and generate a one-time enrollment code. The code is single-use and short-lived (default 10 min) and is consumed on the agent's first successful registration; mint a fresh one for each new agent. +Sign in to the deployed server as an admin and choose **Add Server**. ServerBee atomically creates the Server profile and a bound enrollment offer, then shows its install command once. The offer is single-use and short-lived (default 10 min). diff --git a/docs/adr/0004-agent-authority-owns-enrollment-lifecycle.md b/docs/adr/0004-agent-authority-owns-enrollment-lifecycle.md new file mode 100644 index 00000000..2e292598 --- /dev/null +++ b/docs/adr/0004-agent-authority-owns-enrollment-lifecycle.md @@ -0,0 +1,187 @@ +# ADR-0004: Agent Authority owns the enrollment lifecycle + +## Status + +Accepted (2026-07-13) + +## Context + +Agent enrollment knowledge was split across a shallow `EnrollmentService`, the +Server and Agent HTTP adapters, `AgentManager`, and best-effort audit writes. +The module exposed storage and hashing primitives while callers owned the hard +parts: pending-versus-enrolled checks, offer replacement, run-token mutation, +WebSocket eviction, transaction order, and audit detail. + +That split permitted contradictory behaviour: + +- expired offers failed verification but still blocked or appeared as the + current offer; +- missing optimistic-concurrency input silently enabled last-writer-wins; +- a consumed offer could later also be marked revoked; +- graceful re-enrollment replaced the stored token without immediately fencing + the previous live connection; +- WebSocket token validation raced final connection admission; +- operator-facing token rotation created a valid credential that no Agent + necessarily possessed; +- a committed enrollment whose HTTP response was lost left the Server claimed + by a run token the Agent never received; +- lifecycle audit writes happened after commit and their failures were ignored; +- a machine fingerprint was collected and stored despite no longer identifying, + deduplicating, authorizing, or otherwise affecting an Agent. + +The deletion test confirms the module is shallow: deleting it moves a few +queries and hashes, while the lifecycle complexity remains spread across every +caller. + +## Decision + +Create a deep **Agent Authority** module. Its external seam is a typed use-case +facade; a private state-machine kernel and persistence implementation sit behind +that interface. + +The facade has operation-specific inputs, receipts, and rejection types for the +following use cases: + +- claim a Server identity using an Enrollment code and an Agent-generated run + token; +- issue an offer for an Unclaimed Server with no Outstanding offer; +- begin Graceful or Emergency re-enrollment; +- replace one exact Outstanding offer; +- revoke one exact Outstanding offer; +- revoke Agent authority without creating an offer; +- inspect current Agent authority and offer facts; +- read Agent authority history. + +The facade also owns connection authorization through a two-stage admission +interface. Preflight may reject a bad token before WebSocket upgrade, but it is +not authorization. The returned pending admission must perform final token +revalidation while holding the same per-Server lock used by authority +transitions, then register the connection with `AgentManager`. Callers cannot +register an Agent connection without crossing that final admission seam. + +Public methods never accept a database transaction, token hash, enrollment +entity, or `AgentManager`. Axum adapters translate transport DTOs into typed +inputs and translate typed results back into HTTP responses; they do not edit +authority rows or orchestrate side effects. + +### State model + +Agent authority and Enrollment offer state are orthogonal: + +- Agent authority is **Claimed** when a valid Agent run token exists and + **Unclaimed** when it does not. Online/offline remains an `AgentManager` + connection fact. +- An Enrollment offer begins **Outstanding** and reaches exactly one immutable + outcome: **Consumed**, **Revoked**, **Replaced**, or **Expired**. +- At most one offer may be Outstanding for a Server. +- Replacement requires the exact current offer identity. Missing or stale + identity conflicts; there is no last-writer-wins variant. +- Expiry takes effect at `expires_at` without a background task. The next write + may materialize the Expired outcome before proceeding. + +Graceful re-enrollment leaves existing authority intact while its offer is +Outstanding. Consuming the offer replaces the run token and fences the previous +connection. Emergency re-enrollment removes authority and fences the connection +when the offer is issued. Agent authority revocation removes authority and +fences the connection without creating an offer. + +Operator-facing run-token rotation is removed. Authority can be restored only +through enrollment. + +### Credential ownership + +The Agent generates and durably stages a high-entropy run token before claiming +an offer. The claim request carries that token, and Agent Authority atomically +stores its hash while consuming the offer. The Server never returns the +plaintext run token. + +After an ambiguous network result, the Agent tries its already-staged token on +the WebSocket. Success proves that the claim committed; rejection permits a +retry with the same still-usable Enrollment code and proposed token. A process +restart follows the same path because the proposed token was staged before the +request. + +This is a hard protocol cut. A claim without an Agent-generated run token is +rejected; there is no legacy adapter that generates and returns one. Existing +Agents with valid run tokens remain connected, but any future enrollment +requires an Agent implementing the new claim protocol. + +Agent fingerprint generation, transport, validation, persistence, tests, and +current documentation are removed. Historical specifications and changelog +entries remain historical records. + +### Transactions, history, and fencing + +Every durable transition and its secret-free **Agent authority event** commit +in one SQLite transaction. An event records the actor, request source, Server +snapshot, related offer, mode, outcome, and time. Generic best-effort audit logs +are not the lifecycle source of truth. + +Credential hashes and Enrollment offer rows are deleted with their Server. +Agent authority events survive Server deletion and retain no usable secret; +they are removed only by an explicit audit-retention purge. + +Connection fencing is a hard invariant, not a post-commit courtesy. An +authority transition and final WebSocket admission serialize through the same +per-Server lock. A transition returns success only after its durable mutation, +authority event, and runtime fence complete. Failure is fail-closed: a valid +Agent may be briefly disconnected if a later database operation fails, but its +unchanged token permits reconnection. + +### Server onboarding + +**Server onboarding** remains a separate deep module because it owns Server +identity creation, profile and tag persistence, default monitoring +configuration, and the initial Enrollment offer. Its external interface is +request-idempotent: the same request identity and normalized input return the +same Server; reusing that identity with different input conflicts. + +Server onboarding composes initial-offer creation through a crate-private +transaction seam so the Server and offer remain atomic. No database transaction +appears in either module's external interface. + +SQLite is a local-substitutable dependency and is exercised through the real +test database. `AgentManager` is an in-process dependency and is exercised with +real channels. Neither dependency receives a hypothetical port or mock adapter. + +## Alternatives considered + +- **One generic `transition(AuthorityIntent)` entry point**: minimizes method + count but creates broad command and receipt enums. Callers must understand + irrelevant variants, weakening locality at the external seam. +- **A sealed generic command protocol**: preserves typed receipts while adding + new commands without adding methods, but associated types, sealed traits, and + generic error wrappers cost more discoverability and AI navigability than the + lifecycle's expected rate of change justifies. +- **A mutable aggregate handle (`load`, mutate, `commit`)**: naturally groups + state but leaks lock and transaction lifetime, ordering, and commit + responsibility to callers. It recreates the existing orchestration problem. +- **Keep route orchestration and only expand `EnrollmentService`**: leaves + connection fencing, run-token transitions, and authority history outside the + module, so the deletion test still fails. +- **Retain a legacy server-generated-token claim adapter**: eases rollout but + preserves the ambiguous-response failure and requires two credential + contracts. The enrollment protocol intentionally makes a clean cut instead. +- **Consume the machine fingerprint for identity, token binding, or anomaly + detection**: the value is self-reported, spoofable, and has inconsistent + platform semantics. No current product behaviour justifies retaining it. + +## Consequences + +- HTTP and WebSocket adapters become thin and tests move to the Agent Authority + interface. Primitive `EnrollmentService` tests and duplicated route + orchestration tests are deleted once equivalent interface coverage exists. +- The Server schema needs explicit offer outcomes, immutable authority events, + request-idempotent onboarding storage, and removal of the fingerprint column. + Migrations remain forward-only. +- The Agent registration request and local persistence flow change in concert + with the Server. Install and re-enrollment flows must use the new Agent binary. +- Web and iOS must send exact offer identities for replacement and use Agent + re-enrollment terminology. `recover` is not a domain term; if an old route + spelling is temporarily retained, it is only an HTTP adapter to the same + facade. +- Final WebSocket admission performs an additional token validation. This cost + is accepted to close the authorization race. +- The module's direct SQLite and `AgentManager` dependencies favor the real + architecture over speculative substitutability. If a second runtime or + remote implementation appears, that concrete need can introduce a new seam. diff --git a/scripts/dev-full.sh b/scripts/dev-full.sh index f897bb24..71b79f0e 100755 --- a/scripts/dev-full.sh +++ b/scripts/dev-full.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -# Start server + web dev, and print agent startup command with a freshly minted one-time enrollment code. +# Start server + web dev, and print an agent command from a fresh idempotent Server onboarding. ADMIN_PASS="admin123" SERVER_URL="http://localhost:9527" @@ -16,7 +16,7 @@ SERVER_PID=$! # Wait for server to be ready echo "Waiting for server..." -for i in $(seq 1 30); do +for _ in $(seq 1 30); do if curl -s "$SERVER_URL/healthz" > /dev/null 2>&1; then break fi @@ -31,21 +31,23 @@ fi echo "Server is ready at $SERVER_URL" -# Login and mint a one-time enrollment code +# Login and atomically create a development Server plus its bound enrollment offer. COOKIE_JAR=$(mktemp) curl -s -c "$COOKIE_JAR" -X POST "$SERVER_URL/api/auth/login" \ -H 'Content-Type: application/json' \ -d "{\"username\":\"admin\",\"password\":\"$ADMIN_PASS\"}" > /dev/null -CODE=$(curl -s -b "$COOKIE_JAR" -X POST "$SERVER_URL/api/agent/enrollments" \ - -H 'Content-Type: application/json' -d '{}' \ +ONBOARDING_REQUEST_ID=$(uuidgen) +CODE=$(curl -s -b "$COOKIE_JAR" -X POST "$SERVER_URL/api/servers" \ + -H 'Content-Type: application/json' \ + -d "{\"onboarding_request_id\":\"$ONBOARDING_REQUEST_ID\",\"name\":\"Dev Agent\"}" \ | grep -o '"code":"[^"]*"' | cut -d'"' -f4) rm -f "$COOKIE_JAR" echo "" echo "==========================================" echo " To start the agent, run in another terminal" -echo " (this enrollment code is single-use and freshly minted each run):" +echo " (this Server-bound enrollment offer is single-use and freshly created):" echo "" echo " SERVERBEE_ENROLLMENT_CODE=\"$CODE\" make agent-dev" echo "" diff --git a/tests/README.md b/tests/README.md index 0997e250..694a8458 100644 --- a/tests/README.md +++ b/tests/README.md @@ -12,12 +12,13 @@ cargo build --workspace # 3. 启动 Server(设置管理员密码,开发环境关闭 secure cookie) SERVERBEE_ADMIN__PASSWORD=admin123 SERVERBEE_AUTH__SECURE_COOKIE=false cargo run -p serverbee-server & -# 4. 铸造一次性 enrollment code(登录后调用 API;也可在服务端 UI 设置页生成) +# 4. 原子创建 Server 和绑定的 enrollment offer curl -s -c /tmp/sb-cookies.txt -X POST http://localhost:9527/api/auth/login \ -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin123"}' -curl -s -b /tmp/sb-cookies.txt -X POST http://localhost:9527/api/agent/enrollments \ - -H 'Content-Type: application/json' -d '{}' -# 返回 {"data":{"id":"...","code":"<enrollment_code>","expires_at":...}}(单次使用,默认 10 分钟过期) +curl -s -b /tmp/sb-cookies.txt -X POST http://localhost:9527/api/servers \ + -H 'Content-Type: application/json' \ + -d "{\"onboarding_request_id\":\"$(uuidgen)\",\"name\":\"Local Agent\"}" +# 返回 data.server_id 和 data.enrollment(明文 code 仅本次返回,默认 10 分钟过期) # 5. 启动 Agent(server_url 是 HTTP 基础地址,不是 WS 路径) SERVERBEE_SERVER_URL="http://127.0.0.1:9527" SERVERBEE_ENROLLMENT_CODE="<enrollment_code>" cargo run -p serverbee-agent & @@ -37,8 +38,9 @@ docker compose up -d | [auth-users.md](auth-users.md) | 认证、用户与安全 | `/login`, `/settings/users`, `/settings/api-keys` | | [dashboard.md](dashboard.md) | 自定义仪表盘 | `/` | | [server-detail.md](server-detail.md) | 服务器列表与详情 | `/servers`, `/servers/:id` | -| [registration-hardening.md](registration-hardening.md) | 注册加固、cleanup 与 enrollment code | `/servers`, `/settings`, Docker agent install | -| [agent-enrollment-smoke.md](agent-enrollment-smoke.md) | 一次性 enrollment code 冒烟测试 | `/api/agent/enrollments`, `/api/agent/register`, `/settings` | +| [registration-hardening.md](registration-hardening.md) | Onboarding 幂等、claim 竞态、offer CAS、WS fencing 与 cleanup | `/servers`, `/api/servers/*/agent-authority` | +| [agent-enrollment-smoke.md](agent-enrollment-smoke.md) | Agent Authority 生命周期冒烟测试 | `/api/servers`, `/api/agent/register`, `/api/servers/*/agent-authority` | +| [manual/agent-reenrollment-e2e.md](manual/agent-reenrollment-e2e.md) | 真实 Linux VPS 的 graceful/emergency 重新接入 | Server 详情、Agent 进程、WebSocket | | [ping-tasks.md](ping-tasks.md) | Ping 探测任务管理 | `/settings/ping-tasks` | | [network-quality.md](network-quality.md) | 网络质量监控 | `/network`, `/network/:id`, `/settings/network-probes` | | [docker.md](docker.md) | Docker 容器监控 | `/servers/:id/docker` | diff --git a/tests/agent-enrollment-smoke.md b/tests/agent-enrollment-smoke.md index e95c66b7..4a63a01b 100644 --- a/tests/agent-enrollment-smoke.md +++ b/tests/agent-enrollment-smoke.md @@ -1,158 +1,112 @@ -# Agent Enrollment Code 冒烟测试 +# Agent Authority 生命周期冒烟测试 -验证「一次性 enrollment code 取代共享 auto_discovery_key」改造的关键路径与安全属性。 -环境与启动参考 [README.md](README.md) 的「启动本地环境」。Server 默认 `http://localhost:9527`,管理员用户名 `admin`。 +验证 Server onboarding、offer 单次消费、Agent 自持 run token、重新接入、精确 offer CAS、authority 吊销和事件留存。环境与启动参考 [README.md](README.md)。 -通过标准:步骤 1–7、9 全部符合预期,步骤 10 端到端闭环成功,步骤 11 UI 正常。步骤 8 为可选耗时项。 - ---- - -## 0. 启动环境 - -```bash -cd <repo-or-worktree-root> -SERVERBEE_ADMIN__PASSWORD=admin123 SERVERBEE_AUTH__SECURE_COOKIE=false cargo run -p serverbee-server -``` - -预期:启动 banner **不再打印** `Auto-discovery key`(旧机制已移除)。未设密码时从 banner 的 -`*** IMPORTANT: Save these now ***` 区块读取 `Admin password`。 - -## 1. 管理员登录 +## 0. 登录并准备变量 ```bash -curl -s -c /tmp/sb.txt -X POST http://localhost:9527/api/auth/login \ +BASE=http://localhost:9527 +COOKIE=/tmp/sb-agent-authority.txt +curl -fsS -c "$COOKIE" -X POST "$BASE/api/auth/login" \ -H 'Content-Type: application/json' \ -d '{"username":"admin","password":"admin123"}' +REQUEST_ID=$(uuidgen) ``` -预期:HTTP 200。 - -## 2. 铸造 enrollment code(golden path) +## 1. 幂等 onboarding ```bash -curl -s -b /tmp/sb.txt -X POST http://localhost:9527/api/agent/enrollments \ - -H 'Content-Type: application/json' -d '{}' +BODY="{\"onboarding_request_id\":\"$REQUEST_ID\",\"name\":\"Lifecycle Smoke\",\"ttl_secs\":600}" +CREATED=$(curl -fsS -b "$COOKIE" -X POST "$BASE/api/servers" \ + -H 'Content-Type: application/json' -d "$BODY") +SERVER_ID=$(printf '%s' "$CREATED" | jq -r '.data.server_id') +OFFER_ID=$(printf '%s' "$CREATED" | jq -r '.data.enrollment.id') +CODE=$(printf '%s' "$CREATED" | jq -r '.data.enrollment.code') +curl -fsS -b "$COOKIE" -X POST "$BASE/api/servers" \ + -H 'Content-Type: application/json' -d "$BODY" | jq . ``` -预期:`{"data":{"id":"...","code":"<43 位>","expires_at":"..."}}`。记录 `CODE` 与 `ID`。 +预期:首次响应 `replayed=false` 并返回一次明文 code;重试响应 `replayed=true`、`enrollment=null`,且 `outstanding_offer.id` 等于 `$OFFER_ID`。相同 request ID 搭配不同输入返回 `409 ONBOARDING_IDEMPOTENCY_CONFLICT`。 -## 3. 注册 agent(消费 code) +## 2. Agent 提议并持有 run token ```bash -curl -s -X POST http://localhost:9527/api/agent/register \ +RUN_TOKEN=$(openssl rand -base64 32 | tr -d '\n') +curl -fsS -X POST "$BASE/api/agent/register" \ -H "Authorization: Bearer $CODE" \ - -H 'Content-Type: application/json' -d '{"fingerprint":""}' -``` - -预期:HTTP 200,返回 `server_id` + `token`。记录二者(`SERVER_ID` / `OLD_TOKEN`)。 - -## 4. 单次性校验(核心安全属性) - -```bash -curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:9527/api/agent/register \ - -H "Authorization: Bearer $CODE" + -H 'Content-Type: application/json' \ + -d "{\"proposed_run_token\":\"$RUN_TOKEN\"}" | jq . ``` -预期:**401**——同一 code 已消费,不可重放。 +预期:只返回 `$SERVER_ID`,不返回 token。再次使用 `$CODE` claim 返回 401。`GET /api/servers/$SERVER_ID/agent-authority` 返回 `status=claimed` 且没有 outstanding offer;`websocat "ws://localhost:9527/api/agent/ws?token=$RUN_TOKEN"` 可以握手。 -## 5. 列表不泄露明文 +## 3. Graceful 重新接入与精确替换 ```bash -curl -s -b /tmp/sb.txt http://localhost:9527/api/agent/enrollments +GRACEFUL=$(curl -fsS -b "$COOKIE" -X POST \ + "$BASE/api/servers/$SERVER_ID/agent-authority/re-enrollment" \ + -H 'Content-Type: application/json' -d '{"mode":"graceful"}') +GRACEFUL_ID=$(printf '%s' "$GRACEFUL" | jq -r '.data.enrollment.id') + +REPLACED=$(curl -fsS -b "$COOKIE" -X POST \ + "$BASE/api/servers/$SERVER_ID/agent-authority/offers/$GRACEFUL_ID/replace") +NEW_OFFER_ID=$(printf '%s' "$REPLACED" | jq -r '.data.enrollment.id') ``` -预期:数组包含该条,仅含 `code_prefix`(8 位)与非空 `consumed_at`;**无** `code` / `code_hash` 字段。 +预期:graceful 后 authority 仍为 `claimed`,旧 run token 仍可连接。替换后 `$GRACEFUL_ID` 进入 `replaced` 终态并返回一次新 code;再次替换旧 ID 返回 409,且不能覆盖 `$NEW_OFFER_ID`。 -## 6. 旧机制确已移除 +先精确吊销当前 offer,为 emergency 场景清空 outstanding 状态: ```bash -# 旧共享 key 注册方式 -curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:9527/api/agent/register \ - -H 'Authorization: Bearer test-key' # 预期 401 -# 旧设置端点:handler 已删除。注意未匹配的 /api/* 会被 SPA fallback -# (.fallback(static_handler)) 兜底返回 200 text/html,这是既有路由行为, -# 不代表旧 API 复活。判据是“不再返回旧 JSON(无 key 字段),而是 HTML”。 -curl -s -b /tmp/sb.txt http://localhost:9527/api/settings/auto-discovery-key \ - | head -c 200 -# 预期:输出是 SPA 的 HTML(<!doctype html ...>),而非 {"data":{"key":...}} -curl -s -b /tmp/sb.txt -o /dev/null -w 'content-type=%{content_type}\n' \ - http://localhost:9527/api/settings/auto-discovery-key -# 预期:content-type=text/html...(非 application/json) +curl -fsS -b "$COOKIE" -X DELETE \ + "$BASE/api/servers/$SERVER_ID/agent-authority/offers/$NEW_OFFER_ID" ``` -## 7. Token 轮换 + 吊销旧 token +## 4. Emergency 重新接入与连接 fencing ```bash -curl -s -b /tmp/sb.txt -X POST http://localhost:9527/api/agent/$SERVER_ID/rotate-token +EMERGENCY=$(curl -fsS -b "$COOKIE" -X POST \ + "$BASE/api/servers/$SERVER_ID/agent-authority/re-enrollment" \ + -H 'Content-Type: application/json' -d '{"mode":"emergency"}') +EMERGENCY_CODE=$(printf '%s' "$EMERGENCY" | jq -r '.data.enrollment.code') +NEW_RUN_TOKEN=$(openssl rand -base64 32 | tr -d '\n') ``` -预期:HTTP 200,返回新 `token` ≠ `OLD_TOKEN`。 +预期:authority 立即变为 `unclaimed`,现有旧 WebSocket 被关闭,旧 `$RUN_TOKEN` 新握手返回 401。随后用 `$EMERGENCY_CODE` 和 `$NEW_RUN_TOKEN` claim,状态回到 `claimed`,新 token 可连接。 -再用旧 token 连 WS 验证吊销。**注意**:`curl` 不带 upgrade 头会被 Axum -`WebSocketUpgrade` extractor 在鉴权前打成 400,无法验证 token——必须用真实 -WS 客户端(如 `websocat`): +## 5. 独立吊销 authority ```bash -# 旧 token:握手应被拒(401) -websocat "ws://localhost:9527/api/agent/ws?token=$OLD_TOKEN" # 预期:连接失败 / 401 -# 新 token:可正常握手 -websocat "ws://localhost:9527/api/agent/ws?token=$NEW_TOKEN" # 预期:连接建立 +curl -fsS -b "$COOKIE" -X DELETE \ + "$BASE/api/servers/$SERVER_ID/agent-authority" | jq . +curl -fsS -b "$COOKIE" "$BASE/api/servers/$SERVER_ID/agent-authority" | jq . ``` -> 此属性已有自动化 e2e 覆盖(`integration.rs` 用真实 WebSocket 客户端断言旧 -> token 握手返回 401);手动冒烟若无 `websocat` 可仅依赖该自动化测试。 +预期:返回 `changed=true`,状态为 `unclaimed`,新 WebSocket 被隔离且没有自动生成 offer。此时可通过 `POST /api/servers/$SERVER_ID/agent-authority/offers` 发出 offer;再用准确 offer ID 删除它。 -## 8. TTL 过期(可选,耗时) +## 6. 事件与删除留存 ```bash -SHORT=$(curl -s -b /tmp/sb.txt -X POST http://localhost:9527/api/agent/enrollments \ - -H 'Content-Type: application/json' -d '{"ttl_secs":2}' | grep -o '"code":"[^"]*"' | cut -d'"' -f4) -sleep 3 -curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:9527/api/agent/register \ - -H "Authorization: Bearer $SHORT" # 预期 401 +curl -fsS -b "$COOKIE" \ + "$BASE/api/agent-authority/events?server_id=$SERVER_ID&limit=100" | jq . +curl -fsS -b "$COOKIE" -X DELETE "$BASE/api/servers/$SERVER_ID" +curl -fsS -b "$COOKIE" \ + "$BASE/api/agent-authority/events?server_id=$SERVER_ID&limit=100" | jq . ``` -## 9. 删除 - -```bash -curl -s -o /dev/null -w '%{http_code}\n' -b /tmp/sb.txt \ - -X DELETE http://localhost:9527/api/agent/enrollments/$ID # 预期 200 -``` - -## 10. 端到端真实 agent(完整闭环) - -1. 按步骤 2 铸造新 code。 -2. 写 `agent.toml`: - - ```toml - server_url = "http://localhost:9527" - enrollment_code = "<新 CODE>" - ``` +预期:历史包含 offer issued/replaced/revoked/consumed、graceful/emergency、authority revoked 和 server deleted 等转换,不含任何明文 code 或 run token;删除 Server 后事件仍可读取。 - 或 `SERVERBEE_SERVER_URL=http://127.0.0.1:9527 SERVERBEE_ENROLLMENT_CODE=<CODE> cargo run -p serverbee-agent`。 -3. 预期:agent 日志出现 `Registered as server_id=...` → `Registration successful`,token 落盘到 `agent.toml`。 -4. 重启 agent:使用已存 token 直连,**不再消费 code**(验证 code 仅首次需要)。 -5. 故意用过期/错误 code 启动:agent 应打印 - `Registration failed: HTTP 401 ... enrollment code ... expired or already used`(验证错误透传)。 +## 7. UI 冒烟 -## 11. UI 冒烟(Settings 页,对应 [registration-hardening.md](registration-hardening.md) RH-5) - -通过 `make web-dev`(或 build 后)访问 `/settings`: - -- 点击「生成 enrollment code」→ 一次性显示 code 与可复制安装命令(含 `--enrollment-code` 与当前 origin)。 -- 列表显示该条(prefix + 状态徽章:active / consumed / expired),删除按钮带确认对话框。 -- 刷新页面后明文 code 不再出现(仅展示一次)。 - ---- +- **Add Server** 在失败后使用相同 onboarding request ID 重试,显式关闭后才生成新 ID。 +- 重放响应不尝试恢复明文,只允许精确替换响应中可见的 outstanding offer。 +- Server 详情分别提供 Graceful、Emergency、精确替换/吊销 offer,以及独立的 Agent Authority 吊销确认。 +- Web 与 iOS 都从 `agent_authority` 判断 claimed/unclaimed 和 outstanding 状态。 ## 自动化回归对照 -以下属性已有自动化测试覆盖(`cargo test -p serverbee-server`),冒烟仅作端到端复核: - -| 属性 | 测试 | -|------|------| -| 单次消费 + 并发抢兑竞态 | `service::enrollment` 单元测试 | -| TTL 过期 / prune | `service::enrollment` 单元测试 | -| 列表 DTO 不含 code/hash | `enrollment_summary_dto_never_exposes_code_or_hash` | -| 注册消费 + 重放拒绝 | `register_flow_consumes_code_single_use` | -| 轮换后旧 token 被 401 拒绝 | `integration.rs` e2e 测试 | +```bash +cargo test -p serverbee-server --test agent_registration_integration +cargo test -p serverbee-server service::agent_authority +cargo test -p serverbee-agent register +``` diff --git a/tests/firewall-block.md b/tests/firewall-block.md index c2b6fde6..9059635d 100644 --- a/tests/firewall-block.md +++ b/tests/firewall-block.md @@ -97,7 +97,7 @@ | V3 | 推送二进制 + 启动隔离测试环境 | `scp target/.../release/serverbee-{server,agent} root@<vps-host>:/opt/serverbee/bin/`;`SERVERBEE_SERVER__DATA_DIR=/tmp/sb-test/data SERVERBEE_SERVER__LISTEN=127.0.0.1:9528 nohup ./bin/serverbee-server` | ✅ | | V4 | 首次启动密码 | server log 输出 `FIRST-RUN ADMIN CREDENTIALS` + 一次性 password | ✅ | | V5 | 完成 onboarding 强制改密 | `POST /api/auth/onboarding {"new_password": "..."}` | ✅ | -| V6 | 创建 enrollment | `POST /api/agent/enrollments {"name":"test-srv"}` | ✅ | +| V6 | 创建 Server + enrollment offer | `POST /api/servers`,携带唯一 `onboarding_request_id` 和 `name` | ✅ | | V7 | 启动测试 Agent | `agent.toml` 指向 `http://127.0.0.1:9528`,`nohup` 启动 | ✅ agent log `WebSocket connected` + `Welcome` | ### 执行结果摘录 diff --git a/tests/general-settings.md b/tests/general-settings.md index 1950722d..c608abc4 100644 --- a/tests/general-settings.md +++ b/tests/general-settings.md @@ -2,75 +2,38 @@ ## 前置条件 -参照 [README.md](README.md) 中的「启动本地环境」部分完成 Server + Agent 启动和登录。 +参照 [README.md](README.md) 启动 Server 并以管理员登录。Agent onboarding 不在 Settings 页进行,入口是 **Add Server**。 ---- +## 一、页面加载与数据源 -## 一、页面加载与渲染(/settings) +| # | 测试场景 | 操作步骤 | 预期结果 | +|---|---------|---------|---------| +| GS-1 | 页面正常加载 | 导航到 `/settings` | 显示 Settings 标题,页面无报错 | +| GS-2 | GeoIP 行 | 查看 Data Sources | 展示安装状态和 Download/Update 操作,并显示 DB-IP attribution | +| GS-3 | ASN 行 | 查看 Data Sources | 展示 ASN 数据源状态与可用操作 | +| GS-4 | About 行 | 查看 About | 显示当前 ServerBee 版本 | +| GS-5 | Member 权限 | 以 member 登录 | 可读取允许的状态,不能执行管理员写操作 | -| # | 测试场景 | 操作步骤 | 预期结果 | 状态 | -|---|---------|---------|---------|------| -| GS-1 | 页面正常加载 | 登录后导航到 `/settings` | 页面加载完成,显示标题 "Settings" | ✅ | -| GS-2 | 侧边栏导航 | 点击侧边栏 "Settings" 链接 | 导航到 `/settings` | ✅ | -| GS-3 | 两个 Card 区域 | 查看页面 | 显示 Auto-Discovery Key 卡片 + GeoIP 卡片 | ✅ | +## 二、API 端点 ---- +| # | 测试场景 | 操作步骤 | 预期结果 | +|---|---------|---------|---------| +| API-1 | 获取系统设置 | `GET /api/settings` | 200,返回站点设置,不包含 Agent 密钥 | +| API-2 | 更新系统设置 | `PUT /api/settings` with `{"site_name":"Test"}` | Admin 返回 200;member 返回 403 | +| API-3 | 数据库备份 | `POST /api/settings/backup` | 200,响应为 SQLite 备份附件 | +| API-4 | 恢复无效文件 | `POST /api/settings/restore` with 非 SQLite 数据 | 422 Unprocessable Entity | +| API-5 | 未认证访问 | 不带凭据调用 `GET /api/settings` | 401 | -## 二、Auto-Discovery Key +## 三、Agent lifecycle 边界 -| # | 测试场景 | 操作步骤 | 预期结果 | 状态 | -|---|---------|---------|---------|------| -| GS-4 | Key 掩码显示 | 加载后查看 key 区域 | Key 默认以 `*` 掩码显示 | ✅ 显示 43 个 `*` | -| GS-5 | 显示 Key | 点击眼睛图标("Show key") | Key 明文显示,按钮变为 "Hide key" | ✅ | -| GS-6 | 隐藏 Key | 再次点击("Hide key") | Key 恢复掩码,按钮变为 "Show key" | ✅ | -| GS-7 | 复制 Key | 点击复制按钮 | toast 显示 "Copied to clipboard" | ⏭️ headless clipboard 受限 | -| GS-8 | Key 格式 | 显示 Key 后查看 | Key 为非空字符串(43 字符),等宽字体显示 | ✅ | -| GS-9 | UI 重新生成 Key | 点击 "Regenerate" 并确认,再次显示 key | 新 key 与旧 key 不同;本轮验证从 `test-key` 变为 `Su6GKY9teQFy9psueUb5j371uNWpo8xefFTV_EZ3VJY` | ✅ agent-browser 实测 | +| # | 测试场景 | 操作步骤 | 预期结果 | +|---|---------|---------|---------| +| B-1 | Settings 不承载全局注册码 | 检查 `/settings` | 不显示全局 Agent key、全局 offer 列表或生成入口 | +| B-2 | Add Server 是 onboarding 入口 | 点击侧栏 **Add Server** | Server 配置和绑定 offer 一次创建,明文 code 只显示一次 | ---- +## 四、i18n -## 三、GeoIP 卡片 - -| # | 测试场景 | 操作步骤 | 预期结果 | 状态 | -|---|---------|---------|---------|------| -| GS-10 | GeoIP 状态显示 | 查看 GeoIP 卡片 | 显示 "Not Installed" + Download 按钮 + DB-IP 归属 | ✅ | - ---- - -## 四、API 端点验证 - -| # | 测试场景 | 操作步骤 | 预期结果 | 状态 | -|---|---------|---------|---------|------| -| API-1 | 铸造 enrollment code | `POST /api/agent/enrollments` with `{}` | 200,返回 `{data:{id, code, expires_at}}`,code 仅此一次返回 | ✅ | -| API-2 | 获取系统设置 | `GET /api/settings` | 200,返回 `{site_name, site_description, custom_css, custom_js}` | ✅ | -| API-3 | 更新系统设置 | `PUT /api/settings` with `{"site_name":"Test"}` | 200,返回 `{site_name:"Test",...}` | ✅ | -| API-4 | 列出/删除 enrollment code | `GET /api/agent/enrollments` 后 `DELETE /api/agent/enrollments/{id}` | 200,列表仅含 8 位前缀与元数据;删除后该 code 不再可用 | ✅ | -| API-5 | 数据库备份 | `POST /api/settings/backup` | 200,Content-Disposition: `attachment; filename="serverbee_backup_*.db"` | ✅ | -| API-6 | 恢复无效文件 | `POST /api/settings/restore` with 非 SQLite 数据 | 422 Unprocessable Entity | ✅ | -| API-7 | 恢复过小文件 | `POST /api/settings/restore` with 小于 16 字节 | 422 Unprocessable Entity | ✅ | -| API-8 | 未认证访问 | 不带 cookie → `GET /api/settings` | 401 | ✅ | - ---- - -## 五、i18n - -| # | 测试场景 | 操作步骤 | 预期结果 | 状态 | -|---|---------|---------|---------|------| -| I18N-1 | 英文模式 | 英文下查看 | "Settings"、"Auto-Discovery Key" 英文 | ✅ | -| I18N-2 | 中文模式 | 切换中文 | 标题显示 "设置" | ✅ | - ---- - -## 测试统计 - -| 模块 | 用例数 | ✅ | ⏭️ | — | -|------|--------|-----|------|-----| -| 页面加载与渲染 | 3 | 3 | 0 | 0 | -| Auto-Discovery Key | 6 | 5 | 1 | 0 | -| GeoIP 卡片 | 1 | 1 | 0 | 0 | -| API 端点验证 | 8 | 8 | 0 | 0 | -| i18n | 2 | 2 | 0 | 0 | -| **合计** | **20** | **19** | **1** | **0** | - -- ✅ 通过:19 (95%) -- ⏭️ 跳过(clipboard API 在 headless 环境受限):1 (5%) +| # | 测试场景 | 操作步骤 | 预期结果 | +|---|---------|---------|---------| +| I18N-1 | 英文模式 | 英文下查看 | Settings、Data Sources、About 文案正确 | +| I18N-2 | 中文模式 | 切换中文 | 设置、数据源、关于文案正确,无缺失 key | diff --git a/tests/manual/agent-recover-e2e.md b/tests/manual/agent-recover-e2e.md deleted file mode 100644 index 69b4e736..00000000 --- a/tests/manual/agent-recover-e2e.md +++ /dev/null @@ -1,537 +0,0 @@ -# Agent Recover End-to-End VPS 验证 - -在真实 Linux VPS 上完整验证 `recover agent` 流程: -浏览器 / API → server `/api/servers/{id}/recover` → 重新生成 enrollment code → -`install.sh` 在 agent.toml 已存在时刷新 `enrollment_code` 并清空旧 `token` → -agent 用新 code 重新握手成功。 - -适用于以下修改后做端到端回归: -- `crates/server/src/router/api/server.rs` recover endpoint -- `deploy/install.sh` 的 `install_docker_agent` / `install_binary_agent` 的 agent.toml 刷新逻辑(commit 01b6fcd9) -- 前端 `recover-agent-dialog.tsx` / `regenerate-code-dialog.tsx` 的 WS 缓存更新 - -`demo.serverbee.app` 上是已发布的镜像,**不能** 用来测试当前分支的代码。这套流程在 VPS 上跑你当前分支编译出来的 server + agent。 - ---- - -## 0. 前提 - -- 本机 macOS(Apple Silicon 已验证)。需要: - - `docker buildx`(orbstack / Docker Desktop 都行) - - `cargo`, `rustup target add x86_64-unknown-linux-musl` - - `cargo-zigbuild` (`brew install cargo-zigbuild zig`) — 用它原生交叉编译,比 QEMU 模拟快 5-10 倍 - - `sshpass`(密码登录脚本化用;prod 用 key 时不需要) -- 一台干净 Linux VPS(已验证 Debian 13 trixie x86_64)。 -- 一个 A 记录已指向 VPS IPv4 的域名(用于 Caddy 自动签 Let's Encrypt 证书)。 -- 工作目录 = 仓库根(含 `Dockerfile.server`, `Dockerfile.agent`, `deploy/install.sh`)。 - -> 测试机的具体 IP/域名/凭据不入仓库。如果你有专用复用测试机,参考自己 vault 里的 -> 备忘;本文示例占位符全部用变量。 - -变量约定(下文 shell 片段会引用): - -```bash -export VPS_IP=<your-vps-ipv4> -export VPS_USER=root -export VPS_PASS='...' # 由人 / vault 提供,不入仓 -export DOMAIN=<your-test-host.example.com> -export ACME_EMAIL=<acme-email> # 用于 Let's Encrypt 注册 -# install.sh 的 docker_image_tag 取自 GitHub release 的最新 tag(去掉前导 v)。 -# 当前 main 是 v1.0.0-alpha.4,所以本地镜像必须以 `1.0.0-alpha.4` 为 tag 才能被 -# install.sh 生成的 docker-compose 找到(否则 compose 会去 ghcr 拉真实 release)。 -export PROD_TAG=1.0.0-alpha.4 -export DEV_TAG=1.0.0-alpha.4-dev # 给镜像加的可读 dev 别名 -``` - -> **安全提醒**:用专用测试机;不要把生产凭据放进这套流程。 - ---- - -## 1. 本机交叉编译为 linux/amd64 - -### 1.1 编译前端 - -```bash -cd apps/web && bun install --frozen-lockfile && bun run build && cd - -# 产物:apps/web/dist/ -``` - -### 1.2 编译 Rust 二进制(cargo-zigbuild) - -```bash -cargo zigbuild --release \ - -p serverbee-server -p serverbee-agent \ - --target x86_64-unknown-linux-musl -file target/x86_64-unknown-linux-musl/release/serverbee-server -# → ELF 64-bit LSB executable, x86-64, statically linked -``` - -Apple Silicon M3 Max 上从 cold cache 约 4 分钟(编译 + 链接)。 - -> 替代方案:`docker buildx build --platform linux/amd64 -f Dockerfile .`。该方案在 macOS 上走 QEMU 模拟,cold 编译要 30-60 分钟,不推荐。 - -### 1.3 打包成 docker 镜像 - -`Dockerfile.server` / `Dockerfile.agent` 期望 `docker-bins/linux-${TARGETARCH}/serverbee-{server,agent}` 已经存在。`TARGETARCH=amd64` 由 `--platform linux/amd64` 注入。 - -```bash -mkdir -p docker-bins/linux-amd64 -cp target/x86_64-unknown-linux-musl/release/serverbee-server docker-bins/linux-amd64/ -cp target/x86_64-unknown-linux-musl/release/serverbee-agent docker-bins/linux-amd64/ - -# 用 release-tag(PROD_TAG)作主 tag,让 install.sh 生成的 compose 能直接命中本地镜像 -docker buildx build --platform linux/amd64 --load \ - -t ghcr.io/zingerlittlebee/serverbee-server:${PROD_TAG} \ - -t ghcr.io/zingerlittlebee/serverbee-server:${DEV_TAG} \ - -f Dockerfile.server . - -docker buildx build --platform linux/amd64 --load \ - -t ghcr.io/zingerlittlebee/serverbee-agent:${PROD_TAG} \ - -t ghcr.io/zingerlittlebee/serverbee-agent:${DEV_TAG} \ - -f Dockerfile.agent . -``` - -> 为什么必须用 `PROD_TAG`:`install.sh` 的 `get_latest_version` 走 GitHub release API, -> 然后用 `version#v` 作 image tag。脚本顶层 `RESOLVED_VERSION=""`([deploy/install.sh:745](../deploy/install.sh#L745)) -> 会清空任何外部 env 注入,所以无法用 env 覆盖版本。 -> 同时打 `DEV_TAG` 别名只是为了 `docker images` 一眼能区分。 - -### 1.4 导出为 tar.gz - -```bash -mkdir -p /tmp/sbee-build -docker save \ - ghcr.io/zingerlittlebee/serverbee-server:${PROD_TAG} \ - ghcr.io/zingerlittlebee/serverbee-server:${DEV_TAG} \ - ghcr.io/zingerlittlebee/serverbee-agent:${PROD_TAG} \ - ghcr.io/zingerlittlebee/serverbee-agent:${DEV_TAG} \ - | gzip > /tmp/sbee-build/serverbee-${DEV_TAG}.tar.gz -ls -lh /tmp/sbee-build/serverbee-${DEV_TAG}.tar.gz -# → 实测约 27 MB(gzip 后) -``` - ---- - -## 2. 准备 VPS(一次性) - -```bash -ssh-keygen -R "$VPS_IP" 2>/dev/null || true -sshpass -p "$VPS_PASS" ssh -o StrictHostKeyChecking=accept-new $VPS_USER@$VPS_IP <<'REMOTE' -set -e - -# 清掉测试机上残留的 serverbee 状态 -systemctl stop serverbee-agent.service 2>/dev/null || true -systemctl disable serverbee-agent.service 2>/dev/null || true -systemctl reset-failed serverbee-agent.service 2>/dev/null || true -rm -f /etc/systemd/system/serverbee-agent.service -systemctl daemon-reload -rm -rf /opt/serverbee - -# 装 Docker(Debian/Ubuntu) -if ! command -v docker >/dev/null; then - apt-get update -qq - apt-get install -y -qq ca-certificates curl gnupg lsb-release - install -m 0755 -d /etc/apt/keyrings - curl -fsSL https://download.docker.com/linux/debian/gpg \ - | gpg --dearmor -o /etc/apt/keyrings/docker.gpg - chmod a+r /etc/apt/keyrings/docker.gpg - echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" \ - > /etc/apt/sources.list.d/docker.list - apt-get update -qq - apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin - systemctl enable --now docker -fi -docker --version -docker compose version - -# Caddy 要 80/443,server 暴露 9527 给 Caddy 反代 -ss -ltnp | grep -E ':(80|443|9527) ' && echo PORT_IN_USE || echo ports clean -REMOTE -``` - ---- - -## 3. scp + load 镜像 - -```bash -sshpass -p "$VPS_PASS" scp -o StrictHostKeyChecking=yes \ - /tmp/sbee-build/serverbee-${DEV_TAG}.tar.gz $VPS_USER@$VPS_IP:/root/ - -sshpass -p "$VPS_PASS" ssh $VPS_USER@$VPS_IP \ - "gunzip -c /root/serverbee-${DEV_TAG}.tar.gz | docker load - docker images | grep zingerlittlebee | sort" -``` - -预期看到 4 行(server PROD_TAG/DEV_TAG, agent PROD_TAG/DEV_TAG),且每对的 image ID 相同(同一镜像两个别名)。 - ---- - -## 4. 启动 server + Caddy 反代 HTTPS - -server 容器只绑定到 `127.0.0.1:9527`,由 Caddy 在 :443 终止 TLS 后 reverse_proxy 过去。 - -```bash -sshpass -p "$VPS_PASS" ssh $VPS_USER@$VPS_IP <<REMOTE -set -e - -mkdir -p /opt/serverbee/etc -cat > /opt/serverbee/docker-compose.server.yml <<YAML -services: - serverbee-server: - image: ghcr.io/zingerlittlebee/serverbee-server:${PROD_TAG} - container_name: serverbee-server - ports: - - "127.0.0.1:9527:9527" - volumes: - - serverbee-data:/data - environment: - - SERVERBEE_ADMIN__USERNAME=admin - - SERVERBEE_AUTH__SECURE_COOKIE=true - restart: unless-stopped -volumes: - serverbee-data: -YAML -docker compose -f /opt/serverbee/docker-compose.server.yml up -d -sleep 5 - -# Caddy + Let's Encrypt -if ! command -v caddy >/dev/null; then - apt-get install -y -qq debian-keyring debian-archive-keyring apt-transport-https - curl -1sLf "https://dl.cloudsmith.io/public/caddy/stable/gpg.key" \ - | gpg --dearmor --batch --yes -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg - curl -1sLf "https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt" \ - > /etc/apt/sources.list.d/caddy-stable.list - chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg /etc/apt/sources.list.d/caddy-stable.list - apt-get update -qq && apt-get install -y -qq caddy -fi -cat > /etc/caddy/Caddyfile <<CADDY -{ - email ${ACME_EMAIL} -} -${DOMAIN} { - reverse_proxy 127.0.0.1:9527 -} -CADDY -systemctl restart caddy -sleep 8 -journalctl -u caddy --no-pager -n 20 - -# 一次性抓 admin 初始密码 -docker logs serverbee-server 2>&1 | awk '/FIRST-RUN ADMIN CREDENTIALS/,/=========/' | tail -20 -REMOTE -``` - -预期: - -- `caddy ... certificate obtained successfully` — Let's Encrypt 拿证成功。 -- 日志里能看到 `Username: admin` + `Password: <43位>`,复制下来到下一步用。 - -### 4.1 外部验证 HTTPS - -```bash -curl -fsS -I https://$DOMAIN/healthz | head -3 # 期望 HTTP/2 200 -curl -fsS https://$DOMAIN/healthz # 期望 ok -``` - ---- - -## 5. 完成 onboarding + 创建 server 实体 - -新装的 server 强制首次登录改密:`must_change_password=true` 的用户只能调 -`POST /api/auth/onboarding`、`GET /api/auth/me`、`POST /api/auth/logout` -(白名单见 [crates/server/src/middleware/auth.rs](../../crates/server/src/middleware/auth.rs) -的 `is_onboarding_whitelisted`)。直接调 `PUT /api/auth/password` 会被中间件拦住。 - -```bash -export INIT_PASS='...从 docker logs 拷出来...' -export NEW_PASS='<strong-test-password>' # >=8 位,符合 server 密码策略;不要复用生产密码 - -# 5.1 登录拿 session cookie -rm -f /tmp/sb.cookies -curl -sS -c /tmp/sb.cookies -X POST https://$DOMAIN/api/auth/login \ - -H 'Content-Type: application/json' \ - -d "{\"username\":\"admin\",\"password\":\"$INIT_PASS\"}" -# → must_change_password: true - -# 5.2 走 onboarding 改密 -curl -sS -b /tmp/sb.cookies -c /tmp/sb.cookies -X POST https://$DOMAIN/api/auth/onboarding \ - -H 'Content-Type: application/json' \ - -d "{\"new_password\":\"$NEW_PASS\"}" -# → {"data":"ok"} - -# 5.3 创建一台 server,拿初次 enrollment code -curl -sS -b /tmp/sb.cookies -X POST https://$DOMAIN/api/servers \ - -H 'Content-Type: application/json' \ - -d '{"name":"vps-recover-test"}' | tee /tmp/sb.server.json | jq . - -export SERVER_ID=$(jq -r '.data.server_id' /tmp/sb.server.json) -export INIT_CODE=$(jq -r '.data.enrollment.code' /tmp/sb.server.json) -echo "SERVER_ID=$SERVER_ID INIT_CODE=$INIT_CODE" -``` - ---- - -## 6. 首次 install.sh 装 agent - -把当前分支的 install.sh 推上去,docker 模式装: - -```bash -sshpass -p "$VPS_PASS" scp -o StrictHostKeyChecking=yes \ - deploy/install.sh $VPS_USER@$VPS_IP:/root/install.sh - -sshpass -p "$VPS_PASS" ssh $VPS_USER@$VPS_IP "set -e -bash /root/install.sh install agent --method docker \ - --server-url https://${DOMAIN} \ - --enrollment-code ${INIT_CODE} \ - --yes -cat /opt/serverbee/etc/agent.toml -docker inspect serverbee-agent --format '{{.Image}}'" -``` - -关键校验: - -- 控制台显示 `[INFO] Created /opt/serverbee/etc/agent.toml`(首次 = 走 `if` 分支) -- `agent.toml` 里 `server_url=...`, `enrollment_code=$INIT_CODE`,没有 `token` 行 -- `docker inspect ...Image` 输出的 sha256 与 `docker images | grep ghcr.io/zingerlittlebee/serverbee-agent:${PROD_TAG}` 的 ID 一致 → 确认在跑你的本地编译,而不是从 ghcr 拉的发布镜像 -- VPS 上 `ss -tnp | grep :443` 可看到 `serverbee-agent` ESTAB 到 :443 的连接 -- server REST `/api/servers/$SERVER_ID` 期望 `has_token=true`、`outstanding_enrollment=null`、`agent_version` / `cpu_name` 等字段被 agent 上报填上 -- server logs 出现 `Agent <id> connected from ...` - -> `online` 字段在 REST 里恒为 `null` —— 它是 WS push 才会刷的运行时态,不出现在 `/api/servers` 响应里。判定 agent 实际在线看 server 日志的 `connected` 行或 TCP 连接。 - ---- - -## 7. ⭐ 核心修复点验证:recover + 二次 install.sh - -这是真正要回归的 bug。先让 server 撤回 token + 发新 code,再让 `install.sh` 走 agent.toml 已存在的 `else` 分支。 - -### 7.1 触发 recover(revoke_immediately=true) - -```bash -curl -sS -b /tmp/sb.cookies -X POST "https://$DOMAIN/api/servers/$SERVER_ID/recover" \ - -H 'Content-Type: application/json' \ - -d '{"revoke_immediately":true}' | tee /tmp/sb.recover.json | jq . - -export NEW_CODE=$(jq -r '.data.enrollment.code' /tmp/sb.recover.json) -curl -sS -b /tmp/sb.cookies "https://$DOMAIN/api/servers/$SERVER_ID" \ - | jq '.data | {has_token, outstanding_enrollment}' -``` - -预期: - -- `has_token=false`(旧 token 失效) -- `outstanding_enrollment.code_prefix = "..."` 与 `NEW_CODE` 前 8 位一致 - -VPS 上 server 日志会出现 agent 用旧 token 反复重连失败的 `Agent WS unauthorized ... invalid token (source=query, prefix=<old_prefix>)`。这就是修复前会卡死循环的状态。 - -### 7.2 抓 agent.toml 「before」状态 - -```bash -sshpass -p "$VPS_PASS" ssh $VPS_USER@$VPS_IP "cat /opt/serverbee/etc/agent.toml" -``` - -预期看到:旧 `enrollment_code`、`token = "<旧 token>"`(agent 注册成功后 toml_set 写进去的)。 - -### 7.3 走「重装」流程:uninstall + install 同一份 code - -install.sh 的 `cmd_install` 会先检查 meta 文件,若有就拒装。**recover 的预期 UX 是 -先 `uninstall agent --yes`(保留 agent.toml)再 `install agent ...` 同样命令但带新 code**。 -`uninstall` 不带 `--purge` 时只删 container 和 systemd 单元,**保留 agent.toml**, -这样下一次 install 才会命中 `else` 分支去刷新而不是新建。 - -```bash -sshpass -p "$VPS_PASS" ssh $VPS_USER@$VPS_IP "set -e -bash /root/install.sh uninstall agent --yes - -# agent.toml 仍在(这是 else 分支触发的前提) -cat /opt/serverbee/etc/agent.toml - -bash /root/install.sh install agent --method docker \ - --server-url https://${DOMAIN} \ - --enrollment-code ${NEW_CODE} --yes - -# 关键:agent.toml 刷新后的内容 -cat /opt/serverbee/etc/agent.toml" -``` - -### 7.4 修复有效的判定 - -`cat /opt/serverbee/etc/agent.toml` 必须**全部**满足: - -| 字段 | 期望值 | -| --- | --- | -| `server_url` | `"https://${DOMAIN}"`(重写) | -| `enrollment_code` | **新 code**(`NEW_CODE`,而不是 7.2 抓到的旧 code) | -| `token` | **空字符串** `""`(被 install.sh 主动清空) | -| `[collector]` section | 原样保留 | - -控制台 install.sh 应该打印 `[INFO] /opt/serverbee/etc/agent.toml exists — refreshing server_url, enrollment_code, clearing token` —— 这就是 [deploy/install.sh:1535](../../deploy/install.sh#L1535)(docker 路径)/ [:1665](../../deploy/install.sh#L1665)(binary 路径) 的 `else` 分支。 - -> 修复前的 bug:旧 `else` 分支只 `warn "agent.toml already exists, not overwriting"`, -> 上面三行 **全部** 保持旧值。agent 重启会用作废 token 反复打 server,server 日志狂刷 -> `invalid token (source=query, prefix=...)`,恢复永远不发生。 - -### 7.5 server 端确认重连 - -```bash -sleep 5 -curl -sS -b /tmp/sb.cookies "https://$DOMAIN/api/servers/$SERVER_ID" \ - | jq '.data | {has_token, outstanding_enrollment, agent_version, updated_at}' - -sshpass -p "$VPS_PASS" ssh $VPS_USER@$VPS_IP \ - "docker logs serverbee-server 2>&1 | grep -E 'Agent.*(connected|unauthorized|disconnect)' | tail -10" -``` - -预期 server REST:`has_token=true`、`outstanding_enrollment=null`、`updated_at` 是 install.sh 之后的时间戳。 - -预期 server 日志会出现完整的恢复时间线: - -``` -... Agent <id> connected ... # 6 节首装 -... Agent <id> disconnected # 7.1 recover 撤 token -... Agent WS unauthorized ... invalid token (prefix=<旧>) # 旧 token 反复重连失败 -... Agent <id> connected from ... # 7.3 之后用新 code 重新握手 -``` - ---- - -## 8. 一次实际跑通的样本数据(M3 Max + Debian 13 VPS) - -| 步骤 | 实际耗时 | -| --- | --- | -| `cargo zigbuild` cold | ~4 min | -| `docker buildx` 两个镜像(COPY 已编译二进制) | < 1 s | -| `docker save | gzip` 两镜像 | ~5 s(合计约 27 MB gzipped) | -| `scp` 镜像 tarball 到 VPS | ~15 s | -| `docker load` on VPS | ~10 s | -| `docker compose up -d` server | ~5 s | -| Caddy 装 + Let's Encrypt 签证书 | ~15 s | -| install.sh 首装 agent → 上线 | ~10 s | -| recover + uninstall + reinstall + 重连 | ~25 s | - -总流程从 cold cargo cache 起约 8-10 分钟。命中 cache 复跑约 2-3 分钟。 - ---- - -## 9. 清理 - -测试机复用(保留镜像与压缩包,仅清服务和域名配置): - -```bash -sshpass -p "$VPS_PASS" ssh $VPS_USER@$VPS_IP <<'REMOTE' -docker compose -f /opt/serverbee/docker-compose.agent.yml down -v 2>/dev/null || true -docker compose -f /opt/serverbee/docker-compose.server.yml down -v 2>/dev/null || true -rm -rf /opt/serverbee -systemctl stop caddy && rm -f /etc/caddy/Caddyfile -REMOTE -``` - -完全清场(含镜像):再加 -`docker rmi -f $(docker images -q 'ghcr.io/zingerlittlebee/serverbee-*')`。 - ---- - -## 10. 失败排查表 - -| 症状 | 排查点 | -| --- | --- | -| `[ERROR] Failed to get latest version from GitHub` | install.sh 联网拿不到 release tag;查 VPS DNS / 出网;或直接 `RESOLVED_VERSION` 注入是无效的(脚本第 745 行会清空) | -| Caddy 拿不到证书 | `journalctl -u caddy -n 50`;80/443 防火墙;DNS A 记录未生效;存在错指的 AAAA | -| HTTPS 200 但 server 容器 unhealthy | `docker logs serverbee-server` 看是否数据库 migration 卡死 | -| `MUST_CHANGE_PASSWORD` 错误 | 先调 `POST /api/auth/onboarding`,**不要** 调 `PUT /api/auth/password` | -| install.sh 报 `serverbee-agent is already installed (...). Use 'upgrade' to update.` | recover 流程要先 `uninstall agent --yes` 再 `install ... --enrollment-code <new>`;`uninstall` 不带 `--purge` 会保留 agent.toml,正是 else 分支触发条件 | -| compose 去拉 ghcr 上的真实 release 而不是本地镜像 | 你的本地镜像 tag 不是 `PROD_TAG`(必须 = release 版本字符串去掉 `v`)。重新 `docker tag` 后 `compose up -d` 不会再 pull | -| agent 容器跑起来但 `docker logs` 空 | 正常 —— Rust 默认 `RUST_LOG` 没设,agent 静默运行;判断在线看 server 日志的 `connected` 行或 `ss -tnp | grep :443` | -| 7.4 token 没清空 | `deploy/install.sh` HEAD 不含 fix `01b6fcd9`;或你 install.sh 的 else 分支被某个 patch 改回 `warn ... not overwriting` | -| 7.4 enrollment_code 没换 | 同上;或者 `toml_set` 自身坏了,看 `deploy/install.sh:2747` 附近 | -| outstanding_enrollment 没刷新到前端列表 | server router/recover 没落库 / 前端缓存补丁没生效;查 `apps/web/src/components/server/recover-agent-dialog.tsx` 是否用 `setQueryData` 补丁 `['servers']` 而不是 invalidate | - ---- - -## 11. POSIX sh + OpenRC 安装路径回归(Alpine) - -> 改了 `deploy/install.sh` 的 init 抽象(`svc_*` 分发、`create_openrc_service_*`、 -> `create_systemd_unit_*`)、sha256 校验、doas/sudo re-exec、logrotate、i18n 之后, -> 必须在 **真正的 OpenRC** 上跑一遍——静态 `shellcheck --shell=dash` / `dash -n` -> 只能保证可移植语法,证明不了 `rc-service` / `supervise-daemon` / `rc-update` -> 的运行时行为。给的测试机若是 systemd(如 Debian/Ubuntu),用 **VPS 上的 -> privileged Alpine docker 容器** 起一个 OpenRC PID 1 即可,不必单独开 Alpine VM。 - -### 11.1 在 systemd 测试机上起一个 OpenRC 容器 - -```bash -# VPS 上(需要 docker)。Dockerfile 见下。 -cat > /root/Dockerfile.alpine-openrc <<'EOF' -FROM alpine:3.20 -RUN apk add --no-cache openrc curl ca-certificates procps -RUN echo 'rc_sys="docker"' >> /etc/rc.conf \ - && sed -i 's/^tty/# tty/g' /etc/inittab \ - && rm -f /etc/init.d/hwdrivers /etc/init.d/machine-id 2>/dev/null || true -CMD ["/sbin/init"] -EOF -docker build -t sb-alpine-openrc -f /root/Dockerfile.alpine-openrc /root - -docker rm -f sb-alpine 2>/dev/null -docker run -d --name sb-alpine --privileged \ - --tmpfs /run --tmpfs /run/lock \ - -v /sys/fs/cgroup:/sys/fs/cgroup:rw \ - sb-alpine-openrc -sleep 6 - -# OpenRC 真起来了的判据:default runlevel + softlevel + 三个二进制就位 -docker exec sb-alpine rc-status # → Runlevel: default -docker exec sb-alpine sh -c 'cat /run/openrc/softlevel' # → default -docker exec sb-alpine sh -c 'command -v rc-service supervise-daemon rc-update; ls /sbin/openrc-run' -``` - -把当前分支的 install.sh 拷进容器:`docker cp deploy/install.sh sb-alpine:/root/install.sh`。 - -### 11.2 逐项验证(实测全过,Alpine 3.20 / x86_64 / busybox ash) - -| # | 命令 | 期望 | -| --- | --- | --- | -| 1 | `docker exec sb-alpine sh -n /root/install.sh` | ash 语法干净(无输出) | -| 2 | `sh /root/install.sh server --method binary -y` | `INIT=openrc` 自动命中;下载 musl 二进制;`Starting serverbee-server ... [ ok ]`;`rc-update` 加进 default | -| 3 | `rc-service serverbee-server status` / `curl /healthz` | `status: started` / `ok` | -| 4 | `cat /etc/init.d/serverbee-server` | `#!/sbin/openrc-run`、`supervisor=supervise-daemon`、`depend(){ need net }`、`start_pre()` source env、`output_log`/`error_log` 指向 `/var/log/serverbee-server.log` | -| 5 | `cat /etc/logrotate.d/serverbee-server` | weekly / rotate 4 / copytruncate | -| 6 | 装 agent(先 onboarding 建 server 拿 code,见第 5 节,但 BASE 用 `http://127.0.0.1:9527`)| `Capabilities: ...(default)`、`Created agent.toml`、`Starting serverbee-agent ... [ ok ]` | -| 7 | `curl -b cj /api/servers` | agent 上线:`os":"Alpine Linux ..."`、`capabilities":1852`、`has_token":true`、`protocol_version":4` | -| 8 | `cat /etc/init.d/serverbee-agent` | 含 **`respawn_max=5`** + **`respawn_period=300`**(systemd `StartLimitBurst/IntervalSec` 的 OpenRC 等价物) | -| 9 | `ps aux \| grep supervise-daemon` | 实际带 `--respawn-delay 5 --respawn-max 5 --respawn-period 300` | -| 10 | `serverbee status` | agent + server 都 `active (running)` | -| 11 | `serverbee restart` | server / agent 都干净 stop+start,healthz 回 200(连跑数次不偶发) | -| 12 | `serverbee config set collector.interval 5 -y` | 改 `agent.toml`,打印 diff | -| 13 | `serverbee env set COLLECTOR__INTERVAL 5 -y` | 写 `/opt/serverbee/etc/serverbee-agent.env`(`SERVERBEE_` 前缀),并自动重启 agent | -| 14 | `serverbee uninstall agent -y`(非 purge) | 删 init 脚本 / logrotate / log / env,`rc-update del`,**保留 agent.toml** | -| 15 | `serverbee uninstall server --purge -y` | 全清,且 `/opt/serverbee` base dir 消失(commit 4b1b2b0c 的修复在 OpenRC 同样生效) | - -### 11.3 ⭐ exit-78(永久 enrollment 失败)的 respawn 上限验证 - -OpenRC 没有 systemd `RestartPreventExitStatus=78` 的等价物。为避免坏 enrollment code -导致 `supervise-daemon` 每 5s 无限重启,agent init 脚本带 `respawn_max=5` / -`respawn_period=300`——300s 内超过 5 次 respawn 就放弃,逼近 systemd 行为。验证: - -```bash -# server 在线 + agent.toml 是 bogus code(uninstall 后重装带假 code,token 会被清空) -sh /root/install.sh agent --server-url http://127.0.0.1:9527 \ - --enrollment-code BOGUS-INVALID-xxxxxxxxxxxxxxxxxxxxxxxxxxxx -y -rc-service serverbee-agent restart -# 轮询 supervise-daemon 进程数;约 25-30s(≈5×respawn_delay)后应归 0 -while sleep 5; do pgrep -f 'supervise-daemon serverbee-agent' | wc -l; done -``` - -预期:`supervise-daemon` 在约 5 次后退出(进程数 0),`rc-service serverbee-agent -status` → `stopped`,`/var/log/serverbee-agent.log` 里正好 **5 条** -`Permanent registration failure: HTTP 401`。**不是** 无限循环。 - -> 顺带说明:healthy agent 在默认日志级别下不往 stdout 写,所以 `output_log` 平时是 -> 空文件(systemd 下 `journalctl` 同样为空)——这是 agent 二进制行为,不是 OpenRC -> 日志管道坏了;exit-78 这条恰好证明了 stderr 重定向是通的(错误被捕获进日志)。 - -### 11.4 在 systemd 上的对照 - -同样的 1-15 项在 systemd 测试机(Debian 13 / dash)上已全过(ALL_PASS); -`serverbee env set` 写 systemd drop-in override,`od -c` 字节级确认; -`svc_logs` 走 `journalctl -u serverbee-*`。两套 init 共用同一份 `svc_*` 分发, -唯一差异是上面 11.2 #4/#8 的 unit 文件格式。 diff --git a/tests/manual/agent-reenrollment-e2e.md b/tests/manual/agent-reenrollment-e2e.md new file mode 100644 index 00000000..75a20ea0 --- /dev/null +++ b/tests/manual/agent-reenrollment-e2e.md @@ -0,0 +1,139 @@ +# Agent Re-enrollment End-to-End VPS 验证 + +在真实 Linux VPS 上验证 Agent Authority 的 graceful/emergency 重新接入、Agent 侧 token 暂存、精确 offer CAS 和 WebSocket fencing。构建与部署当前分支的方法见 [full-deploy-e2e.md](full-deploy-e2e.md)。 + +## 0. 前提 + +- 当前分支的 Server 和 Agent 已部署到专用测试 VPS。 +- 已有一个 `claimed` 且在线的 Server,记录其 `$SERVER_ID`。 +- 管理员 Cookie 保存到 `/tmp/sb.cookies`,Server 地址为 `$BASE`。 +- VPS 上 Agent 二进制位于 `/opt/serverbee/bin/serverbee-agent`,正式配置位于 `/opt/serverbee/etc/agent.toml`。 +- 测试凭据只放环境变量或 vault,不入仓库。 + +```bash +export BASE=https://<your-test-host> +export SERVER_ID=<server-id> +export VPS_HOST=root@<your-vps> +``` + +先确认基线: + +```bash +curl -fsS -b /tmp/sb.cookies \ + "$BASE/api/servers/$SERVER_ID/agent-authority" | jq . +``` + +预期 `status=claimed`、`outstanding_offer=null`,Agent 日志持续上报。 + +## 1. Graceful re-enrollment + +```bash +GRACEFUL=$(curl -fsS -b /tmp/sb.cookies -X POST \ + "$BASE/api/servers/$SERVER_ID/agent-authority/re-enrollment" \ + -H 'Content-Type: application/json' \ + -d '{"mode":"graceful","ttl_secs":600}') +GRACEFUL_ID=$(printf '%s' "$GRACEFUL" | jq -r '.data.enrollment.id') +GRACEFUL_CODE=$(printf '%s' "$GRACEFUL" | jq -r '.data.enrollment.code') +``` + +检查: + +- Authority 仍为 `claimed`,outstanding offer ID 为 `$GRACEFUL_ID`。 +- 原 Agent 连接和指标上报不中断。 +- 明文 code 仅在该响应和当前 UI 结果视图出现一次。 + +### 1.1 精确替换的 CAS + +```bash +REPLACED=$(curl -fsS -b /tmp/sb.cookies -X POST \ + "$BASE/api/servers/$SERVER_ID/agent-authority/offers/$GRACEFUL_ID/replace") +REPLACEMENT_ID=$(printf '%s' "$REPLACED" | jq -r '.data.enrollment.id') +REPLACEMENT_CODE=$(printf '%s' "$REPLACED" | jq -r '.data.enrollment.code') + +curl -sS -o /tmp/stale.json -w '%{http_code}\n' -b /tmp/sb.cookies -X POST \ + "$BASE/api/servers/$SERVER_ID/agent-authority/offers/$GRACEFUL_ID/replace" +``` + +预期第二次替换旧 ID 返回 409,当前 outstanding offer 仍是 `$REPLACEMENT_ID`。 + +### 1.2 用真实 Agent 完成 claim + +在 VPS 上建立临时工作目录,让同一 Agent 二进制使用独立 `agent.toml`。不要复制旧 token: + +```bash +ssh "$VPS_HOST" "rm -rf /tmp/serverbee-reenroll && mkdir -p /tmp/serverbee-reenroll && cat > /tmp/serverbee-reenroll/agent.toml <<EOF +server_url = \"$BASE\" +enrollment_code = \"$REPLACEMENT_CODE\" +EOF +cd /tmp/serverbee-reenroll && /opt/serverbee/bin/serverbee-agent > agent.log 2>&1 & echo \$! > agent.pid" +``` + +预期临时 Agent 在 claim 前先把随机 run token 写入临时 `agent.toml`,Server 只返回 `server_id`。新 claim 成功后原连接被 fenced,临时 Agent 保持在线。检查日志不能出现 enrollment code 或 run token 明文。 + +停止临时进程,把它持久化后的 `token` 安全写回正式配置,再重启正式服务。确认不再需要 enrollment code 即可重新连接。 + +## 2. Emergency re-enrollment + +恢复到 `claimed` 且无 outstanding offer 后执行: + +```bash +EMERGENCY=$(curl -fsS -b /tmp/sb.cookies -X POST \ + "$BASE/api/servers/$SERVER_ID/agent-authority/re-enrollment" \ + -H 'Content-Type: application/json' \ + -d '{"mode":"emergency","ttl_secs":600}') +EMERGENCY_ID=$(printf '%s' "$EMERGENCY" | jq -r '.data.enrollment.id') +EMERGENCY_CODE=$(printf '%s' "$EMERGENCY" | jq -r '.data.enrollment.code') +``` + +预期同一状态转换内完成: + +- authority 立即变为 `unclaimed`; +- 旧 WebSocket 被关闭,旧 token 新握手返回 401; +- `$EMERGENCY_ID` 成为唯一 outstanding offer; +- 使用临时 Agent 配置和 `$EMERGENCY_CODE` claim 后恢复 `claimed`。 + +## 3. Offer revoke 与 authority revoke 是两件事 + +发出一个 graceful offer 后,先精确吊销 offer: + +```bash +curl -fsS -b /tmp/sb.cookies -X DELETE \ + "$BASE/api/servers/$SERVER_ID/agent-authority/offers/<exact-offer-id>" +``` + +预期 authority 仍为 `claimed`。随后独立吊销 authority: + +```bash +curl -fsS -b /tmp/sb.cookies -X DELETE \ + "$BASE/api/servers/$SERVER_ID/agent-authority" +``` + +预期 authority 变为 `unclaimed`、连接被 fenced,并且不会隐式生成 offer。需要恢复时显式调用: + +```bash +curl -fsS -b /tmp/sb.cookies -X POST \ + "$BASE/api/servers/$SERVER_ID/agent-authority/offers" \ + -H 'Content-Type: application/json' -d '{"ttl_secs":600}' +``` + +## 4. UI 与事件历史 + +- Web 和 iOS 都提供 Graceful/Emergency 两种明确模式。 +- 有 outstanding offer 时,只能精确替换或吊销当前可见 ID。 +- Agent Authority 吊销有独立的破坏性确认,不假装会生成新 code。 +- 刷新或重放后只显示 offer 元数据,不恢复明文 code。 + +```bash +curl -fsS -b /tmp/sb.cookies \ + "$BASE/api/agent-authority/events?server_id=$SERVER_ID&limit=100" | jq . +``` + +预期历史包含 actor/source、graceful/emergency mode、offer terminal outcome、authority before/after;任何事件都不含明文密钥。 + +## 5. 清场 + +```bash +ssh "$VPS_HOST" 'if [ -f /tmp/serverbee-reenroll/agent.pid ]; then kill "$(cat /tmp/serverbee-reenroll/agent.pid)" 2>/dev/null || true; fi; rm -rf /tmp/serverbee-reenroll' +``` + +确保正式 Agent 使用最新持久化 token 且已在线。若最终保留 `unclaimed` 状态,明确记录并删除 outstanding offer,别给下一位测试者留一枚薛定谔的注册码。 diff --git a/tests/manual/full-deploy-e2e.md b/tests/manual/full-deploy-e2e.md index 97055394..37360c25 100644 --- a/tests/manual/full-deploy-e2e.md +++ b/tests/manual/full-deploy-e2e.md @@ -12,13 +12,13 @@ - 改 server 启动 / 数据库迁移 / OnboardingResponse - 升级 docker base 镜像 -Recover-only 的窄回归用 [agent-recover-e2e.md](agent-recover-e2e.md);本文是它的超集。 +Agent Authority 重新接入的窄回归用 [agent-reenrollment-e2e.md](agent-reenrollment-e2e.md);本文覆盖完整安装主路径。 --- ## 0. 前提 -同 [agent-recover-e2e.md §0](agent-recover-e2e.md#0-前提):本机 macOS + cargo-zigbuild + sshpass;VPS Debian/Ubuntu x86_64;域名 A 记录已指向 VPS。 +同 [agent-reenrollment-e2e.md §0](agent-reenrollment-e2e.md#0-前提):本机 macOS + cargo-zigbuild + sshpass;VPS Debian/Ubuntu x86_64;域名 A 记录已指向 VPS。 > 测试机的 IP/域名/凭据不入仓库。下面所有命令都用变量占位符引用,按你自己 vault > 里的实际值导出后再跑。 @@ -39,7 +39,7 @@ export DEV_TAG=1.0.0-alpha.4-dev ## 1. 本机构建 + 推镜像 / 二进制到 VPS -按 [agent-recover-e2e.md §1–§3](agent-recover-e2e.md#1-本机交叉编译为-linuxamd64) 跑一遍:编译 web、`cargo zigbuild` 出 `target/x86_64-unknown-linux-musl/release/serverbee-{server,agent}`、打 docker 镜像(tag 必须等于 `PROD_TAG`)、`docker save | gzip > /tmp/sbee-build/serverbee-${DEV_TAG}.tar.gz`、scp 到 VPS、`docker load`。 +编译 web,使用 `cargo zigbuild` 产出 `target/x86_64-unknown-linux-musl/release/serverbee-{server,agent}`,再构建 tag 等于 `PROD_TAG` 的 Docker 镜像。通过 `docker save | gzip > /tmp/sbee-build/serverbee-${DEV_TAG}.tar.gz`、scp 和 `docker load` 把当前分支产物送到 VPS。 到这里 VPS 上应有: @@ -83,7 +83,7 @@ REMOTE ## 3. ⭐ install.sh install server(含 Caddy + HTTPS 自动化) -这是上一份 recover runbook 没覆盖的主路径。命令: +这是完整部署主路径。命令: ```bash sshpass -p "$VPS_PASS" ssh $VPS_USER@$VPS_IP \ @@ -148,7 +148,8 @@ curl -sS -b /tmp/sb.cookies -c /tmp/sb.cookies -X POST https://$DOMAIN/api/auth/ curl -sS -b /tmp/sb.cookies -X POST https://$DOMAIN/api/servers \ -H 'Content-Type: application/json' \ - -d '{"name":"vps-fulldeploy-test"}' | tee /tmp/sb.server.json | jq . + -d "{\"onboarding_request_id\":\"$(uuidgen)\",\"name\":\"vps-fulldeploy-test\"}" \ + | tee /tmp/sb.server.json | jq . export SERVER_ID=$(jq -r '.data.server_id' /tmp/sb.server.json) export INIT_CODE=$(jq -r '.data.enrollment.code' /tmp/sb.server.json) @@ -178,8 +179,8 @@ sshpass -p "$VPS_PASS" ssh $VPS_USER@$VPS_IP \ # server 看到 agent 已注册并上报硬件信息 curl -sS -b /tmp/sb.cookies "https://$DOMAIN/api/servers/$SERVER_ID" \ - | jq '.data | {has_token, agent_version, cpu_name, mem_total, outstanding_enrollment}' -# 期望 has_token=true, outstanding_enrollment=null, cpu_name/mem_total 都填上 + | jq '.data | {agent_authority, agent_version, cpu_name, mem_total}' +# 期望 agent_authority.status=claimed、outstanding_offer=null,cpu_name/mem_total 都填上 # server 容器日志 sshpass -p "$VPS_PASS" ssh $VPS_USER@$VPS_IP \ @@ -196,17 +197,18 @@ binary 模式默认会去 GitHub Releases 下 `serverbee-agent-linux-amd64`。 预先把本地编译好的二进制放到 `/opt/serverbee/bin/serverbee-agent`,install.sh 就会 绕过下载、直接使用它,同时仍然走完 agent.toml + systemd unit 的生成路径。 -### 6.1 卸载上一节 docker agent,清 agent.toml,发新 code +### 6.1 卸载上一节 docker agent,清 agent.toml,开始 emergency re-enrollment ```bash sshpass -p "$VPS_PASS" ssh $VPS_USER@$VPS_IP "set -e bash /root/install.sh uninstall agent --yes rm -f /opt/serverbee/etc/agent.toml /opt/serverbee/docker-compose.agent.yml" -# 上一节 INIT_CODE 已被消费,重新走 recover 拿新 code(也顺带把 has_token 翻 false) -curl -sS -b /tmp/sb.cookies -X POST "https://$DOMAIN/api/servers/$SERVER_ID/recover" \ +# 上一节 INIT_CODE 已被消费。Emergency 会立即吊销旧 authority 并发出新 offer。 +curl -sS -b /tmp/sb.cookies -X POST \ + "https://$DOMAIN/api/servers/$SERVER_ID/agent-authority/re-enrollment" \ -H 'Content-Type: application/json' \ - -d '{"revoke_immediately":true}' | tee /tmp/sb.bincode.json | jq . + -d '{"mode":"emergency"}' | tee /tmp/sb.bincode.json | jq . export BIN_CODE=$(jq -r '.data.enrollment.code' /tmp/sb.bincode.json) ``` @@ -317,7 +319,7 @@ REMOTE | `install server --domain` 全套(含 Caddy + Let's Encrypt) | ~30 s | | Onboarding + 创建 server 实体 | ~3 s | | `install agent --method docker` | ~10 s | -| recover + 卸载 + `install agent --method binary` | ~15 s | +| emergency re-enrollment + 卸载 + `install agent --method binary` | ~15 s | | `uninstall server --purge` + `uninstall agent --purge` | ~10 s | 完整一遍从 cold cache 起约 10 分钟。命中 cache 复跑约 3 分钟(不算交互输入)。 @@ -335,18 +337,17 @@ REMOTE | `/api/servers` 返 `MUST_CHANGE_PASSWORD` | 先 `POST /api/auth/onboarding`,**不要** `PUT /api/auth/password`(白名单只放 onboarding) | | binary 模式 install.sh 仍然去 ghcr 拉镜像 / 仍然 download GitHub | 你忘了 `mkdir -p /opt/serverbee/bin && scp <agent binary> ...`;adopt 路径要求二进制 `chmod +x` 且 `[ -f ... ]` 命中 | | docker 模式 install.sh 去 ghcr 拉真实 release | 本地镜像 tag 不是 `PROD_TAG`;`docker tag` 改成 release 版本号去 `v` 形态 | -| `systemctl status serverbee-agent` 报 `status=78/CONFIG` | enrollment_code 已过期/已用;按提示 recover 拿新 code、清 `token` 行后 `systemctl restart serverbee-agent` | +| `systemctl status serverbee-agent` 报 `status=78/CONFIG` | enrollment offer 已过期/已用;在 Server 详情精确替换当前 offer,或按 authority 状态发起重新接入,再更新配置并重启 | | `install.sh install agent` 报 `serverbee-agent is already installed (...). Use 'upgrade'` | meta 残留;先 `uninstall agent --yes`(不带 `--purge` 保留 agent.toml)再 install | | `uninstall server --purge` 删了我刚才 docker load 的镜像 | 是的,符合预期;从 tarball 重新 `docker load < /root/serverbee-*.tar.gz` 即可 | | `online` 字段在 REST 永远为 `null` | 设计如此,运行态走 WS push;判定在线看 server 日志 `Agent <id> connected` | --- -## 10. 跟 agent-recover-e2e.md 的关系 +## 10. 跟 agent-reenrollment-e2e.md 的关系 - 本文 = 完整正向部署流程,覆盖 server + 两种 agent 模式 + 卸载。 -- [agent-recover-e2e.md](agent-recover-e2e.md) = recover 窄回归,专测 install.sh 的 `else` 分支 - (agent.toml 已存在时 `enrollment_code` / `server_url` / `token` 三字段的刷新行为)。 +- [agent-reenrollment-e2e.md](agent-reenrollment-e2e.md) = Agent Authority 窄回归,专测 graceful/emergency、精确 offer CAS、Agent token 暂存和 WS fencing。 - 改了 install.sh 主路径 / cmd_domain / Caddyfile 生成 / install_server_*:跑本文。 -- 改了 recover endpoint / install.sh agent.toml 刷新逻辑 / recover dialog:跑 agent-recover-e2e.md。 -- 不确定时跑本文(超集)。 +- 改了 Agent Authority endpoint、Agent claim/token 持久化或重新接入 UI:跑 agent-reenrollment-e2e.md。 +- 两边都改了就两份都跑,别让“超集”这个词替你漏测状态机。 diff --git a/tests/network-quality.md b/tests/network-quality.md index a9f9476c..331cd258 100644 --- a/tests/network-quality.md +++ b/tests/network-quality.md @@ -10,10 +10,13 @@ cd apps/web && bun install && bun run build && cd ../.. SERVERBEE_ADMIN__PASSWORD=admin123 SERVERBEE_AUTH__SECURE_COOKIE=false cargo run -p serverbee-server & sleep 8 -# 2. 登录并铸造一次性 enrollment code +# 2. 登录并创建 Server 和绑定的 enrollment offer curl -s -c /tmp/sb-cookies.txt -X POST http://localhost:9527/api/auth/login \ -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin123"}' -CODE=$(curl -s -b /tmp/sb-cookies.txt -X POST http://localhost:9527/api/agent/enrollments -H 'Content-Type: application/json' -d '{}' | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['code'])") +CODE=$(curl -s -b /tmp/sb-cookies.txt -X POST http://localhost:9527/api/servers \ + -H 'Content-Type: application/json' \ + -d "{\"onboarding_request_id\":\"$(uuidgen)\",\"name\":\"Network Test Agent\"}" \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['enrollment']['code'])") # 3. 启动 Agent SERVERBEE_SERVER_URL="http://127.0.0.1:9527" SERVERBEE_ENROLLMENT_CODE="$CODE" cargo run -p serverbee-agent & diff --git a/tests/ping-tasks.md b/tests/ping-tasks.md index 809c48f6..0bb16890 100644 --- a/tests/ping-tasks.md +++ b/tests/ping-tasks.md @@ -27,13 +27,14 @@ until curl -fsS http://localhost:9527/healthz >/dev/null; do sleep 1 done -# 4. 登录 admin,铸造一次性 enrollment code +# 4. 登录 admin,创建 Server 和绑定的 enrollment offer curl -fsS -c "$ADMIN_COOKIE" -X POST http://localhost:9527/api/auth/login \ -H 'Content-Type: application/json' \ -d '{"username":"admin","password":"admin123"}' -CODE=$(curl -fsS -b "$ADMIN_COOKIE" -X POST http://localhost:9527/api/agent/enrollments \ - -H 'Content-Type: application/json' -d '{}' \ - | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['code'])") +CODE=$(curl -fsS -b "$ADMIN_COOKIE" -X POST http://localhost:9527/api/servers \ + -H 'Content-Type: application/json' \ + -d "{\"onboarding_request_id\":\"$(uuidgen)\",\"name\":\"Ping Test Agent\"}" \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['enrollment']['code'])") # 5. 启动 Agent SERVERBEE_SERVER_URL="http://127.0.0.1:9527" \ diff --git a/tests/registration-hardening.md b/tests/registration-hardening.md index ee0e4e9f..d3985eae 100644 --- a/tests/registration-hardening.md +++ b/tests/registration-hardening.md @@ -1,101 +1,63 @@ -# 自动注册加固测试用例 +# Agent Authority 竞态与加固测试 -## 前置条件 +这组用例验证 Agent enrollment 的安全边界。Server 身份由管理员 onboarding 创建,不从 Agent 主机信息推导;Agent Authority 只负责 offer、run token、状态转换、连接 fencing 和事件历史。 -参照 [README.md](README.md) 中的「启动本地环境」部分完成 Server 启动和管理员登录。 +## 一、自动化覆盖 -如果要验证 UI 中的 cleanup 行为,需要额外准备两个占位服务器: +| 测试组 | 主要覆盖 | +|--------|----------| +| `service::server_onboarding` | Server、标签、默认探测、初始 offer、事件和幂等记录同事务提交;失败完整回滚 | +| `service::agent_authority` | 单 outstanding 约束、四种终态、claim 竞态、严格 offer CAS、graceful/emergency、删除后事件留存 | +| `agent_registration_integration` | 真实 HTTP/WS claim、fencing、幂等 replay、状态与历史投影 | +| Agent `register` / `reporter` | claim 前持久化 run token、模糊 HTTP 结果后的 WS-first 恢复、401 处理、日志不泄密 | -1. 创建一个“离线占位 server”:只调用注册接口,不建立 WebSocket 连接。 -2. 创建一个“在线但未初始化的占位 server”:调用注册接口后,仅建立 `/api/agent/ws?token=...` 连接并停在 Welcome,不发送 `SystemInfo`。 - -可直接复用下面的命令: - -注册接口现在要求一次性 enrollment code(旧的共享 `auto_discovery_key` 已移除),每次注册都需要先铸造一个新 code(单次使用)。 +运行: ```bash -# 1. 管理员登录 -curl -s -c /tmp/sb-cookies.txt -X POST http://localhost:9527/api/auth/login \ - -H 'Content-Type: application/json' \ - -d '{"username":"admin","password":"admin123"}' - -# 辅助函数:铸造并取出一个一次性 code -mint() { curl -s -b /tmp/sb-cookies.txt -X POST http://localhost:9527/api/agent/enrollments \ - -H 'Content-Type: application/json' -d '{}' | grep -o '"code":"[^"]*"' | cut -d'"' -f4; } - -# 2. 创建离线占位 server -curl -s -X POST http://localhost:9527/api/agent/register \ - -H "Authorization: Bearer $(mint)" - -# 3. 创建带固定 fingerprint 的 server(每次都铸造新 code;同一 fingerprint 复用同一 server) -curl -s -X POST http://localhost:9527/api/agent/register \ - -H "Authorization: Bearer $(mint)" \ - -H 'Content-Type: application/json' \ - -d '{"fingerprint":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}' +cargo test -p serverbee-server service::server_onboarding +cargo test -p serverbee-server service::agent_authority +cargo test -p serverbee-server --test agent_registration_integration +cargo test -p serverbee-agent register +cargo test -p serverbee-agent reporter ``` ---- - -## 一、自动化测试覆盖 - -### 单元测试 - -| 测试组 | 文件 | 验证内容 | -|--------|------|----------| -| Agent 指纹 | `crates/agent/src/fingerprint.rs` | 指纹只基于 `machine-id` 生成;同一 `machine-id` 哈希稳定 | -| Servers 列表 cleanup 计数 | `apps/web/src/lib/orphan-server-utils.test.ts` | 仅将离线且未初始化的 `New Server` 计入 cleanup 候选 | -| Cleanup 辅助逻辑 | `crates/server/src/router/api/server.rs` | `collect_orphan_server_ids` 会跳过在线占位 server | - -### 集成测试 - -| 测试组 | 文件 | 验证内容 | -|--------|------|----------| -| 自动注册复用 | `crates/server/tests/integration.rs` | 相同 fingerprint 重复注册时复用同一 `server_id`,并轮换 token | -| Cleanup 在线保护 | `crates/server/tests/integration.rs` | `DELETE /api/servers/cleanup` 只删除离线 orphan,不删除已在线但尚未上报 `SystemInfo` 的 server | - ---- - -## 二、API 与后端行为 - -| # | 测试场景 | 操作步骤 | 预期结果 | 状态 | -|---|---------|---------|---------|------| -| RH-1 | 相同 fingerprint 复用同一 server | 连续两次调用 `POST /api/agent/register`,请求体都带相同 64 位 hex fingerprint | 两次返回相同 `server_id`,第二次返回新 token,`GET /api/servers` 仅有 1 条记录 | ✅ | -| RH-2 | Cleanup 仅删除离线 orphan | 创建 1 个离线占位 server 和 1 个在线未初始化 server,然后调用 `DELETE /api/servers/cleanup` | 返回 `deleted_count=1`;离线 orphan 被删除,在线未初始化 server 保留 | ✅ | - ---- - -## 三、Servers 列表页(/servers) - -| # | 测试场景 | 操作步骤 | 预期结果 | 状态 | -|---|---------|---------|---------|------| -| RH-3 | Cleanup 按钮计数只包含离线 orphan | 打开 `/servers`,保证存在 1 个离线 orphan 和 1 个在线未初始化 server | 列表页显示 `Clean up unconnected (1)`,不会把在线未初始化 server 算进去 | ✅ agent-browser 实测 | -| RH-4 | Cleanup 操作不误删在线未初始化 server | 点击 cleanup 按钮并确认删除 | cleanup 后 `GET /api/servers` 仅剩在线占位 server,cleanup 按钮消失 | ✅ agent-browser + API 复核 | +## 二、Onboarding 幂等与原子性 ---- +| # | 操作 | 预期 | +|---|------|------| +| RH-1 | 使用相同 actor、`onboarding_request_id` 和等价输入连续或并发调用 `POST /api/servers` | 只有一个 Server、一条 onboarding 记录和一个 outstanding offer;所有响应指向同一 `server_id` | +| RH-2 | 同一 request ID 搭配不同标准化输入 | 返回 409 `ONBOARDING_IDEMPOTENCY_CONFLICT`,不产生额外状态 | +| RH-3 | 制造标签、默认探测或 offer 创建失败 | Server、标签、默认探测、offer、事件与幂等记录全部回滚 | +| RH-4 | 重放已成功 onboarding | `replayed=true`,绝不返回明文 code;只返回当前 outstanding offer 元数据(如有) | -## 四、Settings 页(/settings) +## 三、Claim 与 offer 竞态 -| # | 测试场景 | 操作步骤 | 预期结果 | 状态 | -|---|---------|---------|---------|------| -| RH-5 | UI 铸造一次性 enrollment code | 打开 `/settings`,点击生成 enrollment code | 显示一次性 code 及安装命令;code 单次使用、约 10 分钟过期 | ⬜ 待重测 | +| # | 操作 | 预期 | +|---|------|------| +| RH-5 | 两个 Agent 使用同一 code 和不同 proposed run token 并发 claim | 只有一个成功;offer 只进入一次 `consumed`;胜者 token 成为唯一有效 authority | +| RH-6 | 两个管理员同时替换同一准确 offer ID | 只有一个成功;另一个得到 409 stale/terminal;数据库仍最多一个 outstanding offer | +| RH-7 | 用旧页面中的 offer ID 替换或吊销 | 不能影响较新的 outstanding offer | +| RH-8 | 让 offer 超时后再 claim/replace | claim 返回 401,管理操作返回终态冲突,历史只记录 `expired` 一次 | ---- +## 四、Authority 和 WebSocket fencing -## 五、Docker 安装输出 +| # | 操作 | 预期 | +|---|------|------| +| RH-9 | claimed 状态开始 graceful re-enrollment | 旧 token 和当前连接继续有效,直到新 claim 原子替换 authority | +| RH-10 | claimed 状态开始 emergency re-enrollment | 旧 authority 立即失效、旧连接关闭,同时产生一个 offer | +| RH-11 | 独立 `DELETE /api/servers/{id}/agent-authority` | authority 变为 unclaimed、连接被关闭,不隐式产生 offer | +| RH-12 | 在 WS preflight 与 upgrade/final admission 间吊销 authority | final admission 拒绝旧 token;fencing 后任何旧连接 frame 都不能进入业务分发 | -| # | 测试场景 | 操作步骤 | 预期结果 | 状态 | -|---|---------|---------|---------|------| -| RH-6 | Docker agent compose 包含 machine-id 挂载 | 运行 `serverbee install agent --method docker` 或直接检查生成的 compose 模板 | `docker-compose.agent.yml` 中包含 `- /etc/machine-id:/etc/machine-id:ro` | ✅ 模板检查通过 | +## 五、Cleanup 与安装持久化 ---- +| # | 操作 | 预期 | +|---|------|------| +| RH-13 | 创建一个从未 claim 的离线 Server 和一个已连线但未发 `SystemInfo` 的 Agent,再调用 `DELETE /api/servers/cleanup` | 只删除离线未初始化 Server,在线连接对应记录保留;各自删除事件正确写入 | +| RH-14 | 用 Docker 安装 Agent 并重启容器 | Agent 配置目录持久化,claim 前暂存的 run token 不丢失;重启直接使用 token,不重复消费 offer | -## 测试统计 +## 六、UI/API 投影 -| 模块 | 用例数 | ✅ | ⏭️ | — | -|------|--------|-----|------|-----| -| 自动化测试覆盖 | 5 | 5 | 0 | 0 | -| API 与后端行为 | 2 | 2 | 0 | 0 | -| Servers 列表页 | 2 | 2 | 0 | 0 | -| Settings 页 | 1 | 1 | 0 | 0 | -| Docker 安装输出 | 1 | 1 | 0 | 0 | -| **合计** | **11** | **11** | **0** | **0** | +| # | 操作 | 预期 | +|---|------|------| +| RH-15 | 查看 REST 列表、Browser WS、Web 和 iOS | `agent_authority.status` 与 `outstanding_offer` 一致;online/offline 不改变 claimed/unclaimed | +| RH-16 | 触发每类转换后读取 `/api/agent-authority/events` | actor、source、mode、offer outcome、before/after 完整,不含明文 code/run token;Server 删除后仍可读取 | diff --git a/tests/security-events.md b/tests/security-events.md index 3ecfca1e..ece51e43 100644 --- a/tests/security-events.md +++ b/tests/security-events.md @@ -73,7 +73,7 @@ | O3 | Drawer 查看 evidence | 点击事件行 | Drawer 弹出,evidence JSON 完整 | — | | O4 | source_ip 一键过滤 | 点击表格里的 IP | 自动填入过滤条 | — | | O5 | 服务器详情 Security Tab | 进入 `/servers/$id` → Security tab | 显示该 server 最近 50 条 + "View all" 链接 | — | -| O6 | recovery_merge 携带历史 | server 重新绑定后 | 旧 source_id 的 security_event 行 server_id 被更新为 target | — | +| O6 | 重新接入保持身份 | 同一 Server 完成 Agent re-enrollment | 既有与新上报的 security event 都保持同一 server_id | — | | O7 | retention cleanup | 设置 `retention.security_event_days=1`,跑 cleanup task | 1 天前的事件被删 | — | | O8 | i18n 中英文 | 切换语言 | "Security Events" / "安全事件" 各处标签正确 | — | @@ -83,7 +83,7 @@ |---|---------|---------|---------|------| | B1 | 高频失败抗压 | 在 60s 内触发 1000+ 失败登录 | agent 内存稳定(IP map cap 10000);事件正常聚合 | — | | B2 | WS 断连缓冲 | 触发事件期间停 server → 启回 | agent 缓冲 1000 条;恢复后批量重发;超 1000 老的丢弃,warn 日志 | — | -| B3 | RecoveryLock 期间写入 | 触发 recovery → 期间触发 S4 | 事件正常落库(append-only 不受冻结);recovery_merge 完成后 server_id 跟随 | — | +| B3 | Authority fencing 期间写入 | 触发 emergency re-enrollment,同时从旧连接发送事件 | fencing 后旧连接 frame 不再落库;新 authority claim 后正常写入 | — | --- @@ -104,7 +104,7 @@ | V4 | 启动 server | `nohup target/release/serverbee-server > /tmp/server.log` | ✅ 日志见证 migration `m20260521_000024_create_security_event` 执行 | | V5 | 首次启动密码 | server 控制台打印一次性 admin 密码 | ✅ `cnQJvJUu-...` 由代码生成 | | V6 | 强制改密 | `POST /api/auth/onboarding`(`must_change_password=true` 状态下只放行此路径)| ✅ | -| V7 | 创建 enrollment | `POST /api/agent/enrollments` → `{ code, expires_at }` | ✅ | +| V7 | 创建 Server + enrollment offer | `POST /api/servers`(含唯一 `onboarding_request_id`)→ `{ server_id, enrollment }` | ✅ | | V8 | 启动 agent | env `ENROLLMENT_CODE` 启动 → 自注册,token 写本地 state | ✅ agent log 见 `WebSocket connected` + `Welcome` | | V9 | 触发爆破 | `for i in {1..15}; do sshpass -p wrong ssh testuser_brute@127.0.0.1 true; done` | ✅ journal 见 `Failed password for invalid user testuser_brute` | | V10 | 等待聚合 + 上报 | 等 90s(滑动窗 60s + WS 上报)| ✅ | @@ -141,4 +141,4 @@ ssh_brute_force | 87.251.64.145 | 15 - 端口扫描检测(**P2-P6**):opt-in,需要额外 `CAP_NET_ADMIN` + `conntrack-tools`,本次未启用;机制单测已覆盖 - 告警通知接收链(**A1-A7**):需要外部 webhook/email;本次仅验证规则匹配 + 落库 + 广播 -- 跨 server recovery_merge 实际场景(**B3**):需要双 agent 模拟身份变更 +- Authority fencing 竞态(**B3**):需要双 Agent 连接精确控制旧/新 frame 时序