mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-09 18:15:50 +00:00
Fix agent command channel admission
This commit is contained in:
@@ -159,4 +159,4 @@ Unified agent (`pulse-agent`):
|
||||
|
||||
- **Token Authentication**: All agent-to-server communication requires a valid API token.
|
||||
- **TLS**: Encrypted by default (unless specifically disabled).
|
||||
- **Network Isolation (optional)**: Agent check-in can be served on a dedicated, separately firewalled port that exposes only the agent-ingest routes (`/api/agents/*`), so a host that can reach the agent endpoint over an untrusted network cannot pivot to the web UI or management API. See [Split-Port Agent Ingest](CONFIGURATION.md#split-port-agent-ingest-network-isolation).
|
||||
- **Network Isolation (optional)**: The agent control plane can be served on a dedicated, separately firewalled port. It exposes the bounded report/config, command WebSocket, version, and bootstrap routes needed for the full agent lifecycle, but not the web UI or management API. See [Split-Port Agent Ingest](CONFIGURATION.md#split-port-agent-ingest-network-isolation).
|
||||
|
||||
@@ -202,7 +202,7 @@ Environment variables take precedence over `system.json`.
|
||||
| ---------- | ------------- | --------- |
|
||||
| `FRONTEND_PORT` | Public listening port (web UI, API, and agent ingest) | `7655` |
|
||||
| `PORT` | **Deprecated** legacy alias for `FRONTEND_PORT`, honored only when `FRONTEND_PORT` is unset. Logs a deprecation warning at startup; switch to `FRONTEND_PORT`. | *(unset)* |
|
||||
| `PULSE_AGENT_INGEST_PORT` | Optional dedicated port that serves **only** agent ingest (`/api/agents/*`), network-isolated from the web UI and the rest of the API. `0` = disabled (single port). See [Split-Port Agent Ingest](#split-port-agent-ingest-network-isolation). | `0` |
|
||||
| `PULSE_AGENT_INGEST_PORT` | Optional dedicated port for the complete agent control plane: reports/config (`/api/agents/*`), command admission (`/api/agent/ws`), version checks, and bootstrap downloads. The web UI and management API stay isolated. `0` = disabled (single port). See [Split-Port Agent Ingest](#split-port-agent-ingest-network-isolation). | `0` |
|
||||
| `LOG_LEVEL` | Log verbosity (see below) | `info` |
|
||||
| `LOG_FORMAT` | Log output format (`auto`, `json`, `console`) | `auto` |
|
||||
| `LOG_FILE` | Log file path (enables file logging) | *(unset)* |
|
||||
@@ -249,7 +249,7 @@ Environment variables take precedence over `system.json`.
|
||||
|
||||
### Split-Port Agent Ingest (Network Isolation)
|
||||
|
||||
By default Pulse serves the web UI, the REST API, and agent check-in together on `FRONTEND_PORT`. For deployments that expose Pulse to monitored hosts across an untrusted network (for example, a managed service provider whose clients' Proxmox nodes reach a central Pulse server over the internet), you can move agent check-in onto its own dedicated port and keep the web UI and management API on a separate, firewalled port.
|
||||
By default Pulse serves the web UI, the REST API, and the agent control plane together on `FRONTEND_PORT`. For deployments that expose Pulse to monitored hosts across an untrusted network (for example, a managed service provider whose clients' Proxmox nodes reach a central Pulse server over the internet), you can expose the agent control plane on its own dedicated port and keep the web UI and management API on a separate, firewalled port.
|
||||
|
||||
Set `PULSE_AGENT_INGEST_PORT` to a port other than `FRONTEND_PORT`:
|
||||
|
||||
@@ -259,7 +259,7 @@ PULSE_AGENT_INGEST_PORT=7656
|
||||
|
||||
When enabled:
|
||||
|
||||
- The dedicated port serves **only** the agent-ingest routes (`/api/agents/*`). Every other path, including the web UI, login, and the management API, returns `404`. A host that can reach the agent port cannot pivot to the management interface.
|
||||
- The dedicated port serves only the agent-owned routes required for a complete lifecycle: `/api/agents/*`, `/api/agent/ws`, `/api/agent/version`, `/api/server/info`, `/install.sh`, `/install.ps1`, and `/download/pulse-agent`. Every other path, including the web UI, login, and management APIs, returns `404`. A host that can reach the agent port cannot pivot to the management interface.
|
||||
- The main `FRONTEND_PORT` listener is unchanged and still serves everything (including agent ingest), so existing single-port installs keep working. The dedicated listener is purely additive.
|
||||
- The value is validated at startup: it must be between 1 and 65535 and must differ from `FRONTEND_PORT` and the HTTP redirect port. An invalid value is rejected.
|
||||
|
||||
@@ -270,7 +270,7 @@ PULSE_AGENT_INGEST_PORT=7656
|
||||
PULSE_AGENT_CONNECT_URL=https://agents.example.com:7656
|
||||
```
|
||||
|
||||
Agents then post telemetry to `https://agents.example.com:7656/api/agents/agent/report`, while the web UI and management API remain reachable only on the private `FRONTEND_PORT` listener.
|
||||
Agents then post telemetry to `https://agents.example.com:7656/api/agents/agent/report` and establish their command channel at `wss://agents.example.com:7656/api/agent/ws`, while the web UI and management API remain reachable only on the private `FRONTEND_PORT` listener. If command execution is enabled, both routes must traverse the same proxy/firewall path; a successful report does not prove that the WebSocket is admitted.
|
||||
|
||||
### Iframe Embedding (system.json)
|
||||
|
||||
|
||||
+14
-8
@@ -84,7 +84,7 @@ for the full reference.
|
||||
|
||||
```bash
|
||||
FRONTEND_PORT=7655 # management UI + API: private network / VPN only
|
||||
PULSE_AGENT_INGEST_PORT=7656 # agent check-in only: reachable from client sites
|
||||
PULSE_AGENT_INGEST_PORT=7656 # agent reports + command/control: reachable from client sites
|
||||
PULSE_AGENT_CONNECT_URL=https://agents.example.com:7656
|
||||
```
|
||||
|
||||
@@ -93,14 +93,16 @@ Firewall baseline:
|
||||
| Surface | Port | Reachable from |
|
||||
|---------|------|----------------|
|
||||
| Management UI + API | `FRONTEND_PORT` (7655) | Provider staff network / VPN only |
|
||||
| Agent ingest | `PULSE_AGENT_INGEST_PORT` (7656) | Client sites (or client VPN tunnels) |
|
||||
| Agent control plane | `PULSE_AGENT_INGEST_PORT` (7656) | Client sites (or client VPN tunnels) |
|
||||
| Prometheus metrics | 9091 | Provider monitoring network only |
|
||||
|
||||
The dedicated agent port serves **only** `/api/agents/*`; every other path,
|
||||
including login and the management API, returns `404`. Agent check-in
|
||||
authenticates with an `agent:report`-scoped API token, which cannot read
|
||||
monitoring data or change settings — the token scope and the port isolation
|
||||
are independent layers.
|
||||
The dedicated agent port serves only the report/config, command WebSocket,
|
||||
version, and bootstrap routes documented in
|
||||
[Split-Port Agent Ingest](CONFIGURATION.md#split-port-agent-ingest-network-isolation);
|
||||
login and management APIs return `404`. Report and command access use separate
|
||||
least-privilege scopes (`agent:report` and `agent:exec`) on the same
|
||||
host-bound enrollment token. Token scope, immutable command-session binding,
|
||||
and port isolation are independent layers.
|
||||
|
||||
If agents reach the central server over per-client VPN tunnels instead of the
|
||||
public internet, the same split still applies: expose only the agent port into
|
||||
@@ -108,13 +110,17 @@ the tunnels and keep the management port out of them.
|
||||
|
||||
### Validation checklist (run after setup, repeat after network changes)
|
||||
|
||||
1. **Agent port serves agent ingest only.** Both must return `404`:
|
||||
1. **Agent port excludes management surfaces.** Both must return `404`:
|
||||
|
||||
```bash
|
||||
curl -sk -o /dev/null -w '%{http_code}\n' https://agents.example.com:7656/ # 404
|
||||
curl -sk -o /dev/null -w '%{http_code}\n' https://agents.example.com:7656/api/login # 404
|
||||
```
|
||||
|
||||
When commands are enabled, also verify that `/api/agent/ws` reaches Pulse
|
||||
through the proxy. Agent Doctor reports the command channel as disconnected
|
||||
if telemetry is current but WebSocket admission is absent.
|
||||
|
||||
2. **Management port is not reachable from a client site.** From a client
|
||||
network (or through a client tunnel), a connection to `FRONTEND_PORT` must
|
||||
time out or be refused by your firewall — not answer.
|
||||
|
||||
@@ -134,7 +134,7 @@ To onboard an internal estate:
|
||||
2. **Create an org-bound API token** with the `agent:report` scope, bound to that estate's organization (`orgId`). A token bound to a single organization automatically routes every agent that uses it into that organization, with no extra header required. Binding also scopes the token: an org-bound token cannot access other organizations, including the default org (bind `default` explicitly if a token genuinely needs it). Legacy unbound tokens keep their default-org access.
|
||||
3. **Install the estate's agents** (Proxmox host, Docker, Kubernetes) using that token. Their telemetry lands in the selected organization.
|
||||
4. **(Optional) Alias node names per estate.** If two estates both use the default `pve` hostname and you want them visually distinct, set `--hostname` (or the `PULSE_HOSTNAME` environment variable) on the agent, for example `--hostname "acme-pve1"`. See [UNIFIED_AGENT.md](UNIFIED_AGENT.md).
|
||||
5. **(Optional) Isolate agent check-in on its own port.** When remote nodes reach the central server across the internet, enable [Split-Port Agent Ingest](CONFIGURATION.md#split-port-agent-ingest-network-isolation) so agents connect on a dedicated, firewalled port that exposes only `/api/agents/*` and never the web UI or management API.
|
||||
5. **(Optional) Isolate the agent control plane on its own port.** When remote nodes reach the central server across the internet, enable [Split-Port Agent Ingest](CONFIGURATION.md#split-port-agent-ingest-network-isolation) so reports and command WebSockets share a dedicated, firewalled agent port that never exposes the web UI or management API.
|
||||
|
||||
Route each estate's alerts into the right internal system with per-organization webhooks or the org-scoped alerts API. See the multi-tenant section of [WEBHOOKS.md](WEBHOOKS.md).
|
||||
|
||||
|
||||
@@ -1230,11 +1230,20 @@ the intentionally sparse public response.
|
||||
job, target, and expected node, but not the human owner.
|
||||
Command-agent WebSocket identity follows the same lifecycle/auth split:
|
||||
install-command tokens that enable command execution may be minted before
|
||||
the final agent ID is known, but only Pulse-minted PVE/PBS install-command
|
||||
tokens may bind on first `/api/agent/ws` registration. That first-use bind
|
||||
the final agent ID is known, but only Pulse-minted PVE/PBS or host
|
||||
install-command tokens may bind on first `/api/agent/ws` registration. That first-use bind
|
||||
must persist the registering agent ID and hostname and become authoritative
|
||||
for later command registration attempts; generic unbound `agent:exec`
|
||||
tokens are not lifecycle credentials and must fail closed.
|
||||
tokens are not lifecycle credentials and must fail closed. A pre-v6.1.1
|
||||
hostname-bound record carrying a server-synthesized ID may migrate to the
|
||||
registering runtime ID exactly once; the versioned result must then require
|
||||
both fields. The live registry keys the session by organization plus agent
|
||||
ID, retains only non-secret token identity, isolates equal IDs across
|
||||
organizations, rejects same-organization hostname collisions, and
|
||||
revalidates token existence, expiry, scope, organization, and binding before
|
||||
reporting connectivity or dispatching work. Reconnect replacement and stale
|
||||
socket cleanup must use pointer identity so an old reader cannot remove the
|
||||
newly admitted session.
|
||||
The canonical durable-principal vocabulary for those shared auth routes is
|
||||
recorded in `docs/release-control/v6/internal/IDENTITY_INVARIANTS.md`; agent
|
||||
lifecycle work may consume that identity but must not define a parallel
|
||||
@@ -1872,6 +1881,16 @@ the intentionally sparse public response.
|
||||
|
||||
## Completion Obligations
|
||||
|
||||
Command-capable agent completion must prove more than fresh telemetry. The
|
||||
dedicated agent listener must admit the full bootstrap/report/WebSocket
|
||||
lifecycle, command sessions must be keyed by organization plus canonical bound
|
||||
identity, and every dispatch must revalidate the non-secret token admission.
|
||||
Reconnect replacement may replace only that exact tenant identity; duplicate
|
||||
hostnames, stale sockets, revoked or rebound tokens, and ambiguous hostname or
|
||||
token resolution fail closed. Fleet/Doctor projections must keep adapter health
|
||||
separate from command-channel admission and report an enabled-but-disconnected
|
||||
command runtime as a blocking condition.
|
||||
|
||||
Any Docker / Podman report-size change must update the shared contract, agent
|
||||
diagnostic proof, API encoded/decoded boundary proof, and the API-contract
|
||||
dependency in one slice. A handler-local literal, agent-local threshold, or
|
||||
@@ -2191,6 +2210,14 @@ structured fleet diagnostics that have no ledger binding (agents reporting
|
||||
only workload telemetry, e.g. Docker-only or Kubernetes-only agents) are now
|
||||
appended as diagnostics-only targets, honoring the scoped-agent filter, so a
|
||||
critical workload-only agent can no longer vanish from the fleet view.
|
||||
Telemetry liveness and an applied `commandsEnabled` report are not command
|
||||
connectivity. The connections ledger must consult the tenant- and
|
||||
token-scoped live session registry: an enabled report without an admitted
|
||||
socket projects `remoteControl="disconnected"` and a blocked command policy,
|
||||
while a deliberately disabled command policy remains disabled rather than
|
||||
becoming a transport alarm. Agent Doctor merges that ledger fact with
|
||||
structured diagnostics, so a diagnostic snapshot marked healthy cannot hide
|
||||
the critical `command_channel_disconnected` reason.
|
||||
Diagnostics-only rows render the diagnostic's status, reasons, and evidence
|
||||
but offer no host-local update command (there is no ledger connection to
|
||||
derive an update from). Removed rows are the deliberate exception in the
|
||||
|
||||
@@ -3198,6 +3198,14 @@ query...`, and `Reading storage...` before streamed tool arguments are
|
||||
|
||||
## Completion Obligations
|
||||
|
||||
Every per-organization Assistant or legacy AI service that can discover or
|
||||
dispatch through the host-agent command transport must receive an
|
||||
organization-pinned command-server view. A tenant service must never enumerate
|
||||
the default organization or another tenant and must not rely on an unscoped
|
||||
agent ID to select a session. Loss, revocation, or replacement of the pinned
|
||||
session fails the invocation before command dispatch while the ordinary
|
||||
approval, action-audit, timeout, and cancellation contracts remain in force.
|
||||
|
||||
Qualification floor: Patrol model launch and product-claim qualification must use
|
||||
`cmd/patrol-qualify` against reviewed scenario-owned ground truth. Expected
|
||||
faults and independent postconditions must be declared before the run and
|
||||
|
||||
@@ -3018,6 +3018,15 @@ a new API state machine, queue contract, or verification-accounting field.
|
||||
|
||||
## Completion Obligations
|
||||
|
||||
The public connection ledger and action APIs must project telemetry liveness
|
||||
and command admission independently. An agent may be adapter-healthy while
|
||||
remote control is `disconnected`; in that state command policy is blocked and
|
||||
action dispatch returns the canonical unavailable/disconnected error rather
|
||||
than inferring command readiness from reports. Resource actions resolve the
|
||||
live session inside the request organization, prefer the immutable report
|
||||
token binding when present, and refuse stale-token fallback to hostname or
|
||||
agent ID.
|
||||
|
||||
Docker / Podman report transport changes must prove uncompressed and gzip
|
||||
ingress, exact inclusive boundaries, encoded and decoded 413 rejection,
|
||||
ordinary and 163-container Docker/Podman fleets, and typed compression
|
||||
@@ -6316,6 +6325,15 @@ request/response surface is the Pulse Unified Agent route family, while
|
||||
`/api/agents/host/*` stays a compatibility alias and must not leak back into
|
||||
handler naming, router-owned state, or proof labels as if it were a second
|
||||
product-facing API surface.
|
||||
The optional split listener must carry that report family and the exact agent
|
||||
lifecycle routes `/api/agent/ws`, `/api/agent/version`, `/api/server/info`,
|
||||
`/install.sh`, `/install.ps1`, and `/download/pulse-agent`; allowing reports
|
||||
while returning `404` for the command WebSocket is an invalid partial control
|
||||
plane. The connections response must not infer `fleet.remoteControl="enabled"`
|
||||
from the report flag alone. It projects `disconnected` when command policy is
|
||||
applied enabled but the report token has no current admitted session in the
|
||||
request organization, and returns a blocked command-policy reason so API and
|
||||
Agent Doctor clients cannot present report health as action readiness.
|
||||
That confirmation marker must survive the legacy setup-script transport too:
|
||||
script-generated `/api/auto-register` payloads must send `source="script"`,
|
||||
and canonical callers must send that source explicitly, so later canonical
|
||||
|
||||
@@ -834,6 +834,14 @@ shell clickable behind another overlay.
|
||||
|
||||
## Completion Obligations
|
||||
|
||||
Command-channel routing must remain bounded by tenant-scoped session keys.
|
||||
Router consumers may enumerate only the current organization and must resolve
|
||||
token or hostname aliases to exactly one revalidated live connection before
|
||||
dispatch. Reconnect replacement and token invalidation remove the indexed
|
||||
session without fleet-wide retry loops, and pending request correlation uses
|
||||
the same scoped key so concurrent tenants can reuse local agent/request IDs
|
||||
without contention or cross-delivery.
|
||||
|
||||
1. Update benchmarks, SLOs, or query-plan tests when hot-path behavior changes
|
||||
2. Update this contract when a new protected hot path is adopted
|
||||
3. Route runtime changes through the explicit performance proof policies in `registry.json`; default fallback proof routing is not allowed
|
||||
|
||||
@@ -471,6 +471,15 @@ the `white_label` branding entitlement.
|
||||
|
||||
## Completion Obligations
|
||||
|
||||
Split-port agent exposure is complete only when its exact allowlist covers
|
||||
report/config routes, command WebSocket admission, version/server bootstrap,
|
||||
and the supported installer/download endpoints while every UI and management
|
||||
route remains unavailable. Registration binds one execution-scoped token to
|
||||
one organization, agent ID, and hostname; the server stores only the resulting
|
||||
non-secret admission, revalidates it before visibility or dispatch, and rejects
|
||||
multi-organization authority, identity drift, replay after migration, revoked
|
||||
tokens, and path-normalization variants.
|
||||
|
||||
1. Update privacy/security docs and the telemetry runtime together when outbound-data behavior changes.
|
||||
2. Keep shared API-contract proof routing aligned whenever auth, token, or telemetry settings payloads change.
|
||||
3. Keep shared frontend settings proof routing aligned whenever security/privacy presentation changes.
|
||||
@@ -1471,17 +1480,31 @@ Stopping the watcher is the synchronization point that lets tests and runtime
|
||||
teardown restore auth/config state without racing a background reload.
|
||||
That same server-bind config boundary now also owns optional agent-ingest
|
||||
network isolation. `internal/config/config.go` may accept
|
||||
`PULSE_AGENT_INGEST_PORT` as a dedicated listener for agent report and
|
||||
management traffic so operators can place `/api/agents/*` on its own network or
|
||||
firewall boundary, but the option must fail closed at validation: the agent
|
||||
`PULSE_AGENT_INGEST_PORT` as a dedicated listener for the bounded agent control
|
||||
plane so operators can place report/config traffic, command WebSocket
|
||||
admission, version checks, and bootstrap downloads on one network or firewall
|
||||
boundary, but the option must fail closed at validation: the agent
|
||||
ingest port stays disabled at `0`, must be a valid `1`-`65535` port, and must
|
||||
differ from both the frontend port and any HTTP redirect port. When that
|
||||
listener is active the runtime must serve only the `/api/agents/*` surface on
|
||||
it and must never expose the web UI or the rest of the REST API through that
|
||||
port, so a port reachable from an untrusted agent network cannot widen into the
|
||||
operator console. Enabling the dedicated port is additive: the main listener
|
||||
keeps serving agent ingest too, so the default single-port deployment and
|
||||
existing agents are unaffected.
|
||||
listener is active the runtime must serve only `/api/agents/*` plus the exact
|
||||
agent-owned `/api/agent/ws`, `/api/agent/version`, `/api/server/info`,
|
||||
`/install.sh`, `/install.ps1`, and `/download/pulse-agent` routes. It must never
|
||||
expose the web UI or management REST API through that port, so a port reachable
|
||||
from an untrusted agent network cannot widen into the operator console.
|
||||
Enabling the dedicated port is additive: the main listener keeps serving the
|
||||
agent control plane too, so the default single-port deployment and existing
|
||||
agents are unaffected.
|
||||
|
||||
Command admission is a separate security fact from report health. A live
|
||||
session is owned by one organization, one token identity, one runtime agent ID,
|
||||
and one hostname. Multi-organization exec tokens are ambiguous and must be
|
||||
rejected; duplicate IDs remain isolated across organizations but fail closed
|
||||
inside one organization when hostnames conflict. Legacy hostname-bound tokens
|
||||
may migrate a synthesized ID to the first observed runtime ID once, after
|
||||
which both identity fields are immutable. Revoked, expired, re-scoped, or
|
||||
re-bound token records invalidate an existing socket before connectivity is
|
||||
reported or work is dispatched. Raw bearer values must not be retained in the
|
||||
session registry.
|
||||
|
||||
Locale-catalog additions for shared mobile copy controls remain contract-neutral
|
||||
to security and privacy only while they preserve every governed token, scope,
|
||||
|
||||
@@ -1613,6 +1613,15 @@ recovery scope, or a storage/recovery-owned secret source.
|
||||
|
||||
## Completion Obligations
|
||||
|
||||
Agent-backed lifecycle recovery must namespace every in-memory pending request,
|
||||
typed result, operation query, and deployment progress subscription by the
|
||||
admitted organization session. That transport namespace must not alter the
|
||||
durable receipt identity: action ID, attempt ID, operation kind/version,
|
||||
request digest, subject, and canonical agent ID remain immutable. Reconnect and
|
||||
restart recovery may query the owning live session for a terminal receipt, but
|
||||
may never resend an ambiguous mutation or accept a response from another
|
||||
tenant/session.
|
||||
|
||||
Legacy RBAC JSON import is an adjacent security-owned migration, not recovery
|
||||
inventory or restore evidence. When shared `internal/api/` construction
|
||||
triggers that import, validation and SQLite writes must complete atomically,
|
||||
@@ -2025,6 +2034,16 @@ Storage and recovery consumers may observe action detail, pending/settled
|
||||
projections, and correlated receipt state, but may not infer restore,
|
||||
verification, rollback, or compensation truth from those transport-only fields
|
||||
or create a local retry path when the core attempt is `receipt_pending`.
|
||||
The adjacent command transport now namespaces live sessions and pending
|
||||
response channels by organization plus agent identity, while durable receipt
|
||||
identity remains the immutable attempt/action/operation/digest/agent tuple.
|
||||
Action dispatch and query contexts must carry the owning organization into
|
||||
that transport; token rotation, server restart, socket replacement, or stale
|
||||
reader cleanup may make the executor unavailable, but must not rewrite the
|
||||
bound attempt, admit a cross-tenant response, replay a committed mutation, or
|
||||
convert report health into receipt evidence. Recovery continues query-only
|
||||
against the same bound operation and fails closed while its admitted session
|
||||
is absent.
|
||||
|
||||
Unified Agent lifecycle fields added to the shared host and connections API are
|
||||
adjacent monitoring/API state only. Applied config fingerprints, updater
|
||||
|
||||
@@ -80,6 +80,7 @@ You can pre-configure Pulse by setting environment variables. Plain text credent
|
||||
```bash
|
||||
# Docker Example
|
||||
docker run -d \
|
||||
-e PULSE_DEPLOYMENT_METHOD=docker_run \
|
||||
-e PULSE_AUTH_USER=admin \
|
||||
-e PULSE_AUTH_PASS=secret123 \
|
||||
rcourtman/pulse:latest
|
||||
@@ -201,7 +202,7 @@ Environment variables take precedence over `system.json`.
|
||||
| ---------- | ------------- | --------- |
|
||||
| `FRONTEND_PORT` | Public listening port (web UI, API, and agent ingest) | `7655` |
|
||||
| `PORT` | **Deprecated** legacy alias for `FRONTEND_PORT`, honored only when `FRONTEND_PORT` is unset. Logs a deprecation warning at startup; switch to `FRONTEND_PORT`. | *(unset)* |
|
||||
| `PULSE_AGENT_INGEST_PORT` | Optional dedicated port that serves **only** agent ingest (`/api/agents/*`), network-isolated from the web UI and the rest of the API. `0` = disabled (single port). See [Split-Port Agent Ingest](#split-port-agent-ingest-network-isolation). | `0` |
|
||||
| `PULSE_AGENT_INGEST_PORT` | Optional dedicated port for the complete agent control plane: reports/config (`/api/agents/*`), command admission (`/api/agent/ws`), version checks, and bootstrap downloads. The web UI and management API stay isolated. `0` = disabled (single port). See [Split-Port Agent Ingest](#split-port-agent-ingest-network-isolation). | `0` |
|
||||
| `LOG_LEVEL` | Log verbosity (see below) | `info` |
|
||||
| `LOG_FORMAT` | Log output format (`auto`, `json`, `console`) | `auto` |
|
||||
| `LOG_FILE` | Log file path (enables file logging) | *(unset)* |
|
||||
@@ -248,7 +249,7 @@ Environment variables take precedence over `system.json`.
|
||||
|
||||
### Split-Port Agent Ingest (Network Isolation)
|
||||
|
||||
By default Pulse serves the web UI, the REST API, and agent check-in together on `FRONTEND_PORT`. For deployments that expose Pulse to monitored hosts across an untrusted network (for example, a managed service provider whose clients' Proxmox nodes reach a central Pulse server over the internet), you can move agent check-in onto its own dedicated port and keep the web UI and management API on a separate, firewalled port.
|
||||
By default Pulse serves the web UI, the REST API, and the agent control plane together on `FRONTEND_PORT`. For deployments that expose Pulse to monitored hosts across an untrusted network (for example, a managed service provider whose clients' Proxmox nodes reach a central Pulse server over the internet), you can expose the agent control plane on its own dedicated port and keep the web UI and management API on a separate, firewalled port.
|
||||
|
||||
Set `PULSE_AGENT_INGEST_PORT` to a port other than `FRONTEND_PORT`:
|
||||
|
||||
@@ -258,7 +259,7 @@ PULSE_AGENT_INGEST_PORT=7656
|
||||
|
||||
When enabled:
|
||||
|
||||
- The dedicated port serves **only** the agent-ingest routes (`/api/agents/*`). Every other path, including the web UI, login, and the management API, returns `404`. A host that can reach the agent port cannot pivot to the management interface.
|
||||
- The dedicated port serves only the agent-owned routes required for a complete lifecycle: `/api/agents/*`, `/api/agent/ws`, `/api/agent/version`, `/api/server/info`, `/install.sh`, `/install.ps1`, and `/download/pulse-agent`. Every other path, including the web UI, login, and management APIs, returns `404`. A host that can reach the agent port cannot pivot to the management interface.
|
||||
- The main `FRONTEND_PORT` listener is unchanged and still serves everything (including agent ingest), so existing single-port installs keep working. The dedicated listener is purely additive.
|
||||
- The value is validated at startup: it must be between 1 and 65535 and must differ from `FRONTEND_PORT` and the HTTP redirect port. An invalid value is rejected.
|
||||
|
||||
@@ -269,7 +270,7 @@ PULSE_AGENT_INGEST_PORT=7656
|
||||
PULSE_AGENT_CONNECT_URL=https://agents.example.com:7656
|
||||
```
|
||||
|
||||
Agents then post telemetry to `https://agents.example.com:7656/api/agents/agent/report`, while the web UI and management API remain reachable only on the private `FRONTEND_PORT` listener.
|
||||
Agents then post telemetry to `https://agents.example.com:7656/api/agents/agent/report` and establish their command channel at `wss://agents.example.com:7656/api/agent/ws`, while the web UI and management API remain reachable only on the private `FRONTEND_PORT` listener. If command execution is enabled, both routes must traverse the same proxy/firewall path; a successful report does not prove that the WebSocket is admitted.
|
||||
|
||||
### Iframe Embedding (system.json)
|
||||
|
||||
@@ -308,6 +309,7 @@ When `allowEmbedding` is `false`, Pulse sends `X-Frame-Options: DENY` and `frame
|
||||
| `PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY` | Allow Proxmox-side minimal LXC Docker inventory collection with `pct exec`; collects Docker host/container summary, not inspect/env/mount/process data | `false` |
|
||||
| `PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS` | Optional comma-separated VMID allowlist for Proxmox-side LXC Docker inventory; empty means all running Docker-enabled LXCs are eligible when inventory is enabled | *(unset)* |
|
||||
| `PULSE_TELEMETRY` | Outbound usage telemetry ([details](PRIVACY.md)); set `false` to disable | `true` |
|
||||
| `PULSE_DEPLOYMENT_METHOD` | Optional closed telemetry label: `docker_compose`, `docker_run`, `container_other`, `systemd`, `binary_other`, or `other`; invalid values are reported only as the safe runtime fallback | Inferred as `container_other` or `binary_other` |
|
||||
|
||||
### Logging Overrides
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ describe('ConnectionsAPI', () => {
|
||||
configRollout: 'reported',
|
||||
credentialStatus: 'verified',
|
||||
updateStatus: 'update-available',
|
||||
remoteControl: 'enabled',
|
||||
remoteControl: 'disconnected',
|
||||
configDrift: {
|
||||
status: 'drifted',
|
||||
desired: { version: 'host-agent-config/v1', hash: 'sha256:desired' },
|
||||
@@ -172,12 +172,11 @@ describe('ConnectionsAPI', () => {
|
||||
lastVerifiedAt: '2026-04-22T20:00:00Z',
|
||||
},
|
||||
commandPolicy: {
|
||||
status: 'enabled',
|
||||
desired: 'disabled',
|
||||
status: 'blocked',
|
||||
desired: 'enabled',
|
||||
applied: 'enabled',
|
||||
enforcement: 'drifted',
|
||||
reason:
|
||||
'agent still reports command execution enabled while desired policy disables it',
|
||||
enforcement: 'blocked',
|
||||
reason: 'agent command channel is not connected',
|
||||
},
|
||||
},
|
||||
capabilities: { supportsPause: false, supportsScope: false, supportsTest: false },
|
||||
@@ -195,7 +194,7 @@ describe('ConnectionsAPI', () => {
|
||||
configRollout: 'reported',
|
||||
credentialStatus: 'verified',
|
||||
updateStatus: 'update-available',
|
||||
remoteControl: 'enabled',
|
||||
remoteControl: 'disconnected',
|
||||
configDrift: {
|
||||
status: 'drifted',
|
||||
desired: { version: 'host-agent-config/v1', hash: 'sha256:desired' },
|
||||
@@ -214,11 +213,11 @@ describe('ConnectionsAPI', () => {
|
||||
lastVerifiedAt: '2026-04-22T20:00:00Z',
|
||||
},
|
||||
commandPolicy: {
|
||||
status: 'enabled',
|
||||
desired: 'disabled',
|
||||
status: 'blocked',
|
||||
desired: 'enabled',
|
||||
applied: 'enabled',
|
||||
enforcement: 'drifted',
|
||||
reason: 'agent still reports command execution enabled while desired policy disables it',
|
||||
enforcement: 'blocked',
|
||||
reason: 'agent command channel is not connected',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,7 +25,8 @@ export type ConnectionFleetUpdateStatus =
|
||||
| 'unknown'
|
||||
| 'update-available'
|
||||
| 'updating';
|
||||
export type ConnectionFleetRemoteControl = 'disabled' | 'enabled' | 'not-applicable' | 'unknown';
|
||||
export type ConnectionFleetRemoteControl =
|
||||
'disabled' | 'disconnected' | 'enabled' | 'not-applicable' | 'unknown';
|
||||
export type ConnectionFleetConfigDriftStatus =
|
||||
'current' | 'drifted' | 'not-applicable' | 'paused' | 'pending' | 'unknown';
|
||||
export type ConnectionFleetRolloutStatus =
|
||||
|
||||
+40
@@ -121,6 +121,46 @@ describe('Agent Doctor model', () => {
|
||||
expect(targets[0].commandBlockedReason).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not report healthy when telemetry is fresh but command admission is disconnected', () => {
|
||||
const connection = connectionFixture({
|
||||
agentUpdateAvailable: false,
|
||||
agentVersion: '6.1.1',
|
||||
expectedAgentVersion: '6.1.1',
|
||||
agentIdentity: {
|
||||
hostname: 'host-1',
|
||||
platform: 'ubuntu',
|
||||
architecture: 'amd64',
|
||||
commandsEnabled: true,
|
||||
},
|
||||
fleet: {
|
||||
versionDrift: 'current',
|
||||
remoteControl: 'disconnected',
|
||||
commandPolicy: {
|
||||
status: 'blocked',
|
||||
desired: 'enabled',
|
||||
applied: 'enabled',
|
||||
enforcement: 'blocked',
|
||||
reason: 'Agent reports commands enabled, but no admitted command channel is connected.',
|
||||
},
|
||||
} as Connection['fleet'],
|
||||
});
|
||||
const [target] = collectInfrastructureAgentDoctorTargets({
|
||||
rows: [rowFixture(connection)],
|
||||
connections: [connection],
|
||||
diagnostics: [diagnosticFixture({ status: 'healthy', reasons: [] })],
|
||||
diagnosticsAvailable: true,
|
||||
targetVersion: '6.1.1',
|
||||
});
|
||||
|
||||
expect(target.status).toBe('critical');
|
||||
expect(target.reasons).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: 'command_channel_disconnected',
|
||||
severity: 'critical',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to ledger classification and blocks commands for unknown platforms', () => {
|
||||
const connection = connectionFixture({
|
||||
state: 'stale',
|
||||
|
||||
@@ -108,11 +108,13 @@ const settingsRuntimeSources = import.meta.glob(['../*.tsx', '../ConnectionEdito
|
||||
describe('settings architecture guardrails', () => {
|
||||
it('keeps Unified Agent lifecycle failures on the shared connections contract', () => {
|
||||
expect(connectionsApiSource).toContain("| 'failed'");
|
||||
expect(connectionsApiSource).toContain("'disabled' | 'disconnected' | 'enabled'");
|
||||
expect(connectionsApiSource).toContain('agentUpdate?: ConnectionAgentUpdateStatus;');
|
||||
expect(connectionsApiSource).toContain('agentModules?: ConnectionAgentModuleStatus[];');
|
||||
expect(connectionsTableModelSource).toContain("key: 'module-health'");
|
||||
expect(connectionsTableModelSource).toContain("label: 'Agent update failed'");
|
||||
expect(connectionsTableModelSource).toContain('update?.lastError');
|
||||
expect(connectionsTableModelSource).toContain("case 'disconnected':");
|
||||
});
|
||||
|
||||
it('keeps Settings on the canonical page shell boundary', () => {
|
||||
|
||||
@@ -321,6 +321,14 @@ const commandPolicyFromFleet = (fleet: ConnectionFleetGovernance): ConnectionFle
|
||||
if (fleet.commandPolicy) return fleet.commandPolicy;
|
||||
|
||||
switch (fleet.remoteControl) {
|
||||
case 'disconnected':
|
||||
return {
|
||||
status: 'blocked',
|
||||
desired: 'unknown',
|
||||
applied: 'enabled',
|
||||
enforcement: 'blocked',
|
||||
reason: 'The agent reports command execution enabled, but no command channel is connected.',
|
||||
};
|
||||
case 'enabled':
|
||||
return {
|
||||
status: 'enabled',
|
||||
|
||||
@@ -333,6 +333,20 @@ const ledgerFallbackReasons = (
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
connection.fleet?.remoteControl === 'disconnected' ||
|
||||
(connection.fleet?.commandPolicy?.status === 'blocked' &&
|
||||
connection.agentIdentity?.commandsEnabled)
|
||||
) {
|
||||
reasons.push(
|
||||
fallbackReason(
|
||||
'command_channel_disconnected',
|
||||
'critical',
|
||||
'The agent is reporting, but its command channel is not connected.',
|
||||
connection.fleet?.commandPolicy?.reason ? [connection.fleet.commandPolicy.reason] : [],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (needsUpdate) {
|
||||
reasons.push(
|
||||
fallbackReason(
|
||||
@@ -463,7 +477,17 @@ const doctorTargetFromBinding = (
|
||||
const expectedVersion = expectedVersionFor(connection, targetVersion);
|
||||
const needsUpdate = connectionNeedsUpdate(connection, expectedVersion);
|
||||
const fallbackReasons = ledgerFallbackReasons(connection, needsUpdate);
|
||||
const reasons = diagnosticsAvailable && diagnostic ? (diagnostic.reasons ?? []) : fallbackReasons;
|
||||
const reasons =
|
||||
diagnosticsAvailable && diagnostic
|
||||
? Array.from(
|
||||
new Map(
|
||||
[
|
||||
...fallbackReasons.filter((reason) => reason.code === 'command_channel_disconnected'),
|
||||
...(diagnostic.reasons ?? []),
|
||||
].map((reason) => [reason.code, reason]),
|
||||
).values(),
|
||||
)
|
||||
: fallbackReasons;
|
||||
const updater = updaterPresentation(connection, diagnostic, needsUpdate);
|
||||
let status: InfrastructureAgentDoctorStatus =
|
||||
diagnosticsAvailable && diagnostic
|
||||
@@ -472,7 +496,12 @@ const doctorTargetFromBinding = (
|
||||
|
||||
// The ledger can advance one poll ahead of diagnostics. Never hide a live
|
||||
// update/error signal while the structured endpoint catches up.
|
||||
if (status === 'healthy' && fallbackReasons.length > 0) status = 'warning';
|
||||
const fallbackDoctorStatus = fallbackStatus(connection, fallbackReasons);
|
||||
if (fallbackDoctorStatus === 'critical') {
|
||||
status = 'critical';
|
||||
} else if (status === 'healthy' && fallbackReasons.length > 0) {
|
||||
status = 'warning';
|
||||
}
|
||||
|
||||
const nonVersionReasons = reasons.filter((reason) => reason.code !== 'agent_version_stale');
|
||||
if (
|
||||
|
||||
@@ -27,7 +27,7 @@ func registeredTestAgent(t *testing.T, s *Server, agentID string) (*websocket.Co
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wsWriteMessage(t, conn, mustNewMessage(t, MsgTypeAgentRegister, "", AgentRegisterPayload{AgentID: agentID, Hostname: "host", Version: "6", Platform: "linux", Token: "ok", OperationReceiptVersion: operationreceipt.ProtocolVersion}))
|
||||
wsWriteMessage(t, conn, mustNewMessage(t, MsgTypeAgentRegister, "", AgentRegisterPayload{AgentID: agentID, Hostname: agentID + "-host", Version: "6", Platform: "linux", Token: "ok", OperationReceiptVersion: operationreceipt.ProtocolVersion}))
|
||||
if !wsReadRegisteredPayload(t, conn).Success {
|
||||
t.Fatal("registration failed")
|
||||
}
|
||||
|
||||
+418
-87
@@ -62,7 +62,7 @@ var hostStorageCleanupFingerprintPattern = regexp.MustCompile(`^sha256:[a-f0-9]{
|
||||
// Server manages WebSocket connections from agents
|
||||
type Server struct {
|
||||
mu sync.RWMutex
|
||||
agents map[string]*agentConn // agentID -> connection
|
||||
agents map[string]*agentConn // organizationID + agentID -> connection
|
||||
pendingReqs map[string]chan CommandResultPayload // scoped request key -> response channel
|
||||
pendingHostStorageCleanups map[string]chan HostStorageCleanupResultPayload // scoped request key -> typed storage-cleanup response
|
||||
pendingHostUpdates map[string]chan HostUpdateResultPayload // scoped request key -> typed host-update response
|
||||
@@ -71,7 +71,8 @@ type Server struct {
|
||||
pendingHostOperations map[string]pendingHostOperation // scoped request key -> exact typed APT operation/query identity
|
||||
pendingOperationQueries map[string]pendingOperationQuery
|
||||
deploySubs map[string]chan DeployProgressPayload // deploySubKey(agentID, jobID) -> progress subscriber
|
||||
validateToken func(token string, agentID string, hostname string) bool
|
||||
admitToken AgentRegistrationValidator
|
||||
validateSession AgentSessionValidator
|
||||
commandPolicy *CommandPolicy
|
||||
ipConnCounts map[string]int
|
||||
maxConnsPerIP int
|
||||
@@ -81,9 +82,31 @@ type Server struct {
|
||||
commandAuthorizationVerifier func(CommandAuthorizationRequest) error
|
||||
newCommandApprovalGrant func([]byte, string, ExecuteCommandPayload, time.Time, time.Duration) (*CommandApprovalGrant, error)
|
||||
now func() time.Time
|
||||
agentRegisteredNotifier func(agentID string)
|
||||
agentRegisteredNotifier func(AgentAdmission)
|
||||
}
|
||||
|
||||
const defaultOrganizationID = "default"
|
||||
|
||||
type organizationContextKey struct{}
|
||||
|
||||
// AgentAdmission is the immutable server-owned identity of an admitted command
|
||||
// session. The raw bearer token is deliberately not retained after
|
||||
// registration.
|
||||
type AgentAdmission struct {
|
||||
OrganizationID string
|
||||
TokenID string
|
||||
AgentID string
|
||||
Hostname string
|
||||
}
|
||||
|
||||
// AgentRegistrationValidator authenticates and binds a registration to one
|
||||
// organization, token, agent identity, and hostname.
|
||||
type AgentRegistrationValidator func(token string, agentID string, hostname string) (AgentAdmission, bool)
|
||||
|
||||
// AgentSessionValidator revalidates the non-secret admission immediately
|
||||
// before the server treats a socket as connected or dispatches work to it.
|
||||
type AgentSessionValidator func(AgentAdmission) bool
|
||||
|
||||
// CommandAuthorizationRequest is the complete server-side approval scope
|
||||
// verified and consumed immediately before an approval grant is signed.
|
||||
type CommandAuthorizationRequest struct {
|
||||
@@ -99,6 +122,8 @@ type CommandAuthorizationRequest struct {
|
||||
type agentConn struct {
|
||||
conn *websocket.Conn
|
||||
agent ConnectedAgent
|
||||
admission AgentAdmission
|
||||
sessionKey string
|
||||
approvalGrantKey []byte
|
||||
writeMu sync.Mutex
|
||||
done chan struct{}
|
||||
@@ -143,6 +168,26 @@ func NewServer(validateToken func(token string, agentID string, hostname string)
|
||||
panic("agentexec: validateToken is required")
|
||||
}
|
||||
|
||||
return NewServerWithAdmissionValidator(func(token string, agentID string, hostname string) (AgentAdmission, bool) {
|
||||
if !validateToken(token, agentID, hostname) {
|
||||
return AgentAdmission{}, false
|
||||
}
|
||||
return AgentAdmission{
|
||||
OrganizationID: defaultOrganizationID,
|
||||
AgentID: strings.TrimSpace(agentID),
|
||||
Hostname: strings.TrimSpace(hostname),
|
||||
}, true
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// NewServerWithAdmissionValidator creates a command server whose sessions are
|
||||
// tenant-scoped and can be invalidated after registration without retaining
|
||||
// bearer tokens in memory.
|
||||
func NewServerWithAdmissionValidator(admit AgentRegistrationValidator, validateSession AgentSessionValidator) *Server {
|
||||
if admit == nil {
|
||||
panic("agentexec: admission validator is required")
|
||||
}
|
||||
|
||||
return &Server{
|
||||
agents: make(map[string]*agentConn),
|
||||
pendingReqs: make(map[string]chan CommandResultPayload),
|
||||
@@ -153,7 +198,8 @@ func NewServer(validateToken func(token string, agentID string, hostname string)
|
||||
pendingHostOperations: make(map[string]pendingHostOperation),
|
||||
pendingOperationQueries: make(map[string]pendingOperationQuery),
|
||||
deploySubs: make(map[string]chan DeployProgressPayload),
|
||||
validateToken: validateToken,
|
||||
admitToken: admit,
|
||||
validateSession: validateSession,
|
||||
commandPolicy: DefaultPolicy(),
|
||||
ipConnCounts: make(map[string]int),
|
||||
maxConnsPerIP: defaultMaxWebSocketConnectionsPerIP,
|
||||
@@ -164,6 +210,83 @@ func NewServer(validateToken func(token string, agentID string, hostname string)
|
||||
}
|
||||
}
|
||||
|
||||
// WithOrganizationID scopes command-session lookup and dispatch to a tenant.
|
||||
// Empty values normalize to the single-tenant default for compatibility.
|
||||
func WithOrganizationID(ctx context.Context, organizationID string) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return context.WithValue(ctx, organizationContextKey{}, normalizeOrganizationID(organizationID))
|
||||
}
|
||||
|
||||
func normalizeOrganizationID(organizationID string) string {
|
||||
if organizationID = strings.TrimSpace(organizationID); organizationID != "" {
|
||||
return organizationID
|
||||
}
|
||||
return defaultOrganizationID
|
||||
}
|
||||
|
||||
func organizationIDFromContext(ctx context.Context) string {
|
||||
if ctx != nil {
|
||||
if organizationID, ok := ctx.Value(organizationContextKey{}).(string); ok {
|
||||
return normalizeOrganizationID(organizationID)
|
||||
}
|
||||
}
|
||||
return defaultOrganizationID
|
||||
}
|
||||
|
||||
// OrganizationServer is a tenant-pinned view of a command server. It exists
|
||||
// for consumers whose interface cannot carry the request context through
|
||||
// discovery and dispatch as separate calls (for example, long-lived per-tenant
|
||||
// Assistant services).
|
||||
type OrganizationServer struct {
|
||||
server *Server
|
||||
organizationID string
|
||||
}
|
||||
|
||||
// ForOrganization returns a command-server view that can only discover and
|
||||
// dispatch sessions admitted to organizationID.
|
||||
func (s *Server) ForOrganization(organizationID string) *OrganizationServer {
|
||||
return &OrganizationServer{
|
||||
server: s,
|
||||
organizationID: normalizeOrganizationID(organizationID),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *OrganizationServer) GetConnectedAgents() []ConnectedAgent {
|
||||
if s == nil || s.server == nil {
|
||||
return nil
|
||||
}
|
||||
return s.server.GetConnectedAgentsForOrganization(s.organizationID)
|
||||
}
|
||||
|
||||
func (s *OrganizationServer) ExecuteCommand(ctx context.Context, agentID string, cmd ExecuteCommandPayload) (*CommandResultPayload, error) {
|
||||
if s == nil || s.server == nil {
|
||||
return nil, fmt.Errorf("agent execution server is unavailable")
|
||||
}
|
||||
return s.server.ExecuteCommand(WithOrganizationID(ctx, s.organizationID), agentID, cmd)
|
||||
}
|
||||
|
||||
func agentSessionKey(organizationID, agentID string) string {
|
||||
organizationID = normalizeOrganizationID(organizationID)
|
||||
agentID = strings.TrimSpace(agentID)
|
||||
if organizationID == defaultOrganizationID {
|
||||
// Preserve the historical key for direct single-tenant users and tests.
|
||||
return agentID
|
||||
}
|
||||
return organizationID + "\x00" + agentID
|
||||
}
|
||||
|
||||
func connectionSessionKey(ac *agentConn) string {
|
||||
if ac == nil {
|
||||
return ""
|
||||
}
|
||||
if strings.TrimSpace(ac.sessionKey) != "" {
|
||||
return ac.sessionKey
|
||||
}
|
||||
return agentSessionKey(ac.admission.OrganizationID, ac.agent.AgentID)
|
||||
}
|
||||
|
||||
// SetCommandAuthorizationVerifier installs the server-owned authorization
|
||||
// consumer used for approval-gated arbitrary commands.
|
||||
func (s *Server) SetCommandAuthorizationVerifier(verifier func(CommandAuthorizationRequest) error) {
|
||||
@@ -181,6 +304,20 @@ func (s *Server) SetCommandAuthorizationVerifier(verifier func(CommandAuthorizat
|
||||
// goroutine because the query response can only be read once this server
|
||||
// enters the connection's read loop.
|
||||
func (s *Server) SetAgentRegisteredNotifier(notify func(agentID string)) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if notify == nil {
|
||||
s.agentRegisteredNotifier = nil
|
||||
return
|
||||
}
|
||||
s.agentRegisteredNotifier = func(admission AgentAdmission) {
|
||||
notify(admission.AgentID)
|
||||
}
|
||||
}
|
||||
|
||||
// SetAgentAdmissionNotifier installs the tenant-aware registration callback.
|
||||
func (s *Server) SetAgentAdmissionNotifier(notify func(AgentAdmission)) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
@@ -200,6 +337,40 @@ func pendingRequestKey(agentID, requestID string) string {
|
||||
return agentID + "\x00" + requestID
|
||||
}
|
||||
|
||||
func (s *Server) connectionForOrganization(organizationID, agentID string) (*agentConn, bool) {
|
||||
if s == nil {
|
||||
return nil, false
|
||||
}
|
||||
key := agentSessionKey(organizationID, agentID)
|
||||
s.mu.RLock()
|
||||
ac, ok := s.agents[key]
|
||||
s.mu.RUnlock()
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if s.validateSession == nil || s.validateSession(ac.admission) {
|
||||
return ac, true
|
||||
}
|
||||
|
||||
// A revoked, expired, re-bound, or otherwise stale token must stop being a
|
||||
// command authority immediately. Pointer equality prevents an old
|
||||
// validation result from evicting a replacement session.
|
||||
s.mu.Lock()
|
||||
if current, exists := s.agents[key]; exists && current == ac {
|
||||
delete(s.agents, key)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
ac.signalDone()
|
||||
if ac.conn != nil {
|
||||
_ = ac.conn.Close()
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (s *Server) connectionForContext(ctx context.Context, agentID string) (*agentConn, bool) {
|
||||
return s.connectionForOrganization(organizationIDFromContext(ctx), agentID)
|
||||
}
|
||||
|
||||
func (s *Server) claimPendingHostOperation(agentID, requestID, actionID, operation string) (string, error) {
|
||||
key := pendingRequestKey(agentID, requestID)
|
||||
s.mu.Lock()
|
||||
@@ -223,11 +394,15 @@ func (s *Server) matchesPendingHostOperation(agentID, requestID, actionID, opera
|
||||
}
|
||||
|
||||
func (s *Server) claimPendingDockerOperation(identity operationreceipt.Identity, containerID string) (string, error) {
|
||||
return s.claimPendingDockerOperationForSession(identity.AgentID, identity, containerID)
|
||||
}
|
||||
|
||||
func (s *Server) claimPendingDockerOperationForSession(sessionKey string, identity operationreceipt.Identity, containerID string) (string, error) {
|
||||
identity, err := operationreceipt.NormalizeIdentity(identity)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
key := pendingRequestKey(identity.AgentID, identity.AttemptID)
|
||||
key := pendingRequestKey(sessionKey, identity.AttemptID)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, exists := s.pendingHostOperations[key]; exists {
|
||||
@@ -238,7 +413,11 @@ func (s *Server) claimPendingDockerOperation(identity operationreceipt.Identity,
|
||||
}
|
||||
|
||||
func (s *Server) matchesPendingDockerOperation(agentID string, result DockerContainerLifecycleResultPayload) bool {
|
||||
key := pendingRequestKey(agentID, result.RequestID)
|
||||
return s.matchesPendingDockerOperationForSession(agentID, agentID, result)
|
||||
}
|
||||
|
||||
func (s *Server) matchesPendingDockerOperationForSession(sessionKey, agentID string, result DockerContainerLifecycleResultPayload) bool {
|
||||
key := pendingRequestKey(sessionKey, result.RequestID)
|
||||
s.mu.RLock()
|
||||
expected, ok := s.pendingHostOperations[key]
|
||||
s.mu.RUnlock()
|
||||
@@ -247,7 +426,11 @@ func (s *Server) matchesPendingDockerOperation(agentID string, result DockerCont
|
||||
}
|
||||
|
||||
func (s *Server) matchesPendingDockerUpdateOperation(agentID string, result DockerContainerUpdateResultPayload) bool {
|
||||
key := pendingRequestKey(agentID, result.RequestID)
|
||||
return s.matchesPendingDockerUpdateOperationForSession(agentID, agentID, result)
|
||||
}
|
||||
|
||||
func (s *Server) matchesPendingDockerUpdateOperationForSession(sessionKey, agentID string, result DockerContainerUpdateResultPayload) bool {
|
||||
key := pendingRequestKey(sessionKey, result.RequestID)
|
||||
s.mu.RLock()
|
||||
expected, ok := s.pendingHostOperations[key]
|
||||
s.mu.RUnlock()
|
||||
@@ -723,8 +906,24 @@ func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate token
|
||||
if !s.validateToken(reg.Token, reg.AgentID, reg.Hostname) {
|
||||
// Validate and canonicalize the command-session admission. Reporting and
|
||||
// command admission are intentionally separate trust decisions.
|
||||
admission, admitted := s.admitToken(reg.Token, reg.AgentID, reg.Hostname)
|
||||
admission.OrganizationID = normalizeOrganizationID(admission.OrganizationID)
|
||||
admission.TokenID = strings.TrimSpace(admission.TokenID)
|
||||
admission.AgentID = strings.TrimSpace(admission.AgentID)
|
||||
admission.Hostname = strings.TrimSpace(admission.Hostname)
|
||||
if admission.AgentID == "" {
|
||||
admission.AgentID = reg.AgentID
|
||||
}
|
||||
if admission.Hostname == "" {
|
||||
admission.Hostname = strings.TrimSpace(reg.Hostname)
|
||||
}
|
||||
if admission.AgentID != reg.AgentID ||
|
||||
!unifiedresources.HostnamesEquivalent(admission.Hostname, reg.Hostname) {
|
||||
admitted = false
|
||||
}
|
||||
if !admitted {
|
||||
log.Warn().Str("agent_id", reg.AgentID).Msg("Agent registration rejected: invalid token")
|
||||
// Actionable message instead of a bare "Invalid token": the agent logs
|
||||
// this verbatim, and the dominant causes (token not recognised, or not
|
||||
@@ -748,14 +947,18 @@ func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
ac := &agentConn{
|
||||
conn: conn,
|
||||
agent: ConnectedAgent{
|
||||
AgentID: reg.AgentID,
|
||||
Hostname: reg.Hostname,
|
||||
OrganizationID: admission.OrganizationID,
|
||||
TokenID: admission.TokenID,
|
||||
AgentID: admission.AgentID,
|
||||
Hostname: admission.Hostname,
|
||||
Version: reg.Version,
|
||||
Platform: reg.Platform,
|
||||
Tags: reg.Tags,
|
||||
ConnectedAt: time.Now(),
|
||||
OperationReceiptVersion: reg.OperationReceiptVersion,
|
||||
},
|
||||
admission: admission,
|
||||
sessionKey: agentSessionKey(admission.OrganizationID, admission.AgentID),
|
||||
approvalGrantKey: DeriveApprovalGrantKey(reg.Token),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
@@ -789,23 +992,59 @@ func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Register agent - after this point, other goroutines can access the connection
|
||||
s.mu.Lock()
|
||||
// Close existing connection if any
|
||||
if existing, ok := s.agents[reg.AgentID]; ok {
|
||||
log.Info().
|
||||
Str("agent_id", reg.AgentID).
|
||||
Str("hostname", reg.Hostname).
|
||||
Msg("Replacing existing agent connection")
|
||||
close(existing.done)
|
||||
if err := existing.conn.Close(); err != nil {
|
||||
log.Debug().Err(err).Str("agent_id", reg.AgentID).Msg("Failed to close existing connection during reconnect")
|
||||
for key, existing := range s.agents {
|
||||
if key != ac.sessionKey &&
|
||||
normalizeOrganizationID(existing.admission.OrganizationID) == admission.OrganizationID &&
|
||||
unifiedresources.HostnamesEquivalent(existing.agent.Hostname, ac.agent.Hostname) {
|
||||
s.mu.Unlock()
|
||||
log.Warn().
|
||||
Str("organization_id", admission.OrganizationID).
|
||||
Str("connected_agent_id", existing.agent.AgentID).
|
||||
Str("requested_agent_id", admission.AgentID).
|
||||
Str("hostname", admission.Hostname).
|
||||
Msg("Agent registration rejected: hostname is already owned by another command identity")
|
||||
rejectedMsg, err := NewMessage(MsgTypeRegistered, "", RegisteredPayload{Success: false, Message: "agent hostname is already connected under another identity"})
|
||||
if err == nil {
|
||||
_ = s.sendMessage(conn, rejectedMsg)
|
||||
}
|
||||
closeConn("Failed to close duplicate agent hostname connection")
|
||||
return
|
||||
}
|
||||
}
|
||||
s.agents[reg.AgentID] = ac
|
||||
// Close existing connection if any
|
||||
if existing, ok := s.agents[ac.sessionKey]; ok {
|
||||
if !unifiedresources.HostnamesEquivalent(existing.agent.Hostname, ac.agent.Hostname) {
|
||||
s.mu.Unlock()
|
||||
log.Warn().
|
||||
Str("organization_id", admission.OrganizationID).
|
||||
Str("agent_id", admission.AgentID).
|
||||
Str("connected_hostname", existing.agent.Hostname).
|
||||
Str("requested_hostname", admission.Hostname).
|
||||
Msg("Agent registration rejected: duplicate identity is already connected from another host")
|
||||
rejectedMsg, err := NewMessage(MsgTypeRegistered, "", RegisteredPayload{Success: false, Message: "agent identity is already connected from another host"})
|
||||
if err == nil {
|
||||
_ = s.sendMessage(conn, rejectedMsg)
|
||||
}
|
||||
closeConn("Failed to close duplicate agent identity connection")
|
||||
return
|
||||
}
|
||||
log.Info().
|
||||
Str("organization_id", admission.OrganizationID).
|
||||
Str("agent_id", admission.AgentID).
|
||||
Str("hostname", admission.Hostname).
|
||||
Msg("Replacing existing agent connection")
|
||||
existing.signalDone()
|
||||
if err := existing.conn.Close(); err != nil {
|
||||
log.Debug().Err(err).Str("agent_id", admission.AgentID).Msg("Failed to close existing connection during reconnect")
|
||||
}
|
||||
}
|
||||
s.agents[ac.sessionKey] = ac
|
||||
s.mu.Unlock()
|
||||
|
||||
log.Info().
|
||||
Str("agent_id", reg.AgentID).
|
||||
Str("hostname", reg.Hostname).
|
||||
Str("organization_id", admission.OrganizationID).
|
||||
Str("agent_id", admission.AgentID).
|
||||
Str("hostname", admission.Hostname).
|
||||
Str("version", reg.Version).
|
||||
Str("platform", reg.Platform).
|
||||
Msg("Agent connected")
|
||||
@@ -824,6 +1063,15 @@ func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
Str("agent_id", reg.AgentID).
|
||||
Str("hostname", reg.Hostname).
|
||||
Msg("Failed to send registration ack")
|
||||
ac.writeMu.Unlock()
|
||||
s.mu.Lock()
|
||||
if existing, ok := s.agents[ac.sessionKey]; ok && existing == ac {
|
||||
delete(s.agents, ac.sessionKey)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
ac.signalDone()
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
ac.writeMu.Unlock()
|
||||
|
||||
@@ -833,7 +1081,7 @@ func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
defer close(pingDone)
|
||||
|
||||
if notify := s.agentRegisteredNotifier; notify != nil {
|
||||
go notify(reg.AgentID)
|
||||
go notify(admission)
|
||||
}
|
||||
|
||||
// Run read loop (blocking) - don't use goroutine, or HTTP handler will close connection
|
||||
@@ -843,18 +1091,23 @@ func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) readLoop(ac *agentConn) {
|
||||
defer func() {
|
||||
agentID := ac.agent.AgentID
|
||||
sessionKey := connectionSessionKey(ac)
|
||||
s.mu.Lock()
|
||||
if existing, ok := s.agents[agentID]; ok && existing == ac {
|
||||
delete(s.agents, agentID)
|
||||
existing, sessionExists := s.agents[sessionKey]
|
||||
ownsSession := !sessionExists || existing == ac
|
||||
if sessionExists && existing == ac {
|
||||
delete(s.agents, sessionKey)
|
||||
}
|
||||
// Close all deploy progress subscriptions for this agent so
|
||||
// processPreflightProgress goroutines unblock and detect disconnect.
|
||||
var closeChs []chan DeployProgressPayload
|
||||
prefix := agentID + "\x00"
|
||||
for key, ch := range s.deploySubs {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
closeChs = append(closeChs, ch)
|
||||
delete(s.deploySubs, key)
|
||||
if ownsSession {
|
||||
prefix := sessionKey + "\x00"
|
||||
for key, ch := range s.deploySubs {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
closeChs = append(closeChs, ch)
|
||||
delete(s.deploySubs, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
@@ -926,7 +1179,7 @@ func (s *Server) readLoop(ac *agentConn) {
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
ch, ok := s.pendingReqs[pendingRequestKey(ac.agent.AgentID, result.RequestID)]
|
||||
ch, ok := s.pendingReqs[pendingRequestKey(connectionSessionKey(ac), result.RequestID)]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if ok {
|
||||
@@ -958,12 +1211,12 @@ func (s *Server) readLoop(ac *agentConn) {
|
||||
log.Warn().Err(decodeErr).Str("agent_id", ac.agent.AgentID).Msg("Dropping invalid host update result")
|
||||
continue
|
||||
}
|
||||
if !s.matchesPendingHostOperation(ac.agent.AgentID, result.RequestID, result.ActionID, HostUpdateOperationInstall) {
|
||||
if !s.matchesPendingHostOperation(connectionSessionKey(ac), result.RequestID, result.ActionID, HostUpdateOperationInstall) {
|
||||
log.Warn().Str("agent_id", ac.agent.AgentID).Str("request_id", result.RequestID).Msg("Dropping uncorrelated host update result")
|
||||
continue
|
||||
}
|
||||
s.mu.RLock()
|
||||
ch, ok := s.pendingHostUpdates[pendingRequestKey(ac.agent.AgentID, result.RequestID)]
|
||||
ch, ok := s.pendingHostUpdates[pendingRequestKey(connectionSessionKey(ac), result.RequestID)]
|
||||
s.mu.RUnlock()
|
||||
if ok {
|
||||
select {
|
||||
@@ -979,12 +1232,12 @@ func (s *Server) readLoop(ac *agentConn) {
|
||||
log.Warn().Err(decodeErr).Str("agent_id", ac.agent.AgentID).Msg("Dropping invalid host storage cleanup result")
|
||||
continue
|
||||
}
|
||||
if !s.matchesPendingHostOperation(ac.agent.AgentID, result.RequestID, result.ActionID, HostStorageCleanupOperationPackageCache) {
|
||||
if !s.matchesPendingHostOperation(connectionSessionKey(ac), result.RequestID, result.ActionID, HostStorageCleanupOperationPackageCache) {
|
||||
log.Warn().Str("agent_id", ac.agent.AgentID).Str("request_id", result.RequestID).Msg("Dropping uncorrelated host storage cleanup result")
|
||||
continue
|
||||
}
|
||||
s.mu.RLock()
|
||||
ch, ok := s.pendingHostStorageCleanups[pendingRequestKey(ac.agent.AgentID, result.RequestID)]
|
||||
ch, ok := s.pendingHostStorageCleanups[pendingRequestKey(connectionSessionKey(ac), result.RequestID)]
|
||||
s.mu.RUnlock()
|
||||
if ok {
|
||||
select {
|
||||
@@ -1000,12 +1253,12 @@ func (s *Server) readLoop(ac *agentConn) {
|
||||
log.Warn().Err(decodeErr).Str("agent_id", ac.agent.AgentID).Msg("Dropping invalid docker container lifecycle result")
|
||||
continue
|
||||
}
|
||||
if !s.matchesPendingDockerOperation(ac.agent.AgentID, result) {
|
||||
if !s.matchesPendingDockerOperationForSession(connectionSessionKey(ac), ac.agent.AgentID, result) {
|
||||
log.Warn().Str("agent_id", ac.agent.AgentID).Str("request_id", result.RequestID).Msg("Dropping uncorrelated docker lifecycle result")
|
||||
continue
|
||||
}
|
||||
s.mu.RLock()
|
||||
ch, ok := s.pendingDockerContainerLifecycles[pendingRequestKey(ac.agent.AgentID, result.RequestID)]
|
||||
ch, ok := s.pendingDockerContainerLifecycles[pendingRequestKey(connectionSessionKey(ac), result.RequestID)]
|
||||
s.mu.RUnlock()
|
||||
if ok {
|
||||
select {
|
||||
@@ -1021,12 +1274,12 @@ func (s *Server) readLoop(ac *agentConn) {
|
||||
log.Warn().Err(decodeErr).Str("agent_id", ac.agent.AgentID).Msg("Dropping invalid docker container update result")
|
||||
continue
|
||||
}
|
||||
if !s.matchesPendingDockerUpdateOperation(ac.agent.AgentID, result) {
|
||||
if !s.matchesPendingDockerUpdateOperationForSession(connectionSessionKey(ac), ac.agent.AgentID, result) {
|
||||
log.Warn().Str("agent_id", ac.agent.AgentID).Str("request_id", result.RequestID).Msg("Dropping uncorrelated docker update result")
|
||||
continue
|
||||
}
|
||||
s.mu.RLock()
|
||||
ch, ok := s.pendingDockerContainerUpdates[pendingRequestKey(ac.agent.AgentID, result.RequestID)]
|
||||
ch, ok := s.pendingDockerContainerUpdates[pendingRequestKey(connectionSessionKey(ac), result.RequestID)]
|
||||
s.mu.RUnlock()
|
||||
if ok {
|
||||
select {
|
||||
@@ -1042,7 +1295,7 @@ func (s *Server) readLoop(ac *agentConn) {
|
||||
log.Warn().Err(decodeErr).Str("agent_id", ac.agent.AgentID).Msg("Dropping invalid operation query result")
|
||||
continue
|
||||
}
|
||||
key := pendingRequestKey(ac.agent.AgentID, strings.TrimSpace(msg.ID))
|
||||
key := pendingRequestKey(connectionSessionKey(ac), strings.TrimSpace(msg.ID))
|
||||
s.mu.RLock()
|
||||
pending, ok := s.pendingOperationQueries[key]
|
||||
s.mu.RUnlock()
|
||||
@@ -1072,7 +1325,7 @@ func (s *Server) readLoop(ac *agentConn) {
|
||||
continue
|
||||
}
|
||||
|
||||
subKey := deploySubKey(ac.agent.AgentID, progress.JobID)
|
||||
subKey := deploySubKey(connectionSessionKey(ac), progress.JobID)
|
||||
|
||||
// Hold the read lock across map lookup AND the non-blocking send to
|
||||
// prevent UnsubscribeDeployProgress from closing the channel between
|
||||
@@ -1257,10 +1510,7 @@ func (s *Server) ExecuteCommand(ctx context.Context, agentID string, cmd Execute
|
||||
|
||||
startedAt := time.Now()
|
||||
|
||||
s.mu.RLock()
|
||||
ac, ok := s.agents[agentID]
|
||||
s.mu.RUnlock()
|
||||
|
||||
ac, ok := s.connectionForContext(ctx, agentID)
|
||||
if !ok {
|
||||
log.Warn().
|
||||
Str("agent_id", agentID).
|
||||
@@ -1307,8 +1557,12 @@ func (s *Server) ExecuteCommand(ctx context.Context, agentID string, cmd Execute
|
||||
|
||||
// Create response channel
|
||||
respCh := make(chan CommandResultPayload, 1)
|
||||
reqKey := pendingRequestKey(agentID, cmd.RequestID)
|
||||
reqKey := pendingRequestKey(connectionSessionKey(ac), cmd.RequestID)
|
||||
s.mu.Lock()
|
||||
if _, exists := s.pendingReqs[reqKey]; exists {
|
||||
s.mu.Unlock()
|
||||
return nil, fmt.Errorf("command request %q is already pending", cmd.RequestID)
|
||||
}
|
||||
s.pendingReqs[reqKey] = respCh
|
||||
s.mu.Unlock()
|
||||
|
||||
@@ -1360,7 +1614,7 @@ func (s *Server) ExecuteCommand(ctx context.Context, agentID string, cmd Execute
|
||||
Dur("duration", time.Since(startedAt)).
|
||||
Msg("Agent command completed")
|
||||
return &result, nil
|
||||
case <-time.After(timeout):
|
||||
case <-timer.C:
|
||||
execLog.Warn().
|
||||
Dur("timeout", timeout).
|
||||
Dur("duration", time.Since(startedAt)).
|
||||
@@ -1372,6 +1626,8 @@ func (s *Server) ExecuteCommand(ctx context.Context, agentID string, cmd Execute
|
||||
Dur("duration", time.Since(startedAt)).
|
||||
Msg("Agent command canceled")
|
||||
return nil, ctx.Err()
|
||||
case <-ac.done:
|
||||
return nil, fmt.Errorf("agent %s disconnected before command result", agentID)
|
||||
case <-s.shutdown:
|
||||
return nil, errServerShuttingDown
|
||||
}
|
||||
@@ -1426,9 +1682,7 @@ func prepareHostOperationRequest(s *Server, agentID string, requestID *string, b
|
||||
func dispatchHostOperation[Req hostOperationPayload, Res any](ctx context.Context, s *Server, agentID string, req Req, op hostOperationDispatch[Req, Res]) (*Res, error) {
|
||||
requestID, actionID, operation, timeoutSeconds := req.hostOperationIdentity()
|
||||
|
||||
s.mu.RLock()
|
||||
ac, ok := s.agents[agentID]
|
||||
s.mu.RUnlock()
|
||||
ac, ok := s.connectionForContext(ctx, agentID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("agent %s not connected", agentID)
|
||||
}
|
||||
@@ -1437,8 +1691,9 @@ func dispatchHostOperation[Req hostOperationPayload, Res any](ctx context.Contex
|
||||
}
|
||||
|
||||
respCh := make(chan Res, 1)
|
||||
reqKey := pendingRequestKey(agentID, requestID)
|
||||
hostOperationKey, err := s.claimPendingHostOperation(agentID, requestID, actionID, operation)
|
||||
sessionKey := connectionSessionKey(ac)
|
||||
reqKey := pendingRequestKey(sessionKey, requestID)
|
||||
hostOperationKey, err := s.claimPendingHostOperation(sessionKey, requestID, actionID, operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1557,9 +1812,7 @@ func dispatchTypedDockerContainerOperation[Res any](
|
||||
pending map[string]chan Res, label string,
|
||||
validate func(Res) error,
|
||||
) (*Res, error) {
|
||||
s.mu.RLock()
|
||||
ac, ok := s.agents[agentID]
|
||||
s.mu.RUnlock()
|
||||
ac, ok := s.connectionForContext(ctx, agentID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("agent %s not connected", agentID)
|
||||
}
|
||||
@@ -1568,8 +1821,9 @@ func dispatchTypedDockerContainerOperation[Res any](
|
||||
}
|
||||
|
||||
respCh := make(chan Res, 1)
|
||||
reqKey := pendingRequestKey(agentID, requestID)
|
||||
hostOperationKey, err := s.claimPendingDockerOperation(identity, containerID)
|
||||
sessionKey := connectionSessionKey(ac)
|
||||
reqKey := pendingRequestKey(sessionKey, requestID)
|
||||
hostOperationKey, err := s.claimPendingDockerOperationForSession(sessionKey, identity, containerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1652,12 +1906,16 @@ func (s *Server) currentTime() time.Time {
|
||||
}
|
||||
|
||||
func (s *Server) AgentOperationReceiptVersion(agentID string) int {
|
||||
return s.AgentOperationReceiptVersionForOrganization(defaultOrganizationID, agentID)
|
||||
}
|
||||
|
||||
// AgentOperationReceiptVersionForOrganization reports the live protocol
|
||||
// version only for a currently admitted tenant-scoped session.
|
||||
func (s *Server) AgentOperationReceiptVersionForOrganization(organizationID, agentID string) int {
|
||||
if s == nil {
|
||||
return 0
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
connection, ok := s.agents[strings.TrimSpace(agentID)]
|
||||
connection, ok := s.connectionForOrganization(organizationID, agentID)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
@@ -1674,9 +1932,7 @@ func (s *Server) QueryAgentOperation(ctx context.Context, agentID string, identi
|
||||
if identity.AgentID != agentID {
|
||||
return operationreceipt.QueryResult{}, operationreceipt.ErrBindingConflict
|
||||
}
|
||||
s.mu.RLock()
|
||||
ac, ok := s.agents[agentID]
|
||||
s.mu.RUnlock()
|
||||
ac, ok := s.connectionForContext(ctx, agentID)
|
||||
if !ok {
|
||||
return operationreceipt.QueryResult{}, fmt.Errorf("agent %s not connected", agentID)
|
||||
}
|
||||
@@ -1684,7 +1940,7 @@ func (s *Server) QueryAgentOperation(ctx context.Context, agentID string, identi
|
||||
return operationreceipt.QueryResult{}, fmt.Errorf("agent does not support durable operation receipts")
|
||||
}
|
||||
queryID := identity.AttemptID + ".query." + uuid.NewString()
|
||||
key := pendingRequestKey(agentID, queryID)
|
||||
key := pendingRequestKey(connectionSessionKey(ac), queryID)
|
||||
ch := make(chan operationreceipt.QueryResult, 1)
|
||||
s.mu.Lock()
|
||||
if _, exists := s.pendingOperationQueries[key]; exists {
|
||||
@@ -1734,10 +1990,7 @@ func (s *Server) ReadFile(ctx context.Context, agentID string, req ReadFilePaylo
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
ac, ok := s.agents[agentID]
|
||||
s.mu.RUnlock()
|
||||
|
||||
ac, ok := s.connectionForContext(ctx, agentID)
|
||||
if !ok {
|
||||
log.Warn().
|
||||
Str("agent_id", agentID).
|
||||
@@ -1759,8 +2012,12 @@ func (s *Server) ReadFile(ctx context.Context, agentID string, req ReadFilePaylo
|
||||
|
||||
// Create response channel
|
||||
respCh := make(chan CommandResultPayload, 1)
|
||||
reqKey := pendingRequestKey(agentID, req.RequestID)
|
||||
reqKey := pendingRequestKey(connectionSessionKey(ac), req.RequestID)
|
||||
s.mu.Lock()
|
||||
if _, exists := s.pendingReqs[reqKey]; exists {
|
||||
s.mu.Unlock()
|
||||
return nil, fmt.Errorf("read_file request %q is already pending", req.RequestID)
|
||||
}
|
||||
s.pendingReqs[reqKey] = respCh
|
||||
s.mu.Unlock()
|
||||
|
||||
@@ -1819,6 +2076,8 @@ func (s *Server) ReadFile(ctx context.Context, agentID string, req ReadFilePaylo
|
||||
return nil, fmt.Errorf("read_file timed out after %v", timeout)
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("read_file %q on agent %q canceled: %w", req.RequestID, agentID, ctx.Err())
|
||||
case <-ac.done:
|
||||
return nil, fmt.Errorf("agent %s disconnected before read_file result", agentID)
|
||||
case <-s.shutdown:
|
||||
return nil, errServerShuttingDown
|
||||
}
|
||||
@@ -1826,33 +2085,100 @@ func (s *Server) ReadFile(ctx context.Context, agentID string, req ReadFilePaylo
|
||||
|
||||
// GetConnectedAgents returns a list of currently connected agents
|
||||
func (s *Server) GetConnectedAgents() []ConnectedAgent {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.GetConnectedAgentsForOrganization(defaultOrganizationID)
|
||||
}
|
||||
|
||||
agents := make([]ConnectedAgent, 0, len(s.agents))
|
||||
// GetConnectedAgentsForOrganization returns only currently admitted sessions
|
||||
// owned by one tenant.
|
||||
func (s *Server) GetConnectedAgentsForOrganization(organizationID string) []ConnectedAgent {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
organizationID = normalizeOrganizationID(organizationID)
|
||||
s.mu.RLock()
|
||||
ids := make([]string, 0, len(s.agents))
|
||||
for _, ac := range s.agents {
|
||||
agents = append(agents, ac.agent)
|
||||
if normalizeOrganizationID(ac.admission.OrganizationID) == organizationID {
|
||||
ids = append(ids, ac.agent.AgentID)
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
agents := make([]ConnectedAgent, 0, len(ids))
|
||||
for _, agentID := range ids {
|
||||
if ac, ok := s.connectionForOrganization(organizationID, agentID); ok {
|
||||
agents = append(agents, ac.agent)
|
||||
}
|
||||
}
|
||||
return agents
|
||||
}
|
||||
|
||||
// IsAgentConnected checks if an agent is currently connected
|
||||
func (s *Server) IsAgentConnected(agentID string) bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
_, ok := s.agents[agentID]
|
||||
return s.IsAgentConnectedForOrganization(defaultOrganizationID, agentID)
|
||||
}
|
||||
|
||||
// IsAgentConnectedForOrganization checks command-channel admission rather than
|
||||
// telemetry liveness.
|
||||
func (s *Server) IsAgentConnectedForOrganization(organizationID, agentID string) bool {
|
||||
_, ok := s.connectionForOrganization(organizationID, agentID)
|
||||
return ok
|
||||
}
|
||||
|
||||
// GetAgentForHost finds the agent for a given hostname using the canonical
|
||||
// hostname-equivalence contract shared with the unified identity layer.
|
||||
func (s *Server) GetAgentForHost(hostname string) (string, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.GetAgentForHostForOrganization(defaultOrganizationID, hostname)
|
||||
}
|
||||
|
||||
// GetAgentForHostForOrganization resolves a hostname only within one tenant.
|
||||
func (s *Server) GetAgentForHostForOrganization(organizationID, hostname string) (string, bool) {
|
||||
if s == nil {
|
||||
return "", false
|
||||
}
|
||||
organizationID = normalizeOrganizationID(organizationID)
|
||||
s.mu.RLock()
|
||||
ids := make([]string, 0, len(s.agents))
|
||||
for _, ac := range s.agents {
|
||||
if unifiedresources.HostnamesEquivalent(ac.agent.Hostname, hostname) {
|
||||
return ac.agent.AgentID, true
|
||||
if normalizeOrganizationID(ac.admission.OrganizationID) == organizationID &&
|
||||
unifiedresources.HostnamesEquivalent(ac.agent.Hostname, hostname) {
|
||||
ids = append(ids, ac.agent.AgentID)
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
if len(ids) != 1 {
|
||||
return "", false
|
||||
}
|
||||
for _, agentID := range ids {
|
||||
if _, ok := s.connectionForOrganization(organizationID, agentID); ok {
|
||||
return agentID, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// GetAgentForTokenForOrganization resolves the canonical live command session
|
||||
// for the same enrollment token that owns a telemetry resource.
|
||||
func (s *Server) GetAgentForTokenForOrganization(organizationID, tokenID string) (string, bool) {
|
||||
if s == nil || strings.TrimSpace(tokenID) == "" {
|
||||
return "", false
|
||||
}
|
||||
organizationID = normalizeOrganizationID(organizationID)
|
||||
tokenID = strings.TrimSpace(tokenID)
|
||||
s.mu.RLock()
|
||||
ids := make([]string, 0, 1)
|
||||
for _, ac := range s.agents {
|
||||
if normalizeOrganizationID(ac.admission.OrganizationID) == organizationID &&
|
||||
strings.TrimSpace(ac.admission.TokenID) == tokenID {
|
||||
ids = append(ids, ac.agent.AgentID)
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
if len(ids) != 1 {
|
||||
return "", false
|
||||
}
|
||||
for _, agentID := range ids {
|
||||
if _, ok := s.connectionForOrganization(organizationID, agentID); ok {
|
||||
return agentID, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
@@ -1864,12 +2190,16 @@ func (s *Server) GetAgentForHost(hostname string) (string, bool) {
|
||||
// events for the given agent and job ID. Returns a buffered channel. The caller
|
||||
// must call UnsubscribeDeployProgress when done.
|
||||
func (s *Server) SubscribeDeployProgress(agentID, jobID string, bufSize int) chan DeployProgressPayload {
|
||||
return s.SubscribeDeployProgressForOrganization(defaultOrganizationID, agentID, jobID, bufSize)
|
||||
}
|
||||
|
||||
func (s *Server) SubscribeDeployProgressForOrganization(organizationID, agentID, jobID string, bufSize int) chan DeployProgressPayload {
|
||||
if bufSize <= 0 {
|
||||
bufSize = 64
|
||||
}
|
||||
ch := make(chan DeployProgressPayload, bufSize)
|
||||
s.mu.Lock()
|
||||
s.deploySubs[deploySubKey(agentID, jobID)] = ch
|
||||
s.deploySubs[deploySubKey(agentSessionKey(organizationID, agentID), jobID)] = ch
|
||||
s.mu.Unlock()
|
||||
return ch
|
||||
}
|
||||
@@ -1877,7 +2207,11 @@ func (s *Server) SubscribeDeployProgress(agentID, jobID string, bufSize int) cha
|
||||
// UnsubscribeDeployProgress removes and closes the progress subscriber for an agent's job.
|
||||
// Safe to call multiple times — a no-op if already unsubscribed (e.g. by readLoop cleanup).
|
||||
func (s *Server) UnsubscribeDeployProgress(agentID, jobID string) {
|
||||
key := deploySubKey(agentID, jobID)
|
||||
s.UnsubscribeDeployProgressForOrganization(defaultOrganizationID, agentID, jobID)
|
||||
}
|
||||
|
||||
func (s *Server) UnsubscribeDeployProgressForOrganization(organizationID, agentID, jobID string) {
|
||||
key := deploySubKey(agentSessionKey(organizationID, agentID), jobID)
|
||||
s.mu.Lock()
|
||||
ch, exists := s.deploySubs[key]
|
||||
delete(s.deploySubs, key)
|
||||
@@ -1915,10 +2249,7 @@ func (s *Server) sendDeployCommand(ctx context.Context, agentID string, msgType
|
||||
return fmt.Errorf("agent id is required")
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
ac, ok := s.agents[agentID]
|
||||
s.mu.RUnlock()
|
||||
|
||||
ac, ok := s.connectionForContext(ctx, agentID)
|
||||
if !ok {
|
||||
return fmt.Errorf("agent %s not connected", agentID)
|
||||
}
|
||||
|
||||
@@ -229,15 +229,13 @@ func TestHandleWebSocket_RegistrationAckSendFailure(t *testing.T) {
|
||||
Token: "any",
|
||||
}))
|
||||
|
||||
waitFor(t, 2*time.Second, func() bool { return s.IsAgentConnected("a1") })
|
||||
|
||||
_ = conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
|
||||
if _, _, err := conn.ReadMessage(); err == nil {
|
||||
t.Fatalf("expected no registration ack when send fails")
|
||||
}
|
||||
|
||||
conn.Close()
|
||||
waitFor(t, 2*time.Second, func() bool { return !s.IsAgentConnected("a1") })
|
||||
if s.IsAgentConnected("a1") {
|
||||
t.Fatal("agent must not remain command-connected when registration acknowledgement fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWebSocket_PongHandler(t *testing.T) {
|
||||
|
||||
@@ -846,6 +846,22 @@ func TestHandleWebSocket_ReconnectSameAgentIDClosesOldConnection(t *testing.T) {
|
||||
}))
|
||||
_ = wsReadRegisteredPayload(t, c1)
|
||||
|
||||
progressCh := s.SubscribeDeployProgress("a1", "job-reconnect", 1)
|
||||
defer s.UnsubscribeDeployProgress("a1", "job-reconnect")
|
||||
commandDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := s.ExecuteCommand(context.Background(), "a1", ExecuteCommandPayload{
|
||||
RequestID: "command-before-reconnect",
|
||||
Command: "true",
|
||||
Timeout: 10,
|
||||
Trusted: true,
|
||||
})
|
||||
commandDone <- err
|
||||
}()
|
||||
if command := wsReadRawMessage(t, c1); command.Type != MsgTypeExecuteCmd {
|
||||
t.Fatalf("old session received %q, want %q", command.Type, MsgTypeExecuteCmd)
|
||||
}
|
||||
|
||||
c2 := dial()
|
||||
defer c2.Close()
|
||||
wsWriteMessage(t, c2, mustNewMessage(t, MsgTypeAgentRegister, "", AgentRegisterPayload{
|
||||
@@ -862,4 +878,150 @@ func TestHandleWebSocket_ReconnectSameAgentIDClosesOldConnection(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected old connection to be closed")
|
||||
}
|
||||
|
||||
select {
|
||||
case commandErr := <-commandDone:
|
||||
if commandErr == nil || !strings.Contains(commandErr.Error(), "disconnected") {
|
||||
t.Fatalf("in-flight command reconnect result = %v, want disconnected", commandErr)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("in-flight command did not stop when its session was replaced")
|
||||
}
|
||||
|
||||
progress := DeployProgressPayload{
|
||||
RequestID: "deploy-after-reconnect",
|
||||
JobID: "job-reconnect",
|
||||
Phase: DeployPhasePreflightSSH,
|
||||
Status: DeployStepOK,
|
||||
}
|
||||
wsWriteMessage(t, c2, mustNewMessage(t, MsgTypeDeployProgress, progress.RequestID, progress))
|
||||
select {
|
||||
case received, ok := <-progressCh:
|
||||
if !ok {
|
||||
t.Fatal("replacement cleanup closed the active deploy subscription")
|
||||
}
|
||||
if received.RequestID != progress.RequestID {
|
||||
t.Fatalf("deploy progress request id = %q, want %q", received.RequestID, progress.RequestID)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("replacement session did not retain deploy progress subscription")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandSessionsAreTenantScopedAndDuplicateIdentityFailsClosed(t *testing.T) {
|
||||
admissions := map[string]AgentAdmission{
|
||||
"token-a": {OrganizationID: "org-a", TokenID: "token-a", AgentID: "shared", Hostname: "host-a"},
|
||||
"token-b": {OrganizationID: "org-b", TokenID: "token-b", AgentID: "shared", Hostname: "host-b"},
|
||||
"token-c": {OrganizationID: "org-a", TokenID: "token-c", AgentID: "shared", Hostname: "other-host"},
|
||||
"token-d": {OrganizationID: "org-a", TokenID: "token-d", AgentID: "other-id", Hostname: "host-a"},
|
||||
}
|
||||
s := NewServerWithAdmissionValidator(func(token, _, _ string) (AgentAdmission, bool) {
|
||||
admission, ok := admissions[token]
|
||||
return admission, ok
|
||||
}, func(AgentAdmission) bool { return true })
|
||||
ts := newWSServer(t, s)
|
||||
defer ts.Close()
|
||||
|
||||
register := func(token, agentID, hostname string) (*websocket.Conn, RegisteredPayload) {
|
||||
t.Helper()
|
||||
conn, _, err := dialAgentExecWebSocket(t, ts.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("Dial: %v", err)
|
||||
}
|
||||
wsWriteMessage(t, conn, mustNewMessage(t, MsgTypeAgentRegister, "", AgentRegisterPayload{
|
||||
AgentID: agentID, Hostname: hostname, Token: token,
|
||||
}))
|
||||
return conn, wsReadRegisteredPayload(t, conn)
|
||||
}
|
||||
|
||||
orgA, ack := register("token-a", "shared", "host-a")
|
||||
defer orgA.Close()
|
||||
if !ack.Success {
|
||||
t.Fatalf("org-a registration failed: %s", ack.Message)
|
||||
}
|
||||
orgB, ack := register("token-b", "shared", "host-b")
|
||||
defer orgB.Close()
|
||||
if !ack.Success {
|
||||
t.Fatalf("org-b registration failed: %s", ack.Message)
|
||||
}
|
||||
if !s.IsAgentConnectedForOrganization("org-a", "shared") ||
|
||||
!s.IsAgentConnectedForOrganization("org-b", "shared") {
|
||||
t.Fatal("same agent id must remain independently connected in both organizations")
|
||||
}
|
||||
if s.IsAgentConnected("shared") {
|
||||
t.Fatal("tenant-scoped sessions must not leak into the default organization")
|
||||
}
|
||||
orgAView := s.ForOrganization("org-a")
|
||||
orgAAgents := orgAView.GetConnectedAgents()
|
||||
if len(orgAAgents) != 1 || orgAAgents[0].Hostname != "host-a" {
|
||||
t.Fatalf("org-a server view leaked another tenant: %#v", orgAAgents)
|
||||
}
|
||||
orgBAgents := s.ForOrganization("org-b").GetConnectedAgents()
|
||||
if len(orgBAgents) != 1 || orgBAgents[0].Hostname != "host-b" {
|
||||
t.Fatalf("org-b server view leaked another tenant: %#v", orgBAgents)
|
||||
}
|
||||
|
||||
duplicate, ack := register("token-c", "shared", "other-host")
|
||||
defer duplicate.Close()
|
||||
if ack.Success {
|
||||
t.Fatal("same-tenant duplicate identity from another hostname was admitted")
|
||||
}
|
||||
if !s.IsAgentConnectedForOrganization("org-a", "shared") {
|
||||
t.Fatal("rejected duplicate identity evicted the original session")
|
||||
}
|
||||
|
||||
duplicateHost, ack := register("token-d", "other-id", "host-a")
|
||||
defer duplicateHost.Close()
|
||||
if ack.Success {
|
||||
t.Fatal("same-tenant hostname was admitted under a second identity")
|
||||
}
|
||||
if !s.IsAgentConnectedForOrganization("org-a", "shared") {
|
||||
t.Fatal("rejected duplicate hostname evicted the original session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokedAdmissionInvalidatesStaleSocketBeforeDispatch(t *testing.T) {
|
||||
valid := true
|
||||
admission := AgentAdmission{
|
||||
OrganizationID: "org-a",
|
||||
TokenID: "token-a",
|
||||
AgentID: "agent-a",
|
||||
Hostname: "host-a",
|
||||
}
|
||||
s := NewServerWithAdmissionValidator(func(token, _, _ string) (AgentAdmission, bool) {
|
||||
return admission, token == admission.TokenID
|
||||
}, func(candidate AgentAdmission) bool {
|
||||
return valid && candidate == admission
|
||||
})
|
||||
ts := newWSServer(t, s)
|
||||
defer ts.Close()
|
||||
|
||||
conn, _, err := dialAgentExecWebSocket(t, ts.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("Dial: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
wsWriteMessage(t, conn, mustNewMessage(t, MsgTypeAgentRegister, "", AgentRegisterPayload{
|
||||
AgentID: admission.AgentID, Hostname: admission.Hostname, Token: admission.TokenID,
|
||||
}))
|
||||
if ack := wsReadRegisteredPayload(t, conn); !ack.Success {
|
||||
t.Fatalf("registration failed: %s", ack.Message)
|
||||
}
|
||||
if !s.IsAgentConnectedForOrganization("org-a", "agent-a") {
|
||||
t.Fatal("expected admitted session")
|
||||
}
|
||||
|
||||
valid = false
|
||||
ctx := WithOrganizationID(context.Background(), "org-a")
|
||||
if _, err := s.ExecuteCommand(ctx, "agent-a", ExecuteCommandPayload{
|
||||
RequestID: "after-revocation",
|
||||
Command: "true",
|
||||
TargetType: "agent",
|
||||
Trusted: true,
|
||||
}); err == nil || !strings.Contains(err.Error(), "not connected") {
|
||||
t.Fatalf("revoked stale socket remained dispatchable: %v", err)
|
||||
}
|
||||
if s.IsAgentConnectedForOrganization("org-a", "agent-a") {
|
||||
t.Fatal("revoked session remained visible as connected")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,6 +400,8 @@ const (
|
||||
|
||||
// ConnectedAgent represents an agent connected via WebSocket
|
||||
type ConnectedAgent struct {
|
||||
OrganizationID string
|
||||
TokenID string
|
||||
AgentID string
|
||||
Hostname string
|
||||
Version string
|
||||
|
||||
@@ -16,6 +16,72 @@ type actionAgentCommander interface {
|
||||
IsAgentConnected(agentID string) bool
|
||||
}
|
||||
|
||||
type scopedActionAgentCommander interface {
|
||||
IsAgentConnectedForOrganization(organizationID, agentID string) bool
|
||||
GetAgentForHostForOrganization(organizationID, hostname string) (string, bool)
|
||||
GetAgentForTokenForOrganization(organizationID, tokenID string) (string, bool)
|
||||
}
|
||||
|
||||
type scopedAgentOperationReceiptCapability interface {
|
||||
AgentOperationReceiptVersionForOrganization(organizationID, agentID string) int
|
||||
}
|
||||
|
||||
type tenantAgentServer interface {
|
||||
GetConnectedAgents() []agentexec.ConnectedAgent
|
||||
ExecuteCommand(ctx context.Context, agentID string, cmd agentexec.ExecuteCommandPayload) (*agentexec.CommandResultPayload, error)
|
||||
}
|
||||
|
||||
func tenantAgentServerForOrganization(server *agentexec.Server, organizationID string) tenantAgentServer {
|
||||
if server == nil {
|
||||
return nil
|
||||
}
|
||||
return server.ForOrganization(organizationID)
|
||||
}
|
||||
|
||||
func agentCommandContext(ctx context.Context) context.Context {
|
||||
return agentexec.WithOrganizationID(ctx, GetOrgID(ctx))
|
||||
}
|
||||
|
||||
func isAgentCommandConnected(ctx context.Context, agents any, agentID string) bool {
|
||||
if scoped, ok := agents.(scopedActionAgentCommander); ok {
|
||||
return scoped.IsAgentConnectedForOrganization(GetOrgID(ctx), agentID)
|
||||
}
|
||||
if legacy, ok := agents.(interface{ IsAgentConnected(string) bool }); ok {
|
||||
return legacy.IsAgentConnected(agentID)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func commandAgentForHost(ctx context.Context, agents actionAgentCommander, hostname string) (string, bool) {
|
||||
if scoped, ok := agents.(scopedActionAgentCommander); ok {
|
||||
return scoped.GetAgentForHostForOrganization(GetOrgID(ctx), hostname)
|
||||
}
|
||||
if agents == nil {
|
||||
return "", false
|
||||
}
|
||||
return agents.GetAgentForHost(hostname)
|
||||
}
|
||||
|
||||
func commandAgentForToken(ctx context.Context, agents actionAgentCommander, tokenID string) (string, bool) {
|
||||
if strings.TrimSpace(tokenID) == "" {
|
||||
return "", false
|
||||
}
|
||||
if scoped, ok := agents.(scopedActionAgentCommander); ok {
|
||||
return scoped.GetAgentForTokenForOrganization(GetOrgID(ctx), tokenID)
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func liveAgentOperationReceiptVersion(ctx context.Context, agents any, agentID string) int {
|
||||
if scoped, ok := agents.(scopedAgentOperationReceiptCapability); ok {
|
||||
return scoped.AgentOperationReceiptVersionForOrganization(GetOrgID(ctx), agentID)
|
||||
}
|
||||
if capability, ok := agents.(agentOperationReceiptCapability); ok {
|
||||
return capability.AgentOperationReceiptVersion(agentID)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type actionHandlerProvider interface {
|
||||
ActionHandlerNames() []string
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
@@ -11,11 +12,42 @@ import (
|
||||
const (
|
||||
agentInstallIssuedViaConfig = "config_agent_install_command"
|
||||
agentInstallIssuedViaHosted = "hosted_agent_install_command"
|
||||
agentExecBindingVersionKey = "agent_exec_binding_version"
|
||||
agentExecBindingVersion = "2"
|
||||
)
|
||||
|
||||
type agentExecMetadataValue struct {
|
||||
value string
|
||||
present bool
|
||||
}
|
||||
|
||||
func snapshotAgentExecMetadata(metadata map[string]string, keys ...string) map[string]agentExecMetadataValue {
|
||||
snapshot := make(map[string]agentExecMetadataValue, len(keys))
|
||||
for _, key := range keys {
|
||||
value, present := metadata[key]
|
||||
snapshot[key] = agentExecMetadataValue{value: value, present: present}
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func restoreAgentExecMetadata(metadata map[string]string, snapshot map[string]agentExecMetadataValue) {
|
||||
for key, previous := range snapshot {
|
||||
if previous.present {
|
||||
metadata[key] = previous.value
|
||||
} else {
|
||||
delete(metadata, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) validateAgentExecToken(token string, agentID string, hostname string) bool {
|
||||
_, ok := r.admitAgentExecToken(token, agentID, hostname)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (r *Router) admitAgentExecToken(token string, agentID string, hostname string) (agentexec.AgentAdmission, bool) {
|
||||
if r == nil || r.config == nil {
|
||||
return false
|
||||
return agentexec.AgentAdmission{}, false
|
||||
}
|
||||
|
||||
requestedID := strings.TrimSpace(agentID)
|
||||
@@ -33,7 +65,7 @@ func (r *Router) validateAgentExecToken(token string, agentID string, hostname s
|
||||
Str("agent_id", requestedID).
|
||||
Str("hostname", requestedHost).
|
||||
Msg("Agent exec token not recognized by this server — re-run the agent installer to re-enroll this agent")
|
||||
return false
|
||||
return agentexec.AgentAdmission{}, false
|
||||
}
|
||||
|
||||
tokenID := record.ID
|
||||
@@ -42,7 +74,20 @@ func (r *Router) validateAgentExecToken(token string, agentID string, hostname s
|
||||
log.Warn().
|
||||
Str("token_id", tokenID).
|
||||
Msg("Agent exec token missing required scope: agent:exec")
|
||||
return false
|
||||
return agentexec.AgentAdmission{}, false
|
||||
}
|
||||
orgs := record.GetBoundOrgs()
|
||||
if len(orgs) > 1 {
|
||||
config.Mu.Unlock()
|
||||
log.Warn().
|
||||
Str("token_id", tokenID).
|
||||
Strs("organization_ids", orgs).
|
||||
Msg("Agent exec token rejected because command sessions require one organization binding")
|
||||
return agentexec.AgentAdmission{}, false
|
||||
}
|
||||
organizationID := "default"
|
||||
if len(orgs) == 1 && strings.TrimSpace(orgs[0]) != "" {
|
||||
organizationID = strings.TrimSpace(orgs[0])
|
||||
}
|
||||
|
||||
boundID := strings.TrimSpace(record.Metadata["bound_agent_id"])
|
||||
@@ -53,22 +98,30 @@ func (r *Router) validateAgentExecToken(token string, agentID string, hostname s
|
||||
if record.Metadata == nil {
|
||||
record.Metadata = make(map[string]string)
|
||||
}
|
||||
previousMetadata := snapshotAgentExecMetadata(
|
||||
record.Metadata,
|
||||
"bound_agent_id",
|
||||
"bound_hostname",
|
||||
"bound_at",
|
||||
agentExecBindingVersionKey,
|
||||
)
|
||||
record.Metadata["bound_agent_id"] = requestedID
|
||||
record.Metadata["bound_hostname"] = requestedHost
|
||||
record.Metadata["bound_at"] = time.Now().UTC().Format(time.RFC3339)
|
||||
tokens := make([]config.APITokenRecord, len(r.config.APITokens))
|
||||
copy(tokens, r.config.APITokens)
|
||||
config.Mu.Unlock()
|
||||
|
||||
record.Metadata[agentExecBindingVersionKey] = agentExecBindingVersion
|
||||
if r.persistence != nil {
|
||||
if err := r.persistence.SaveAPITokens(tokens); err != nil {
|
||||
log.Warn().
|
||||
if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil {
|
||||
restoreAgentExecMetadata(record.Metadata, previousMetadata)
|
||||
config.Mu.Unlock()
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("token_id", tokenID).
|
||||
Str("hostname", requestedHost).
|
||||
Msg("Failed to persist first-use Proxmox agent exec token binding")
|
||||
Msg("Failed to persist first-use agent exec token binding; command registration denied")
|
||||
return agentexec.AgentAdmission{}, false
|
||||
}
|
||||
}
|
||||
config.Mu.Unlock()
|
||||
|
||||
log.Info().
|
||||
Str("token_id", tokenID).
|
||||
@@ -76,7 +129,12 @@ func (r *Router) validateAgentExecToken(token string, agentID string, hostname s
|
||||
Str("issued_via", issuedVia).
|
||||
Str("install_type", installType).
|
||||
Msg("Bound agent install token to first command agent registration")
|
||||
return true
|
||||
return agentexec.AgentAdmission{
|
||||
OrganizationID: organizationID,
|
||||
TokenID: tokenID,
|
||||
AgentID: requestedID,
|
||||
Hostname: requestedHost,
|
||||
}, true
|
||||
}
|
||||
|
||||
if boundID == "" && boundHost == "" {
|
||||
@@ -84,17 +142,93 @@ func (r *Router) validateAgentExecToken(token string, agentID string, hostname s
|
||||
log.Warn().
|
||||
Str("token_id", tokenID).
|
||||
Msg("Agent exec token missing binding metadata")
|
||||
return false
|
||||
return agentexec.AgentAdmission{}, false
|
||||
}
|
||||
|
||||
if boundHost != "" && strings.EqualFold(boundHost, requestedHost) {
|
||||
// Pre-v6.1.1 deploy tokens could carry a server-synthesized agent ID even
|
||||
// though the runtime derives its ID from machine-id. Migrate that
|
||||
// hostname-bound legacy record exactly once, then enforce both fields.
|
||||
if strings.TrimSpace(record.Metadata[agentExecBindingVersionKey]) != agentExecBindingVersion &&
|
||||
boundHost != "" && strings.EqualFold(boundHost, requestedHost) {
|
||||
previousID := boundID
|
||||
previousMetadata := snapshotAgentExecMetadata(
|
||||
record.Metadata,
|
||||
"bound_agent_id",
|
||||
"bound_at",
|
||||
agentExecBindingVersionKey,
|
||||
)
|
||||
record.Metadata["bound_agent_id"] = requestedID
|
||||
record.Metadata["bound_at"] = time.Now().UTC().Format(time.RFC3339)
|
||||
record.Metadata[agentExecBindingVersionKey] = agentExecBindingVersion
|
||||
if r.persistence != nil {
|
||||
if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil {
|
||||
restoreAgentExecMetadata(record.Metadata, previousMetadata)
|
||||
config.Mu.Unlock()
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("token_id", tokenID).
|
||||
Msg("Failed to persist legacy agent exec identity migration; command registration denied")
|
||||
return agentexec.AgentAdmission{}, false
|
||||
}
|
||||
}
|
||||
config.Mu.Unlock()
|
||||
return true
|
||||
log.Info().
|
||||
Str("token_id", tokenID).
|
||||
Str("previous_agent_id", previousID).
|
||||
Str("agent_id", requestedID).
|
||||
Str("hostname", requestedHost).
|
||||
Msg("Migrated legacy hostname-bound agent exec token to immutable runtime identity")
|
||||
return agentexec.AgentAdmission{
|
||||
OrganizationID: organizationID,
|
||||
TokenID: tokenID,
|
||||
AgentID: requestedID,
|
||||
Hostname: requestedHost,
|
||||
}, true
|
||||
}
|
||||
|
||||
if boundID != "" && boundID == requestedID {
|
||||
idMatches := boundID == "" || boundID == requestedID
|
||||
hostMatches := boundHost == "" || strings.EqualFold(boundHost, requestedHost)
|
||||
if idMatches && hostMatches {
|
||||
previousMetadata := snapshotAgentExecMetadata(
|
||||
record.Metadata,
|
||||
"bound_agent_id",
|
||||
"bound_hostname",
|
||||
"bound_at",
|
||||
agentExecBindingVersionKey,
|
||||
)
|
||||
metadataChanged := false
|
||||
if boundID == "" && boundHost != "" {
|
||||
record.Metadata["bound_agent_id"] = requestedID
|
||||
boundID = requestedID
|
||||
metadataChanged = true
|
||||
}
|
||||
if boundHost == "" && boundID != "" {
|
||||
record.Metadata["bound_hostname"] = requestedHost
|
||||
boundHost = requestedHost
|
||||
metadataChanged = true
|
||||
}
|
||||
if metadataChanged {
|
||||
record.Metadata["bound_at"] = time.Now().UTC().Format(time.RFC3339)
|
||||
record.Metadata[agentExecBindingVersionKey] = agentExecBindingVersion
|
||||
}
|
||||
if metadataChanged && r.persistence != nil {
|
||||
if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil {
|
||||
restoreAgentExecMetadata(record.Metadata, previousMetadata)
|
||||
config.Mu.Unlock()
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("token_id", tokenID).
|
||||
Msg("Failed to persist migrated agent exec token binding; command registration denied")
|
||||
return agentexec.AgentAdmission{}, false
|
||||
}
|
||||
}
|
||||
config.Mu.Unlock()
|
||||
return true
|
||||
return agentexec.AgentAdmission{
|
||||
OrganizationID: organizationID,
|
||||
TokenID: tokenID,
|
||||
AgentID: requestedID,
|
||||
Hostname: requestedHost,
|
||||
}, true
|
||||
}
|
||||
|
||||
config.Mu.Unlock()
|
||||
@@ -105,6 +239,39 @@ func (r *Router) validateAgentExecToken(token string, agentID string, hostname s
|
||||
Str("requested_id", requestedID).
|
||||
Str("requested_hostname", requestedHost).
|
||||
Msg("Agent token mismatch: token is not bound to the registering agent")
|
||||
return agentexec.AgentAdmission{}, false
|
||||
}
|
||||
|
||||
func (r *Router) validateAgentExecSession(admission agentexec.AgentAdmission) bool {
|
||||
if r == nil || r.config == nil {
|
||||
return false
|
||||
}
|
||||
tokenID := strings.TrimSpace(admission.TokenID)
|
||||
requestedID := strings.TrimSpace(admission.AgentID)
|
||||
requestedHost := strings.TrimSpace(admission.Hostname)
|
||||
if tokenID == "" || requestedID == "" || requestedHost == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
config.Mu.Lock()
|
||||
defer config.Mu.Unlock()
|
||||
for index := range r.config.APITokens {
|
||||
record := &r.config.APITokens[index]
|
||||
if record.ID != tokenID || record.IsExpired() || !record.HasScope(config.ScopeAgentExec) {
|
||||
continue
|
||||
}
|
||||
orgs := record.GetBoundOrgs()
|
||||
organizationID := "default"
|
||||
if len(orgs) > 1 {
|
||||
return false
|
||||
}
|
||||
if len(orgs) == 1 && strings.TrimSpace(orgs[0]) != "" {
|
||||
organizationID = strings.TrimSpace(orgs[0])
|
||||
}
|
||||
return organizationID == strings.TrimSpace(admission.OrganizationID) &&
|
||||
strings.TrimSpace(record.Metadata["bound_agent_id"]) == requestedID &&
|
||||
strings.EqualFold(strings.TrimSpace(record.Metadata["bound_hostname"]), requestedHost)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -636,7 +636,7 @@ func (h *AIHandler) initTenantService(ctx context.Context, orgID string) AIServi
|
||||
chatCfg := chat.Config{
|
||||
AIConfig: aiCfg,
|
||||
DataDir: dataDir,
|
||||
AgentServer: h.agentServer,
|
||||
AgentServer: tenantAgentServerForOrganization(h.agentServer, orgID),
|
||||
ReadState: h.readStateForOrg(orgID),
|
||||
OrgID: orgID,
|
||||
ControlLevelResolver: func(next *config.AIConfig) string {
|
||||
@@ -886,7 +886,7 @@ func (h *AIHandler) startWithConfig(ctx context.Context, monitor *monitoring.Mon
|
||||
AIConfig: aiCfg,
|
||||
DataDir: dataDir,
|
||||
StateProvider: monitor,
|
||||
AgentServer: h.agentServer,
|
||||
AgentServer: tenantAgentServerForOrganization(h.agentServer, orgID),
|
||||
ReadState: h.readStateForOrg(orgID),
|
||||
OrgID: orgID,
|
||||
ControlLevelResolver: func(next *config.AIConfig) string {
|
||||
|
||||
@@ -287,7 +287,7 @@ func (h *AISettingsHandler) providerSnapshot() aiSettingsProviderSnapshot {
|
||||
}
|
||||
|
||||
func (h *AISettingsHandler) newFailClosedTenantService(orgID string) *ai.Service {
|
||||
svc := ai.NewService(nil, h.agentServer)
|
||||
svc := ai.NewService(nil, tenantAgentServerForOrganization(h.agentServer, orgID))
|
||||
svc.SetOrgID(orgID)
|
||||
h.stateMu.RLock()
|
||||
patrolAutopilotPolicy := h.patrolAutopilotPolicy
|
||||
@@ -338,7 +338,7 @@ func NewAISettingsHandler(mtp *config.MultiTenantPersistence, mtm *monitoring.Mu
|
||||
incidentRecorders: make(map[string]*metrics.IncidentRecorder),
|
||||
}
|
||||
|
||||
defaultAIService = ai.NewService(defaultPersistence, agentServer)
|
||||
defaultAIService = ai.NewService(defaultPersistence, tenantAgentServerForOrganization(agentServer, "default"))
|
||||
defaultAIService.SetOrgID("default")
|
||||
defaultAIService.SetAlertAnalyzerFactory(getCreateAlertAnalyzer())
|
||||
if defaultPersistence != nil {
|
||||
@@ -435,7 +435,7 @@ func (h *AISettingsHandler) GetAIService(ctx context.Context) *ai.Service {
|
||||
return h.newFailClosedTenantService(orgID)
|
||||
}
|
||||
|
||||
svc = ai.NewService(persistence, h.agentServer)
|
||||
svc = ai.NewService(persistence, tenantAgentServerForOrganization(h.agentServer, orgID))
|
||||
svc.SetOrgID(orgID)
|
||||
h.stateMu.RLock()
|
||||
patrolAutopilotPolicy := h.patrolAutopilotPolicy
|
||||
@@ -4747,7 +4747,7 @@ func (h *AISettingsHandler) HandleGetConnectedAgents(w http.ResponseWriter, r *h
|
||||
|
||||
var agents []agentInfo
|
||||
if h.agentServer != nil {
|
||||
for _, a := range h.agentServer.GetConnectedAgents() {
|
||||
for _, a := range h.agentServer.GetConnectedAgentsForOrganization(GetOrgID(r.Context())) {
|
||||
agents = append(agents, agentInfo{
|
||||
AgentID: a.AgentID,
|
||||
Hostname: a.Hostname,
|
||||
|
||||
@@ -5,10 +5,13 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai/memory"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
@@ -28,6 +31,70 @@ type recordingMetadataProvider struct {
|
||||
guestURLs map[string]string
|
||||
}
|
||||
|
||||
func TestTenantAgentServerForOrganizationFailsClosedAcrossTenants(t *testing.T) {
|
||||
admissions := map[string]agentexec.AgentAdmission{
|
||||
"token-a": {OrganizationID: "tenant-a", TokenID: "token-a", AgentID: "agent-a", Hostname: "host-a"},
|
||||
"token-b": {OrganizationID: "tenant-b", TokenID: "token-b", AgentID: "agent-b", Hostname: "host-b"},
|
||||
}
|
||||
server := agentexec.NewServerWithAdmissionValidator(
|
||||
func(token, _, _ string) (agentexec.AgentAdmission, bool) {
|
||||
admission, ok := admissions[token]
|
||||
return admission, ok
|
||||
},
|
||||
func(agentexec.AgentAdmission) bool { return true },
|
||||
)
|
||||
websocketServer := httptest.NewServer(http.HandlerFunc(server.HandleWebSocket))
|
||||
defer websocketServer.Close()
|
||||
|
||||
register := func(admission agentexec.AgentAdmission) *websocket.Conn {
|
||||
t.Helper()
|
||||
url := "ws" + strings.TrimPrefix(websocketServer.URL, "http")
|
||||
conn, _, err := websocket.DefaultDialer.Dial(url, http.Header{
|
||||
"Origin": []string{websocketServer.URL},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dial agent websocket: %v", err)
|
||||
}
|
||||
message, err := agentexec.NewMessage(agentexec.MsgTypeAgentRegister, "", agentexec.AgentRegisterPayload{
|
||||
AgentID: admission.AgentID,
|
||||
Hostname: admission.Hostname,
|
||||
Token: admission.TokenID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create registration message: %v", err)
|
||||
}
|
||||
if err := conn.WriteJSON(message); err != nil {
|
||||
t.Fatalf("write registration: %v", err)
|
||||
}
|
||||
var response agentexec.Message
|
||||
if err := conn.ReadJSON(&response); err != nil {
|
||||
t.Fatalf("read registration response: %v", err)
|
||||
}
|
||||
var registered agentexec.RegisteredPayload
|
||||
if err := response.DecodePayload(®istered); err != nil || !registered.Success {
|
||||
t.Fatalf("registration failed: payload=%+v err=%v", registered, err)
|
||||
}
|
||||
return conn
|
||||
}
|
||||
|
||||
connA := register(admissions["token-a"])
|
||||
defer connA.Close()
|
||||
connB := register(admissions["token-b"])
|
||||
defer connB.Close()
|
||||
|
||||
tenantA := tenantAgentServerForOrganization(server, "tenant-a")
|
||||
agents := tenantA.GetConnectedAgents()
|
||||
if len(agents) != 1 || agents[0].AgentID != "agent-a" {
|
||||
t.Fatalf("tenant-a command view leaked another tenant: %#v", agents)
|
||||
}
|
||||
if _, err := tenantA.ExecuteCommand(context.Background(), "agent-b", agentexec.ExecuteCommandPayload{
|
||||
Command: "true",
|
||||
Trusted: true,
|
||||
}); err == nil || !strings.Contains(err.Error(), "not connected") {
|
||||
t.Fatalf("cross-tenant command dispatch did not fail closed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *recordingMetadataProvider) SetGuestURL(id, url string) error {
|
||||
p.guestURLs[id] = url
|
||||
return nil
|
||||
|
||||
@@ -38,6 +38,7 @@ const (
|
||||
fleetStateCurrent = "current"
|
||||
fleetStateDegraded = "degraded"
|
||||
fleetStateDisabled = "disabled"
|
||||
fleetStateDisconnected = "disconnected"
|
||||
fleetStateEnabled = "enabled"
|
||||
fleetStateEnrolled = "enrolled"
|
||||
fleetStateHealthy = "healthy"
|
||||
@@ -543,6 +544,8 @@ func buildAgentConnection(host models.Host, expectedAgentVersion string, now tim
|
||||
AgentUpdate: connectionAgentUpdateStatus(host.AgentUpdate),
|
||||
AgentModules: connectionAgentModuleStatuses(host.AgentModules),
|
||||
Capabilities: ConnectionCapabilities{SupportsPause: false, SupportsScope: false, SupportsTest: false},
|
||||
agentID: strings.TrimSpace(host.ID),
|
||||
agentTokenID: strings.TrimSpace(host.TokenID),
|
||||
}, now)
|
||||
conn.Fleet.ConfigDrift = connectionFleetAgentConfigDrift(conn, desiredConfig, host.AppliedConfig)
|
||||
conn.Fleet.CredentialHealth = connectionFleetAgentCredentialHealth(conn, host, now)
|
||||
@@ -725,6 +728,9 @@ func connectionFleetRemoteControl(conn Connection) string {
|
||||
return fleetStateUnknown
|
||||
}
|
||||
if conn.AgentIdentity != nil && conn.AgentIdentity.CommandsEnabled {
|
||||
if conn.commandChannelConnected != nil && !*conn.commandChannelConnected {
|
||||
return fleetStateDisconnected
|
||||
}
|
||||
return fleetStateEnabled
|
||||
}
|
||||
return fleetStateDisabled
|
||||
@@ -1008,6 +1014,15 @@ func connectionFleetAgentCommandPolicy(conn Connection, host models.Host, desire
|
||||
}
|
||||
|
||||
applied := connectionFleetCommandPolicyState(host.CommandsEnabled)
|
||||
if applied == fleetStateEnabled && conn.commandChannelConnected != nil && !*conn.commandChannelConnected {
|
||||
return &ConnectionFleetCommandPolicy{
|
||||
Status: fleetStateBlocked,
|
||||
Desired: desired,
|
||||
Applied: applied,
|
||||
Enforcement: fleetStateBlocked,
|
||||
Reason: "agent reports command execution enabled, but no admitted command channel is connected",
|
||||
}
|
||||
}
|
||||
policy := &ConnectionFleetCommandPolicy{
|
||||
Status: applied,
|
||||
Desired: desired,
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
||||
@@ -13,11 +14,23 @@ import (
|
||||
// any persistence of its own — it composes per-type stores and the
|
||||
// monitoring scheduler's in-memory health data into a single list.
|
||||
type ConnectionsHandlers struct {
|
||||
getConfig func(ctx context.Context) *config.Config
|
||||
getPersistence func(ctx context.Context) *config.ConfigPersistence
|
||||
getMonitor func(ctx context.Context) *monitoring.Monitor
|
||||
getTrueNASPoller func(ctx context.Context) *monitoring.TrueNASPoller
|
||||
getVMwarePoller func(ctx context.Context) *monitoring.VMwarePoller
|
||||
getConfig func(ctx context.Context) *config.Config
|
||||
getPersistence func(ctx context.Context) *config.ConfigPersistence
|
||||
getMonitor func(ctx context.Context) *monitoring.Monitor
|
||||
getTrueNASPoller func(ctx context.Context) *monitoring.TrueNASPoller
|
||||
getVMwarePoller func(ctx context.Context) *monitoring.VMwarePoller
|
||||
agentCommandSessionConnected func(organizationID, tokenID, agentID, hostname string) bool
|
||||
}
|
||||
|
||||
// SetAgentCommandSessionProvider wires the live command-channel registry into
|
||||
// the ledger. Telemetry liveness alone is not command readiness.
|
||||
func (h *ConnectionsHandlers) SetAgentCommandSessionProvider(
|
||||
connected func(organizationID, tokenID, agentID, hostname string) bool,
|
||||
) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.agentCommandSessionConnected = connected
|
||||
}
|
||||
|
||||
// NewConnectionsHandlers wires the aggregator behind the request-scoped
|
||||
@@ -62,9 +75,39 @@ func (h *ConnectionsHandlers) HandleList(w http.ResponseWriter, r *http.Request)
|
||||
persistence := h.getPersistence(ctx)
|
||||
monitor := h.getMonitor(ctx)
|
||||
|
||||
inputs := buildAggregatorInputsWithRuntimeSources(ctx, cfg, persistence, monitor, h.runtimeSources(ctx, resolveTenantOrgID(r)))
|
||||
organizationID := resolveTenantOrgID(r)
|
||||
inputs := buildAggregatorInputsWithRuntimeSources(ctx, cfg, persistence, monitor, h.runtimeSources(ctx, organizationID))
|
||||
|
||||
connections := buildConnections(inputs)
|
||||
if h.agentCommandSessionConnected != nil {
|
||||
for index := range connections {
|
||||
conn := &connections[index]
|
||||
if conn.Type != ConnectionTypeAgent || conn.AgentIdentity == nil {
|
||||
continue
|
||||
}
|
||||
connected := h.agentCommandSessionConnected(
|
||||
organizationID,
|
||||
conn.agentTokenID,
|
||||
conn.agentID,
|
||||
conn.AgentIdentity.Hostname,
|
||||
)
|
||||
conn.commandChannelConnected = &connected
|
||||
conn.Fleet.RemoteControl = connectionFleetRemoteControl(*conn)
|
||||
if conn.AgentIdentity.CommandsEnabled && !connected {
|
||||
desired := fleetStateUnknown
|
||||
if conn.Fleet.CommandPolicy != nil && strings.TrimSpace(conn.Fleet.CommandPolicy.Desired) != "" {
|
||||
desired = conn.Fleet.CommandPolicy.Desired
|
||||
}
|
||||
conn.Fleet.CommandPolicy = &ConnectionFleetCommandPolicy{
|
||||
Status: fleetStateBlocked,
|
||||
Desired: desired,
|
||||
Applied: fleetStateEnabled,
|
||||
Enforcement: fleetStateBlocked,
|
||||
Reason: "agent reports command execution enabled, but no admitted command channel is connected",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, ConnectionsListResponse{
|
||||
Connections: connections,
|
||||
Systems: buildConnectionSystems(connections, monitor),
|
||||
|
||||
@@ -90,6 +90,79 @@ func TestConnectionsHandleListIncludesContinuityBackedHostAgents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionsLedgerSeparatesTelemetryHealthFromCommandAdmission(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := &config.Config{DataPath: dir}
|
||||
now := time.Now().UTC()
|
||||
monitor, err := monitoring.New(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("monitoring.New: %v", err)
|
||||
}
|
||||
t.Cleanup(monitor.Stop)
|
||||
|
||||
for _, report := range []agentshost.Report{
|
||||
{
|
||||
Agent: agentshost.AgentInfo{ID: "agent-enabled", Version: "6.1.1", IntervalSeconds: 30, CommandsEnabled: true},
|
||||
Host: agentshost.HostInfo{ID: "machine-enabled", MachineID: "machine-enabled", Hostname: "docker-host", Platform: "linux"},
|
||||
Timestamp: now,
|
||||
},
|
||||
{
|
||||
Agent: agentshost.AgentInfo{ID: "agent-disabled", Version: "6.1.1", IntervalSeconds: 30, CommandsEnabled: false},
|
||||
Host: agentshost.HostInfo{ID: "machine-disabled", MachineID: "machine-disabled", Hostname: "policy-disabled", Platform: "linux"},
|
||||
Timestamp: now,
|
||||
},
|
||||
} {
|
||||
tokenID := "token-enabled"
|
||||
if report.Agent.ID == "agent-disabled" {
|
||||
tokenID = "token-disabled"
|
||||
}
|
||||
if _, err := monitor.ApplyHostReport(report, &config.APITokenRecord{ID: tokenID, Name: tokenID}); err != nil {
|
||||
t.Fatalf("ApplyHostReport(%s): %v", report.Agent.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
handler := NewConnectionsHandlers(
|
||||
func(context.Context) *config.Config { return cfg },
|
||||
func(context.Context) *config.ConfigPersistence { return nil },
|
||||
func(context.Context) *monitoring.Monitor { return monitor },
|
||||
)
|
||||
handler.SetAgentCommandSessionProvider(func(_, tokenID, _, _ string) bool {
|
||||
return tokenID == "token-connected"
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/connections", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleList(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var response ConnectionsListResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
byID := make(map[string]Connection, len(response.Connections))
|
||||
for _, connection := range response.Connections {
|
||||
byID[connection.ID] = connection
|
||||
}
|
||||
|
||||
enabled := byID["agent:machine-enabled"]
|
||||
if enabled.State != ConnectionStateActive || enabled.Fleet.AdapterHealth != fleetStateHealthy {
|
||||
t.Fatalf("telemetry health changed when command channel was absent: %+v", enabled.Fleet)
|
||||
}
|
||||
if enabled.Fleet.RemoteControl != fleetStateDisconnected ||
|
||||
enabled.Fleet.CommandPolicy == nil ||
|
||||
enabled.Fleet.CommandPolicy.Status != fleetStateBlocked {
|
||||
t.Fatalf("enabled report without admitted socket must be disconnected/blocked: %+v", enabled.Fleet)
|
||||
}
|
||||
|
||||
disabled := byID["agent:machine-disabled"]
|
||||
if disabled.Fleet.RemoteControl != fleetStateDisabled ||
|
||||
disabled.Fleet.CommandPolicy == nil ||
|
||||
disabled.Fleet.CommandPolicy.Status != fleetStateDisabled {
|
||||
t.Fatalf("disabled command policy must not be misreported as a transport failure: %+v", disabled.Fleet)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionsHandleListUsesTrueNASPollerRuntimeSummary(t *testing.T) {
|
||||
persistence := config.NewConfigPersistence(t.TempDir())
|
||||
connection := config.TrueNASInstance{
|
||||
|
||||
@@ -181,6 +181,10 @@ type Connection struct {
|
||||
AgentModules []ConnectionAgentModuleStatus `json:"agentModules,omitempty"`
|
||||
Fleet ConnectionFleetGovernance `json:"fleet"`
|
||||
Capabilities ConnectionCapabilities `json:"capabilities"`
|
||||
|
||||
agentID string
|
||||
agentTokenID string
|
||||
commandChannelConnected *bool
|
||||
}
|
||||
|
||||
type ConnectionSystemComponentRole string
|
||||
|
||||
@@ -20678,9 +20678,11 @@ func TestContract_DockerLifecycleActionsResolveCommandAgentAndDispatchOneTypedOp
|
||||
}
|
||||
sharedSrc := string(sharedSource)
|
||||
for _, snippet := range []string{
|
||||
"func (e dockerContainerActionExecutor) connectedDockerCommandAgentID(resource unified.Resource) (string, error)",
|
||||
"func (e dockerContainerActionExecutor) connectedDockerCommandAgentID(ctx context.Context, resource unified.Resource) (string, error)",
|
||||
"resource.Docker.TokenID",
|
||||
"commandAgentForToken(ctx, e.agents, resource.Docker.TokenID)",
|
||||
"resource.Docker.AgentID",
|
||||
"e.agents.GetAgentForHost(strings.TrimSpace(resource.Docker.Hostname))",
|
||||
"commandAgentForHost(ctx, e.agents, strings.TrimSpace(resource.Docker.Hostname))",
|
||||
"ExecuteDockerContainerLifecycle(context.Context, string, agentexec.DockerContainerLifecyclePayload)",
|
||||
"ActionDispatchOperationKinds",
|
||||
"BindActionDispatch",
|
||||
@@ -20715,9 +20717,11 @@ func TestContract_DockerLifecycleActionsResolveCommandAgentAndDispatchOneTypedOp
|
||||
if strings.Contains(reconcileSource, "executorForAction(ctx, record.Request)") {
|
||||
t.Fatal("durable reconciliation must not rediscover its executor from current resource inventory")
|
||||
}
|
||||
if strings.Index(src, "if agentID := strings.TrimSpace(resource.Docker.AgentID)") >
|
||||
strings.Index(src, "e.agents.GetAgentForHost(strings.TrimSpace(resource.Docker.Hostname))") {
|
||||
t.Fatal("docker lifecycle executor must try the Docker reporting agent id before falling back to hostname resolution")
|
||||
if strings.Index(src, "commandAgentForToken(ctx, e.agents, resource.Docker.TokenID)") >
|
||||
strings.Index(src, "if agentID := strings.TrimSpace(resource.Docker.AgentID)") ||
|
||||
strings.Index(src, "if agentID := strings.TrimSpace(resource.Docker.AgentID)") >
|
||||
strings.Index(src, "commandAgentForHost(ctx, e.agents, strings.TrimSpace(resource.Docker.Hostname))") {
|
||||
t.Fatal("docker lifecycle executor must resolve the immutable reporting token before legacy agent-id and hostname fallbacks")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20750,9 +20754,9 @@ func TestContract_ProxmoxLifecycleActionsResolveNodeCommandAgentAndVerifyState(t
|
||||
}
|
||||
src := string(source)
|
||||
for _, snippet := range []string{
|
||||
"func (e proxmoxGuestActionExecutor) connectedProxmoxNodeCommandAgentID(resource unified.Resource) (string, error)",
|
||||
"func (e proxmoxGuestActionExecutor) connectedProxmoxNodeCommandAgentID(ctx context.Context, resource unified.Resource) (string, error)",
|
||||
"resource.Proxmox.LinkedAgentID",
|
||||
"e.agents.GetAgentForHost(strings.TrimSpace(resource.Proxmox.NodeName))",
|
||||
"commandAgentForHost(ctx, e.agents, strings.TrimSpace(resource.Proxmox.NodeName))",
|
||||
"Trusted: true",
|
||||
"func (e proxmoxGuestActionExecutor) verifyProxmoxGuestState(",
|
||||
"proxmoxGuestStatusCommand(kind, vmid)",
|
||||
@@ -20764,7 +20768,7 @@ func TestContract_ProxmoxLifecycleActionsResolveNodeCommandAgentAndVerifyState(t
|
||||
}
|
||||
}
|
||||
if strings.Index(src, "if agentID := strings.TrimSpace(resource.Proxmox.LinkedAgentID)") >
|
||||
strings.Index(src, "e.agents.GetAgentForHost(strings.TrimSpace(resource.Proxmox.NodeName))") {
|
||||
strings.Index(src, "commandAgentForHost(ctx, e.agents, strings.TrimSpace(resource.Proxmox.NodeName))") {
|
||||
t.Fatal("proxmox lifecycle executor must try the linked Proxmox node agent before falling back to node hostname resolution")
|
||||
}
|
||||
|
||||
@@ -20935,8 +20939,8 @@ func TestContract_DurableOperationReceiptCapabilityGatesAPTActions(t *testing.T)
|
||||
files := map[string][]string{
|
||||
"../../pkg/agents/host/report.go": {"OperationReceiptVersion int", `json:"operationReceiptVersion,omitempty"`},
|
||||
"../unifiedresources/adapters.go": {"host.OperationReceiptVersion != operationreceipt.ProtocolVersion", "hostPackageUpdateCapabilities(host)", "hostStorageCleanupCapability(host)"},
|
||||
"host_update_action_executor.go": {"resource.Agent.OperationReceiptVersion != operationreceipt.ProtocolVersion", "AgentOperationReceiptVersion(agentID) != operationreceipt.ProtocolVersion"},
|
||||
"host_storage_cleanup_action_executor.go": {"resource.Agent.OperationReceiptVersion != operationreceipt.ProtocolVersion", "AgentOperationReceiptVersion(agentID) != operationreceipt.ProtocolVersion"},
|
||||
"host_update_action_executor.go": {"resource.Agent.OperationReceiptVersion != operationreceipt.ProtocolVersion", "liveAgentOperationReceiptVersion(ctx, e.agents, agentID) != operationreceipt.ProtocolVersion"},
|
||||
"host_storage_cleanup_action_executor.go": {"resource.Agent.OperationReceiptVersion != operationreceipt.ProtocolVersion", "liveAgentOperationReceiptVersion(ctx, e.agents, agentID) != operationreceipt.ProtocolVersion"},
|
||||
"../ai/findings_apt_workflows.go": {"aptHostHasCapability(host, \"install_os_updates\", \"host.package_updates\")", "aptHostHasCapability(host, \"clean_package_cache\", \"host.storage_cleanup\")"},
|
||||
}
|
||||
for path, fragments := range files {
|
||||
|
||||
@@ -113,7 +113,7 @@ func (h *DeployHandlers) HandleCandidates(w http.ResponseWriter, r *http.Request
|
||||
|
||||
// Build connected agents set
|
||||
connectedAgents := make(map[string]bool)
|
||||
for _, agent := range h.execServer.GetConnectedAgents() {
|
||||
for _, agent := range h.execServer.GetConnectedAgentsForOrganization(GetOrgID(r.Context())) {
|
||||
connectedAgents[agent.AgentID] = true
|
||||
}
|
||||
|
||||
@@ -226,7 +226,7 @@ func (h *DeployHandlers) HandleCreatePreflight(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
|
||||
// Verify source agent is connected.
|
||||
if !h.execServer.IsAgentConnected(req.SourceAgentID) {
|
||||
if !h.execServer.IsAgentConnectedForOrganization(resolveTenantOrgID(r), req.SourceAgentID) {
|
||||
writeErrorResponse(w, http.StatusConflict, "source_agent_offline", "Source agent is not connected", nil)
|
||||
return
|
||||
}
|
||||
@@ -361,11 +361,11 @@ func (h *DeployHandlers) HandleCreatePreflight(w http.ResponseWriter, r *http.Re
|
||||
})
|
||||
|
||||
// Subscribe to progress before sending command to avoid race.
|
||||
progressCh := h.execServer.SubscribeDeployProgress(req.SourceAgentID, jobID, 64)
|
||||
progressCh := h.execServer.SubscribeDeployProgressForOrganization(job.OrgID, req.SourceAgentID, jobID, 64)
|
||||
|
||||
// Send command to agent.
|
||||
if err := h.execServer.SendDeployPreflight(ctx, req.SourceAgentID, payload); err != nil {
|
||||
h.execServer.UnsubscribeDeployProgress(req.SourceAgentID, jobID)
|
||||
if err := h.execServer.SendDeployPreflight(agentCommandContext(ctx), req.SourceAgentID, payload); err != nil {
|
||||
h.execServer.UnsubscribeDeployProgressForOrganization(job.OrgID, req.SourceAgentID, jobID)
|
||||
_ = h.store.UpdateJobStatus(ctx, jobID, deploy.JobFailed)
|
||||
log.Error().Err(err).Str("job_id", jobID).Msg("Failed to send preflight command")
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "send_failed",
|
||||
@@ -374,7 +374,7 @@ func (h *DeployHandlers) HandleCreatePreflight(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
|
||||
// Start background goroutine to process progress events.
|
||||
go h.processPreflightProgress(jobID, req.SourceAgentID, progressCh)
|
||||
go h.processPreflightProgress(job.OrgID, jobID, req.SourceAgentID, progressCh)
|
||||
|
||||
resp := createPreflightResponse{
|
||||
PreflightID: jobID,
|
||||
@@ -554,8 +554,8 @@ func (h *DeployHandlers) HandlePreflightEvents(w http.ResponseWriter, r *http.Re
|
||||
|
||||
// processPreflightProgress reads deploy progress events from the agent and
|
||||
// persists them as deploy events, also broadcasting to SSE clients.
|
||||
func (h *DeployHandlers) processPreflightProgress(jobID, agentID string, ch <-chan agentexec.DeployProgressPayload) {
|
||||
defer h.execServer.UnsubscribeDeployProgress(agentID, jobID)
|
||||
func (h *DeployHandlers) processPreflightProgress(organizationID, jobID, agentID string, ch <-chan agentexec.DeployProgressPayload) {
|
||||
defer h.execServer.UnsubscribeDeployProgressForOrganization(organizationID, agentID, jobID)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -1016,7 +1016,7 @@ func (h *DeployHandlers) HandleCreateJob(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
// Verify source agent is connected.
|
||||
if !h.execServer.IsAgentConnected(req.SourceAgentID) {
|
||||
if !h.execServer.IsAgentConnectedForOrganization(resolveTenantOrgID(r), req.SourceAgentID) {
|
||||
writeErrorResponse(w, http.StatusConflict, "source_agent_offline", "Source agent is not connected", nil)
|
||||
return
|
||||
}
|
||||
@@ -1224,11 +1224,11 @@ func (h *DeployHandlers) HandleCreateJob(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
// Subscribe to progress before sending command.
|
||||
progressCh := h.execServer.SubscribeDeployProgress(req.SourceAgentID, jobID, 64)
|
||||
progressCh := h.execServer.SubscribeDeployProgressForOrganization(orgID, req.SourceAgentID, jobID, 64)
|
||||
|
||||
// Send install command to agent.
|
||||
if err := h.execServer.SendDeployInstall(ctx, req.SourceAgentID, payload); err != nil {
|
||||
h.execServer.UnsubscribeDeployProgress(req.SourceAgentID, jobID)
|
||||
if err := h.execServer.SendDeployInstall(agentCommandContext(ctx), req.SourceAgentID, payload); err != nil {
|
||||
h.execServer.UnsubscribeDeployProgressForOrganization(orgID, req.SourceAgentID, jobID)
|
||||
_ = h.store.UpdateJobStatus(ctx, jobID, deploy.JobFailed)
|
||||
// Mark pending targets as failed so they're eligible for retry.
|
||||
for _, it := range installTargets {
|
||||
@@ -1241,7 +1241,7 @@ func (h *DeployHandlers) HandleCreateJob(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
// Start background goroutine to process progress events.
|
||||
go h.processInstallProgress(jobID, req.SourceAgentID, job.RetryMax, progressCh)
|
||||
go h.processInstallProgress(orgID, jobID, req.SourceAgentID, job.RetryMax, progressCh)
|
||||
|
||||
resp := createJobResponse{
|
||||
JobID: jobID,
|
||||
@@ -1315,7 +1315,7 @@ func (h *DeployHandlers) HandleCancelJob(w http.ResponseWriter, r *http.Request)
|
||||
RequestID: generateID("req"),
|
||||
JobID: jobID,
|
||||
}
|
||||
if err := h.execServer.SendDeployCancel(ctx, job.SourceAgentID, cancelPayload); err != nil {
|
||||
if err := h.execServer.SendDeployCancel(agentCommandContext(ctx), job.SourceAgentID, cancelPayload); err != nil {
|
||||
log.Error().Err(err).Str("job_id", jobID).Msg("Failed to send cancel command")
|
||||
// Don't fail the request — the agent may have already disconnected.
|
||||
// processInstallProgress will handle the channel close.
|
||||
@@ -1392,7 +1392,7 @@ func (h *DeployHandlers) HandleRetryJob(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
// Verify source agent is connected.
|
||||
if !h.execServer.IsAgentConnected(job.SourceAgentID) {
|
||||
if !h.execServer.IsAgentConnectedForOrganization(orgID, job.SourceAgentID) {
|
||||
writeErrorResponse(w, http.StatusConflict, "source_agent_offline", "Source agent is not connected", nil)
|
||||
return
|
||||
}
|
||||
@@ -1523,9 +1523,9 @@ func (h *DeployHandlers) HandleRetryJob(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
// Subscribe and send.
|
||||
progressCh := h.execServer.SubscribeDeployProgress(job.SourceAgentID, jobID, 64)
|
||||
if err := h.execServer.SendDeployInstall(ctx, job.SourceAgentID, payload); err != nil {
|
||||
h.execServer.UnsubscribeDeployProgress(job.SourceAgentID, jobID)
|
||||
progressCh := h.execServer.SubscribeDeployProgressForOrganization(orgID, job.SourceAgentID, jobID, 64)
|
||||
if err := h.execServer.SendDeployInstall(agentCommandContext(ctx), job.SourceAgentID, payload); err != nil {
|
||||
h.execServer.UnsubscribeDeployProgressForOrganization(orgID, job.SourceAgentID, jobID)
|
||||
_ = h.store.UpdateJobStatus(ctx, jobID, deploy.JobFailed)
|
||||
// Mark retried targets back to failed so they can be retried again.
|
||||
for _, it := range installTargets {
|
||||
@@ -1537,7 +1537,7 @@ func (h *DeployHandlers) HandleRetryJob(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
go h.processInstallProgress(jobID, job.SourceAgentID, job.RetryMax, progressCh)
|
||||
go h.processInstallProgress(orgID, jobID, job.SourceAgentID, job.RetryMax, progressCh)
|
||||
|
||||
resp := map[string]any{
|
||||
"jobId": jobID,
|
||||
@@ -1553,8 +1553,8 @@ func (h *DeployHandlers) HandleRetryJob(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// processInstallProgress reads install progress events from the agent and
|
||||
// persists them as deploy events, also broadcasting to SSE clients.
|
||||
func (h *DeployHandlers) processInstallProgress(jobID, agentID string, retryMax int, ch <-chan agentexec.DeployProgressPayload) {
|
||||
defer h.execServer.UnsubscribeDeployProgress(agentID, jobID)
|
||||
func (h *DeployHandlers) processInstallProgress(organizationID, jobID, agentID string, retryMax int, ch <-chan agentexec.DeployProgressPayload) {
|
||||
defer h.execServer.UnsubscribeDeployProgressForOrganization(organizationID, agentID, jobID)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
|
||||
@@ -1622,7 +1622,7 @@ func TestProcessInstallProgress_AgentDisconnect(t *testing.T) {
|
||||
close(ch)
|
||||
|
||||
// Run in foreground for testing.
|
||||
h.processInstallProgress("dep_disc", "agent-1", 3, ch)
|
||||
h.processInstallProgress("default", "dep_disc", "agent-1", 3, ch)
|
||||
|
||||
// Job should be failed.
|
||||
job, _ := h.store.GetJob(ctx, "dep_disc")
|
||||
|
||||
@@ -68,7 +68,7 @@ func (e dockerContainerActionExecutor) BindActionDispatch(ctx context.Context, r
|
||||
if err != nil {
|
||||
return unified.ActionDispatchAttempt{}, err
|
||||
}
|
||||
agentID, err := e.connectedDockerCommandAgentID(resource)
|
||||
agentID, err := e.connectedDockerCommandAgentID(ctx, resource)
|
||||
if err != nil {
|
||||
return unified.ActionDispatchAttempt{}, err
|
||||
}
|
||||
@@ -90,7 +90,7 @@ func (e dockerContainerActionExecutor) BindActionDispatch(ctx context.Context, r
|
||||
if err != nil {
|
||||
return unified.ActionDispatchAttempt{}, err
|
||||
}
|
||||
agentID, err := e.connectedDockerCommandAgentID(resource)
|
||||
agentID, err := e.connectedDockerCommandAgentID(ctx, resource)
|
||||
if err != nil {
|
||||
return unified.ActionDispatchAttempt{}, err
|
||||
}
|
||||
@@ -149,7 +149,7 @@ func (e dockerContainerActionExecutor) ExecuteAction(ctx context.Context, record
|
||||
if dockerContainerRef(resource) == "" {
|
||||
return nil, fmt.Errorf("docker container resource %q has no executable container id", record.Request.ResourceID)
|
||||
}
|
||||
agentID, err := e.connectedDockerCommandAgentID(resource)
|
||||
agentID, err := e.connectedDockerCommandAgentID(ctx, resource)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -169,7 +169,7 @@ func (e dockerContainerActionExecutor) ExecuteAction(ctx context.Context, record
|
||||
if agentexec.DockerContainerLifecycleOperationIdentity(agentID, req) != (operationreceipt.Identity{AttemptID: attempt.ID, ActionID: attempt.ActionID, OperationKind: attempt.OperationKind, OperationVersion: attempt.OperationVersion, RequestDigest: attempt.RequestDigest, AgentID: attempt.AgentID}) {
|
||||
return nil, fmt.Errorf("docker container lifecycle dispatch binding drift")
|
||||
}
|
||||
result, err := typedAgents.ExecuteDockerContainerLifecycle(ctx, agentID, req)
|
||||
result, err := typedAgents.ExecuteDockerContainerLifecycle(agentCommandContext(ctx), agentID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -202,7 +202,7 @@ func (e dockerContainerActionExecutor) executeDockerContainerUpdate(ctx context.
|
||||
if dockerContainerRef(resource) == "" {
|
||||
return nil, fmt.Errorf("docker container resource %q has no executable container id", record.Request.ResourceID)
|
||||
}
|
||||
agentID, err := e.connectedDockerCommandAgentID(resource)
|
||||
agentID, err := e.connectedDockerCommandAgentID(ctx, resource)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -217,7 +217,7 @@ func (e dockerContainerActionExecutor) executeDockerContainerUpdate(ctx context.
|
||||
if agentexec.DockerContainerUpdateOperationIdentity(agentID, req) != (operationreceipt.Identity{AttemptID: attempt.ID, ActionID: attempt.ActionID, OperationKind: attempt.OperationKind, OperationVersion: attempt.OperationVersion, RequestDigest: attempt.RequestDigest, AgentID: attempt.AgentID}) {
|
||||
return nil, fmt.Errorf("docker container update dispatch binding drift")
|
||||
}
|
||||
result, err := typedAgents.ExecuteDockerContainerUpdate(ctx, agentID, req)
|
||||
result, err := typedAgents.ExecuteDockerContainerUpdate(agentCommandContext(ctx), agentID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -261,7 +261,7 @@ func (e dockerContainerActionExecutor) ReconcileActionDispatch(ctx context.Conte
|
||||
return nil, unified.ActionDispatchReceipt{}, false, nil
|
||||
}
|
||||
identity := operationreceipt.Identity{AttemptID: attempt.ID, ActionID: attempt.ActionID, OperationKind: attempt.OperationKind, OperationVersion: attempt.OperationVersion, RequestDigest: attempt.RequestDigest, AgentID: attempt.AgentID}
|
||||
query, err := querier.QueryAgentOperation(ctx, attempt.AgentID, identity)
|
||||
query, err := querier.QueryAgentOperation(agentCommandContext(ctx), attempt.AgentID, identity)
|
||||
if err != nil {
|
||||
return nil, unified.ActionDispatchReceipt{}, false, err
|
||||
}
|
||||
@@ -329,12 +329,11 @@ func (e dockerContainerActionExecutor) CheckActionAvailable(ctx context.Context,
|
||||
if _, err := e.executableDockerContainerResource(ctx, resource, operation); err != nil {
|
||||
return unavailableDockerActionReadiness(operation, dockerActionUnavailableReasonCode(err), dockerActionUnavailableReason(err))
|
||||
}
|
||||
if _, err := e.connectedDockerCommandAgentID(resource); err != nil {
|
||||
if _, err := e.connectedDockerCommandAgentID(ctx, resource); err != nil {
|
||||
return unavailableDockerActionReadiness(operation, "command_agent_disconnected", "Docker / Podman command agent is not connected.")
|
||||
}
|
||||
agentID, _ := e.connectedDockerCommandAgentID(resource)
|
||||
liveCapability, supported := e.agents.(agentOperationReceiptCapability)
|
||||
if !supported || liveCapability.AgentOperationReceiptVersion(agentID) != operationreceipt.ProtocolVersion {
|
||||
agentID, _ := e.connectedDockerCommandAgentID(ctx, resource)
|
||||
if liveAgentOperationReceiptVersion(ctx, e.agents, agentID) != operationreceipt.ProtocolVersion {
|
||||
return unavailableDockerActionReadiness(operation, "operation_receipt_unsupported", "The Pulse agent on this host cannot run reviewed actions: it is on an older version, or its durable state directory is unavailable. Update the agent, or check the agent logs if it is already current, then retry.")
|
||||
}
|
||||
return readiness
|
||||
@@ -377,19 +376,27 @@ func (e dockerContainerActionExecutor) executableDockerContainerResource(_ conte
|
||||
return resource, nil
|
||||
}
|
||||
|
||||
func (e dockerContainerActionExecutor) connectedDockerCommandAgentID(resource unified.Resource) (string, error) {
|
||||
func (e dockerContainerActionExecutor) connectedDockerCommandAgentID(ctx context.Context, resource unified.Resource) (string, error) {
|
||||
if e.agents == nil {
|
||||
return "", fmt.Errorf("docker container command agent is not connected")
|
||||
}
|
||||
if resource.Docker == nil {
|
||||
return "", fmt.Errorf("docker resource metadata missing")
|
||||
}
|
||||
if agentID := strings.TrimSpace(resource.Docker.AgentID); agentID != "" && e.agents.IsAgentConnected(agentID) {
|
||||
if agentID, ok := commandAgentForToken(ctx, e.agents, resource.Docker.TokenID); ok {
|
||||
return strings.TrimSpace(agentID), nil
|
||||
}
|
||||
// When telemetry names a token, it is the immutable session binding. Do
|
||||
// not fall back to a different identity or hostname after rotation.
|
||||
if strings.TrimSpace(resource.Docker.TokenID) != "" {
|
||||
return "", fmt.Errorf("docker container command agent is not connected")
|
||||
}
|
||||
if agentID := strings.TrimSpace(resource.Docker.AgentID); agentID != "" && isAgentCommandConnected(ctx, e.agents, agentID) {
|
||||
return agentID, nil
|
||||
}
|
||||
if agentID, ok := e.agents.GetAgentForHost(strings.TrimSpace(resource.Docker.Hostname)); ok {
|
||||
if agentID, ok := commandAgentForHost(ctx, e.agents, strings.TrimSpace(resource.Docker.Hostname)); ok {
|
||||
agentID = strings.TrimSpace(agentID)
|
||||
if agentID != "" && e.agents.IsAgentConnected(agentID) {
|
||||
if agentID != "" && isAgentCommandConnected(ctx, e.agents, agentID) {
|
||||
return agentID, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,28 @@ type fakeDockerActionAgentCommander struct {
|
||||
queries []operationreceipt.Identity
|
||||
}
|
||||
|
||||
type scopedFakeDockerActionAgentCommander struct {
|
||||
*fakeDockerActionAgentCommander
|
||||
tokenAgents map[string]string
|
||||
lastOrg string
|
||||
}
|
||||
|
||||
func (f *scopedFakeDockerActionAgentCommander) IsAgentConnectedForOrganization(organizationID, agentID string) bool {
|
||||
f.lastOrg = organizationID
|
||||
return f.IsAgentConnected(agentID)
|
||||
}
|
||||
|
||||
func (f *scopedFakeDockerActionAgentCommander) GetAgentForHostForOrganization(organizationID, hostname string) (string, bool) {
|
||||
f.lastOrg = organizationID
|
||||
return f.GetAgentForHost(hostname)
|
||||
}
|
||||
|
||||
func (f *scopedFakeDockerActionAgentCommander) GetAgentForTokenForOrganization(organizationID, tokenID string) (string, bool) {
|
||||
f.lastOrg = organizationID
|
||||
agentID, ok := f.tokenAgents[tokenID]
|
||||
return agentID, ok
|
||||
}
|
||||
|
||||
func dockerActionDispatchContext(t *testing.T, executor dockerContainerActionExecutor, record unified.ActionAuditRecord) context.Context {
|
||||
t.Helper()
|
||||
attempt, err := unified.NewActionDispatchAttempt(record.ID, time.Now())
|
||||
@@ -122,6 +144,36 @@ func dockerActionReadinessByName(readinesses []unified.ResourceActionReadiness,
|
||||
return unified.ResourceActionReadiness{}, false
|
||||
}
|
||||
|
||||
func TestDockerCommandSessionResolutionUsesTenantAndImmutableReportToken(t *testing.T) {
|
||||
agents := &scopedFakeDockerActionAgentCommander{
|
||||
fakeDockerActionAgentCommander: &fakeDockerActionAgentCommander{
|
||||
connected: map[string]bool{"canonical-agent": true, "stale-agent": true},
|
||||
agentByHost: map[string]string{"docker-host": "stale-agent"},
|
||||
},
|
||||
tokenAgents: map[string]string{"fresh-token": "canonical-agent"},
|
||||
}
|
||||
executor := dockerContainerActionExecutor{agents: agents}
|
||||
ctx := context.WithValue(context.Background(), OrgIDContextKey, "org-b")
|
||||
resource := unified.Resource{Docker: &unified.DockerData{
|
||||
TokenID: "fresh-token",
|
||||
AgentID: "stale-agent",
|
||||
Hostname: "docker-host",
|
||||
}}
|
||||
|
||||
agentID, err := executor.connectedDockerCommandAgentID(ctx, resource)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve command session: %v", err)
|
||||
}
|
||||
if agentID != "canonical-agent" || agents.lastOrg != "org-b" {
|
||||
t.Fatalf("resolved agent/org = %q/%q, want canonical-agent/org-b", agentID, agents.lastOrg)
|
||||
}
|
||||
|
||||
delete(agents.tokenAgents, "fresh-token")
|
||||
if agentID, err := executor.connectedDockerCommandAgentID(ctx, resource); err == nil {
|
||||
t.Fatalf("stale token unexpectedly fell back to report identity %q", agentID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerContainerActionExecutorDispatchesPodmanRestartAndVerification(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()})
|
||||
|
||||
@@ -58,7 +58,7 @@ func (e hostStorageCleanupActionExecutor) ActionDispatchOperationKinds() []strin
|
||||
return []string{agentexec.HostStorageCleanupOperationPackageCache}
|
||||
}
|
||||
|
||||
func (e hostStorageCleanupActionExecutor) CheckActionAvailable(_ context.Context, req unified.ActionRequest, resource unified.Resource) unified.ResourceActionReadiness {
|
||||
func (e hostStorageCleanupActionExecutor) CheckActionAvailable(ctx context.Context, req unified.ActionRequest, resource unified.Resource) unified.ResourceActionReadiness {
|
||||
if strings.TrimSpace(req.CapabilityName) != hostStorageCleanupCapability {
|
||||
return unified.ResourceActionReadiness{}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ func (e hostStorageCleanupActionExecutor) CheckActionAvailable(_ context.Context
|
||||
if !ok || capability.InternalHandler != hostStorageCleanupActionHandler {
|
||||
return unified.ResourceActionReadiness{}
|
||||
}
|
||||
if err := e.validateResource(resource); err != nil {
|
||||
if err := e.validateResource(ctx, resource); err != nil {
|
||||
return unified.ResourceActionReadiness{
|
||||
Name: hostStorageCleanupCapability,
|
||||
Available: false,
|
||||
@@ -107,7 +107,7 @@ func (e hostStorageCleanupActionExecutor) ExecuteAction(ctx context.Context, rec
|
||||
if agentexec.HostStorageCleanupOperationIdentity(agentID, req) != (operationreceipt.Identity{AttemptID: attempt.ID, ActionID: attempt.ActionID, OperationKind: attempt.OperationKind, OperationVersion: attempt.OperationVersion, RequestDigest: attempt.RequestDigest, AgentID: attempt.AgentID}) {
|
||||
return nil, fmt.Errorf("host cleanup dispatch binding drift")
|
||||
}
|
||||
result, err := e.agents.ExecuteHostStorageCleanup(ctx, agentID, req)
|
||||
result, err := e.agents.ExecuteHostStorageCleanup(agentCommandContext(ctx), agentID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -130,7 +130,7 @@ func (e hostStorageCleanupActionExecutor) ReconcileActionDispatch(ctx context.Co
|
||||
if !ok {
|
||||
return nil, unified.ActionDispatchReceipt{}, false, nil
|
||||
}
|
||||
query, err := querier.QueryAgentOperation(ctx, attempt.AgentID, identity)
|
||||
query, err := querier.QueryAgentOperation(agentCommandContext(ctx), attempt.AgentID, identity)
|
||||
if err != nil {
|
||||
return nil, unified.ActionDispatchReceipt{}, false, err
|
||||
}
|
||||
@@ -174,13 +174,13 @@ func (e hostStorageCleanupActionExecutor) currentResource(ctx context.Context, r
|
||||
if !ok || resource == nil {
|
||||
return unified.Resource{}, fmt.Errorf("resource %q is no longer present", resourceID)
|
||||
}
|
||||
if err := e.validateResource(*resource); err != nil {
|
||||
if err := e.validateResource(ctx, *resource); err != nil {
|
||||
return unified.Resource{}, err
|
||||
}
|
||||
return *resource, nil
|
||||
}
|
||||
|
||||
func (e hostStorageCleanupActionExecutor) validateResource(resource unified.Resource) error {
|
||||
func (e hostStorageCleanupActionExecutor) validateResource(ctx context.Context, resource unified.Resource) error {
|
||||
if resource.Type != unified.ResourceTypeAgent || resource.Agent == nil {
|
||||
return fmt.Errorf("resource is not an agent-managed host")
|
||||
}
|
||||
@@ -214,11 +214,10 @@ func (e hostStorageCleanupActionExecutor) validateResource(resource unified.Reso
|
||||
return fmt.Errorf("package cache filesystem is not under storage pressure")
|
||||
}
|
||||
agentID := strings.TrimSpace(resource.Agent.AgentID)
|
||||
liveCapability, supported := e.agents.(agentOperationReceiptCapability)
|
||||
if agentID == "" || e.agents == nil || !e.agents.IsAgentConnected(agentID) {
|
||||
if agentID == "" || e.agents == nil || !isAgentCommandConnected(ctx, e.agents, agentID) {
|
||||
return fmt.Errorf("host command agent is disconnected")
|
||||
}
|
||||
if !supported || liveCapability.AgentOperationReceiptVersion(agentID) != operationreceipt.ProtocolVersion {
|
||||
if liveAgentOperationReceiptVersion(ctx, e.agents, agentID) != operationreceipt.ProtocolVersion {
|
||||
return fmt.Errorf("live durable operation receipt protocol is unsupported")
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -71,7 +71,7 @@ func (e hostUpdateActionExecutor) CheckActionAvailable(ctx context.Context, req
|
||||
if !ok || capability.InternalHandler != hostPackageUpdateActionHandler {
|
||||
return unified.ResourceActionReadiness{}
|
||||
}
|
||||
if err := e.validateResource(resource); err != nil {
|
||||
if err := e.validateResource(ctx, resource); err != nil {
|
||||
return unified.ResourceActionReadiness{
|
||||
Name: hostPackageUpdateCapability,
|
||||
Available: false,
|
||||
@@ -112,7 +112,7 @@ func (e hostUpdateActionExecutor) ExecuteAction(ctx context.Context, record unif
|
||||
if agentexec.HostUpdateOperationIdentity(agentID, req) != (operationreceipt.Identity{AttemptID: attempt.ID, ActionID: attempt.ActionID, OperationKind: attempt.OperationKind, OperationVersion: attempt.OperationVersion, RequestDigest: attempt.RequestDigest, AgentID: attempt.AgentID}) {
|
||||
return nil, fmt.Errorf("host update dispatch binding drift")
|
||||
}
|
||||
result, err := e.agents.ExecuteHostUpdate(ctx, agentID, req)
|
||||
result, err := e.agents.ExecuteHostUpdate(agentCommandContext(ctx), agentID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -135,7 +135,7 @@ func (e hostUpdateActionExecutor) ReconcileActionDispatch(ctx context.Context, r
|
||||
if !ok {
|
||||
return nil, unified.ActionDispatchReceipt{}, false, nil
|
||||
}
|
||||
query, err := querier.QueryAgentOperation(ctx, attempt.AgentID, identity)
|
||||
query, err := querier.QueryAgentOperation(agentCommandContext(ctx), attempt.AgentID, identity)
|
||||
if err != nil {
|
||||
return nil, unified.ActionDispatchReceipt{}, false, err
|
||||
}
|
||||
@@ -186,13 +186,13 @@ func (e hostUpdateActionExecutor) currentResource(ctx context.Context, resourceI
|
||||
if !ok || resource == nil {
|
||||
return unified.Resource{}, fmt.Errorf("resource %q is no longer present", resourceID)
|
||||
}
|
||||
if err := e.validateResource(*resource); err != nil {
|
||||
if err := e.validateResource(ctx, *resource); err != nil {
|
||||
return unified.Resource{}, err
|
||||
}
|
||||
return *resource, nil
|
||||
}
|
||||
|
||||
func (e hostUpdateActionExecutor) validateResource(resource unified.Resource) error {
|
||||
func (e hostUpdateActionExecutor) validateResource(ctx context.Context, resource unified.Resource) error {
|
||||
if resource.Type != unified.ResourceTypeAgent || resource.Agent == nil {
|
||||
return fmt.Errorf("resource is not an agent-managed host")
|
||||
}
|
||||
@@ -223,11 +223,10 @@ func (e hostUpdateActionExecutor) validateResource(resource unified.Resource) er
|
||||
return fmt.Errorf("host has no pending package updates")
|
||||
}
|
||||
agentID := strings.TrimSpace(resource.Agent.AgentID)
|
||||
liveCapability, supported := e.agents.(agentOperationReceiptCapability)
|
||||
if agentID == "" || e.agents == nil || !e.agents.IsAgentConnected(agentID) {
|
||||
if agentID == "" || e.agents == nil || !isAgentCommandConnected(ctx, e.agents, agentID) {
|
||||
return fmt.Errorf("host command agent is disconnected")
|
||||
}
|
||||
if !supported || liveCapability.AgentOperationReceiptVersion(agentID) != operationreceipt.ProtocolVersion {
|
||||
if liveAgentOperationReceiptVersion(ctx, e.agents, agentID) != operationreceipt.ProtocolVersion {
|
||||
return fmt.Errorf("live durable operation receipt protocol is unsupported")
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -82,7 +82,7 @@ func (e proxmoxGuestActionExecutor) ExecuteAction(ctx context.Context, record un
|
||||
return nil, err
|
||||
}
|
||||
vmid := resource.Proxmox.VMID
|
||||
agentID, err := e.connectedProxmoxNodeCommandAgentID(resource)
|
||||
agentID, err := e.connectedProxmoxNodeCommandAgentID(ctx, resource)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -95,7 +95,7 @@ func (e proxmoxGuestActionExecutor) ExecuteAction(ctx context.Context, record un
|
||||
|
||||
command := proxmoxGuestLifecycleCommand(kind, operation, vmid)
|
||||
actionStartedAt := time.Now().UTC()
|
||||
result, err := e.agents.ExecuteCommand(ctx, agentID, agentexec.ExecuteCommandPayload{
|
||||
result, err := e.agents.ExecuteCommand(agentCommandContext(ctx), agentID, agentexec.ExecuteCommandPayload{
|
||||
RequestID: attempt.ID,
|
||||
Command: command,
|
||||
ApprovalID: record.ID,
|
||||
@@ -129,7 +129,7 @@ func (e proxmoxGuestActionExecutor) CheckActionAvailable(ctx context.Context, re
|
||||
if _, _, err := e.executableProxmoxGuestResource(resource, operation); err != nil {
|
||||
return unavailableProxmoxActionReadiness(operation, proxmoxActionUnavailableReasonCode(err), proxmoxActionUnavailableReason(err))
|
||||
}
|
||||
if _, err := e.connectedProxmoxNodeCommandAgentID(resource); err != nil {
|
||||
if _, err := e.connectedProxmoxNodeCommandAgentID(ctx, resource); err != nil {
|
||||
return unavailableProxmoxActionReadiness(operation, "command_agent_disconnected", "Proxmox node command agent is not connected.")
|
||||
}
|
||||
return readiness
|
||||
@@ -183,19 +183,19 @@ func (e proxmoxGuestActionExecutor) executableProxmoxGuestResource(resource unif
|
||||
return resource, kind, nil
|
||||
}
|
||||
|
||||
func (e proxmoxGuestActionExecutor) connectedProxmoxNodeCommandAgentID(resource unified.Resource) (string, error) {
|
||||
func (e proxmoxGuestActionExecutor) connectedProxmoxNodeCommandAgentID(ctx context.Context, resource unified.Resource) (string, error) {
|
||||
if e.agents == nil {
|
||||
return "", fmt.Errorf("proxmox node command agent is not connected")
|
||||
}
|
||||
if resource.Proxmox == nil {
|
||||
return "", fmt.Errorf("proxmox resource metadata missing")
|
||||
}
|
||||
if agentID := strings.TrimSpace(resource.Proxmox.LinkedAgentID); agentID != "" && e.agents.IsAgentConnected(agentID) {
|
||||
if agentID := strings.TrimSpace(resource.Proxmox.LinkedAgentID); agentID != "" && isAgentCommandConnected(ctx, e.agents, agentID) {
|
||||
return agentID, nil
|
||||
}
|
||||
if agentID, ok := e.agents.GetAgentForHost(strings.TrimSpace(resource.Proxmox.NodeName)); ok {
|
||||
if agentID, ok := commandAgentForHost(ctx, e.agents, strings.TrimSpace(resource.Proxmox.NodeName)); ok {
|
||||
agentID = strings.TrimSpace(agentID)
|
||||
if agentID != "" && e.agents.IsAgentConnected(agentID) {
|
||||
if agentID != "" && isAgentCommandConnected(ctx, e.agents, agentID) {
|
||||
return agentID, nil
|
||||
}
|
||||
}
|
||||
@@ -344,7 +344,7 @@ func (e proxmoxGuestActionExecutor) verifyProxmoxGuestState(ctx context.Context,
|
||||
}
|
||||
}
|
||||
|
||||
result, err := e.agents.ExecuteCommand(ctx, agentID, agentexec.ExecuteCommandPayload{
|
||||
result, err := e.agents.ExecuteCommand(agentCommandContext(ctx), agentID, agentexec.ExecuteCommandPayload{
|
||||
RequestID: fmt.Sprintf("%s-verify-%d", actionID, attempt+1),
|
||||
Command: command,
|
||||
ApprovalID: actionID,
|
||||
|
||||
+14
-1
@@ -673,8 +673,21 @@ func (r *Router) setupRoutes() {
|
||||
r.systemSettingsHandler = NewSystemSettingsHandler(r.config, r.persistence, r.wsHub, r.mtMonitor, r.monitor, r.reloadSystemSettings, r.reloadFunc)
|
||||
|
||||
// Agent execution server for AI tool use
|
||||
r.agentExecServer = agentexec.NewServer(r.validateAgentExecToken)
|
||||
r.agentExecServer = agentexec.NewServerWithAdmissionValidator(r.admitAgentExecToken, r.validateAgentExecSession)
|
||||
r.agentExecServer.SetCommandAuthorizationVerifier(verifyAndConsumeCommandAuthorization)
|
||||
if r.connectionsHandlers != nil {
|
||||
r.connectionsHandlers.SetAgentCommandSessionProvider(func(organizationID, tokenID, agentID, hostname string) bool {
|
||||
if strings.TrimSpace(tokenID) != "" {
|
||||
_, connected := r.agentExecServer.GetAgentForTokenForOrganization(organizationID, tokenID)
|
||||
return connected
|
||||
}
|
||||
if strings.TrimSpace(agentID) != "" && r.agentExecServer.IsAgentConnectedForOrganization(organizationID, agentID) {
|
||||
return true
|
||||
}
|
||||
_, connected := r.agentExecServer.GetAgentForHostForOrganization(organizationID, hostname)
|
||||
return connected
|
||||
})
|
||||
}
|
||||
if r.resourceHandlers != nil {
|
||||
r.resourceHandlers.SetActionExecutor(newRoutedActionExecutor(
|
||||
r.resourceHandlers,
|
||||
|
||||
@@ -752,7 +752,7 @@ func (a *agentCommandAdapter) ExecuteCommand(ctx context.Context, agentID, comma
|
||||
if a.handler.agentServer == nil {
|
||||
return "", "", -1, fmt.Errorf("agent server not available")
|
||||
}
|
||||
result, execErr := a.handler.agentServer.ExecuteCommand(ctx, agentID, agentexec.ExecuteCommandPayload{
|
||||
result, execErr := a.handler.agentServer.ExecuteCommand(agentCommandContext(ctx), agentID, agentexec.ExecuteCommandPayload{
|
||||
Command: command,
|
||||
TargetType: "agent",
|
||||
})
|
||||
@@ -766,7 +766,9 @@ func (a *agentCommandAdapter) FindAgentForTarget(targetHost string) string {
|
||||
if a.handler.agentServer == nil {
|
||||
return ""
|
||||
}
|
||||
agents := a.handler.agentServer.GetConnectedAgents()
|
||||
// This legacy resolver is only used by the default-organization adapter.
|
||||
// Tenant Assistant services receive an organization-pinned server view.
|
||||
agents := a.handler.agentServer.GetConnectedAgentsForOrganization("default")
|
||||
if len(agents) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -562,6 +562,60 @@ func TestAgentExecTokenBindingEnforced(t *testing.T) {
|
||||
conn.Close()
|
||||
}
|
||||
|
||||
func TestAgentExecTokenRejectsAmbiguousMultiOrganizationAuthority(t *testing.T) {
|
||||
rawToken := "multi-org-agent-token-123.12345678"
|
||||
record := newTokenRecord(t, rawToken, []string{config.ScopeAgentExec}, map[string]string{
|
||||
"bound_agent_id": "agent-1",
|
||||
"bound_hostname": "host-1",
|
||||
agentExecBindingVersionKey: agentExecBindingVersion,
|
||||
})
|
||||
record.OrgIDs = []string{"org-a", "org-b"}
|
||||
cfg := newTestConfigWithTokens(t, record)
|
||||
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
|
||||
|
||||
if _, ok := router.admitAgentExecToken(rawToken, "agent-1", "host-1"); ok {
|
||||
t.Fatal("multi-organization exec token was admitted to an ambiguous command session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentExecTokenBindingFailsClosedAndRestoresMetadataWhenPersistenceFails(t *testing.T) {
|
||||
rawToken := "persist-failure-agent-token-123.12345678"
|
||||
record := newTokenRecord(t, rawToken, []string{config.ScopeAgentExec}, map[string]string{
|
||||
"install_type": "docker",
|
||||
"issued_via": agentInstallIssuedViaConfig,
|
||||
})
|
||||
cfg := newTestConfigWithTokens(t, record)
|
||||
|
||||
notDirectory := filepath.Join(t.TempDir(), "not-a-directory")
|
||||
persistence := config.NewConfigPersistence(notDirectory)
|
||||
if err := os.RemoveAll(notDirectory); err != nil {
|
||||
t.Fatalf("remove persistence directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(notDirectory, []byte("blocked"), 0600); err != nil {
|
||||
t.Fatalf("create persistence blocker: %v", err)
|
||||
}
|
||||
router := &Router{
|
||||
config: cfg,
|
||||
persistence: persistence,
|
||||
}
|
||||
|
||||
if _, ok := router.admitAgentExecToken(rawToken, "agent-1", "host-1"); ok {
|
||||
t.Fatal("first-use command admission succeeded without durable identity binding")
|
||||
}
|
||||
config.Mu.RLock()
|
||||
defer config.Mu.RUnlock()
|
||||
for _, key := range []string{
|
||||
"bound_agent_id",
|
||||
"bound_hostname",
|
||||
"bound_at",
|
||||
agentExecBindingVersionKey,
|
||||
} {
|
||||
if _, present := cfg.APITokens[0].Metadata[key]; present {
|
||||
t.Fatalf("failed persistence left transient %q binding metadata behind", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentExecTokenBindingAcceptsHostnameMatch covers the deploy/enroll flow
|
||||
// where the runtime token carries both bound_agent_id (server-canonical
|
||||
// "agent-<hostname>" form) and bound_hostname, but the agent's runtime
|
||||
@@ -615,6 +669,43 @@ func TestAgentExecTokenBindingAcceptsHostnameMatch(t *testing.T) {
|
||||
t.Fatalf("expected registration to be accepted when hostname matches bound_hostname, got %q", reg.Message)
|
||||
}
|
||||
conn.Close()
|
||||
config.Mu.RLock()
|
||||
if got := cfg.APITokens[0].Metadata["bound_agent_id"]; got != "f0c1b2a3e4d5f60718293a4b5c6d7e8f" {
|
||||
config.Mu.RUnlock()
|
||||
t.Fatalf("legacy hostname binding migrated agent id = %q", got)
|
||||
}
|
||||
if got := cfg.APITokens[0].Metadata[agentExecBindingVersionKey]; got != agentExecBindingVersion {
|
||||
config.Mu.RUnlock()
|
||||
t.Fatalf("legacy binding version = %q", got)
|
||||
}
|
||||
config.Mu.RUnlock()
|
||||
|
||||
// The hostname exception is a one-time upgrade migration. Once the runtime
|
||||
// identity is persisted, replay with a second ID on the same host fails.
|
||||
conn, _, err = websocket.DefaultDialer.Dial(wsURL, wsHeadersForHTTP(t, ts.URL))
|
||||
if err != nil {
|
||||
t.Fatalf("Dial: %v", err)
|
||||
}
|
||||
regMsg, err = agentexec.NewMessage(agentexec.MsgTypeAgentRegister, "", agentexec.AgentRegisterPayload{
|
||||
AgentID: "second-runtime-id",
|
||||
Hostname: "prox97",
|
||||
Version: "1.0.0",
|
||||
Platform: "linux",
|
||||
Token: rawToken,
|
||||
})
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
t.Fatalf("NewMessage: %v", err)
|
||||
}
|
||||
if err := conn.WriteJSON(regMsg); err != nil {
|
||||
conn.Close()
|
||||
t.Fatalf("WriteJSON: %v", err)
|
||||
}
|
||||
if replay := readRegisteredPayload(t, conn); replay.Success {
|
||||
conn.Close()
|
||||
t.Fatal("migrated token accepted replay under a second runtime identity")
|
||||
}
|
||||
conn.Close()
|
||||
|
||||
// Mismatched hostname AND mismatched agent_id must still be rejected —
|
||||
// a leaked token cannot be used from a different host claiming a different
|
||||
|
||||
+30
-12
@@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -186,10 +187,9 @@ func Run(ctx context.Context, version string) error {
|
||||
}
|
||||
defer mainListener.Close()
|
||||
|
||||
// Optional dedicated agent-ingest listener. When PULSE_AGENT_INGEST_PORT is
|
||||
// set, agent report/management traffic (/api/agents/*) is also served on this
|
||||
// separate port so operators can expose it on its own network/firewall
|
||||
// boundary without exposing the web UI or the rest of the REST API there.
|
||||
// Optional dedicated agent-control listener. Reports, command admission,
|
||||
// version checks, and bootstrap downloads share this network boundary;
|
||||
// the web UI and management API are not exposed on it.
|
||||
var agentListener net.Listener
|
||||
if cfg.AgentIngestPort > 0 {
|
||||
agentAddr := fmt.Sprintf("%s:%d", cfg.BindAddress, cfg.AgentIngestPort)
|
||||
@@ -633,9 +633,8 @@ func Run(ctx context.Context, version string) error {
|
||||
},
|
||||
}
|
||||
|
||||
// When a dedicated agent-ingest listener is configured, serve only the
|
||||
// /api/agents/* surface on it so that port never exposes the web UI or the
|
||||
// rest of the REST API.
|
||||
// The dedicated listener serves the complete agent control plane, while
|
||||
// excluding the web UI and management REST API.
|
||||
var agentSrv *http.Server
|
||||
if agentListener != nil {
|
||||
agentSrv = &http.Server{
|
||||
@@ -876,13 +875,12 @@ shutdown:
|
||||
return runErr
|
||||
}
|
||||
|
||||
// agentIngestHandler restricts a handler to the agent-ingest surface
|
||||
// (/api/agents/*). It backs the optional dedicated agent-ingest listener so
|
||||
// that port serves only agent report/management endpoints and never the web UI
|
||||
// or the rest of the REST API. Every other path returns 404.
|
||||
// agentIngestHandler restricts a handler to the complete agent control plane.
|
||||
// Reports, command admission, version checks, and bootstrap downloads must
|
||||
// share one reachable listener; management APIs and the web UI remain absent.
|
||||
func agentIngestHandler(inner http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasPrefix(r.URL.Path, "/api/agents/") {
|
||||
if !isAgentControlPlanePath(r.URL.Path) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
@@ -890,6 +888,26 @@ func agentIngestHandler(inner http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func isAgentControlPlanePath(requestPath string) bool {
|
||||
if requestPath == "" || path.Clean(requestPath) != requestPath {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(requestPath, "/api/agents/") {
|
||||
return true
|
||||
}
|
||||
switch requestPath {
|
||||
case "/api/agent/ws",
|
||||
"/api/agent/version",
|
||||
"/api/server/info",
|
||||
"/install.sh",
|
||||
"/install.ps1",
|
||||
"/download/pulse-agent":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// startMetricsServer starts the Prometheus /metrics endpoint. When metricsToken
|
||||
// is non-empty, requests must include a matching Authorization: Bearer <token> header.
|
||||
func startMetricsServer(ctx context.Context, addr string, metricsToken string, allowInsecureRemote bool) error {
|
||||
|
||||
@@ -12,7 +12,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/extensions"
|
||||
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/metrics"
|
||||
@@ -36,6 +39,12 @@ func TestAgentIngestHandler(t *testing.T) {
|
||||
{"/api/agents/kubernetes/report", true, http.StatusOK},
|
||||
{"/api/agents/agent/lookup", true, http.StatusOK},
|
||||
{"/api/agents/agent/config", true, http.StatusOK},
|
||||
{"/api/agent/ws", true, http.StatusOK},
|
||||
{"/api/agent/version", true, http.StatusOK},
|
||||
{"/api/server/info", true, http.StatusOK},
|
||||
{"/install.sh", true, http.StatusOK},
|
||||
{"/install.ps1", true, http.StatusOK},
|
||||
{"/download/pulse-agent", true, http.StatusOK},
|
||||
// Everything outside the agent-ingest surface must be rejected so the
|
||||
// dedicated port never exposes the web UI or the rest of the REST API.
|
||||
{"/", false, http.StatusNotFound},
|
||||
@@ -44,6 +53,10 @@ func TestAgentIngestHandler(t *testing.T) {
|
||||
{"/api/state", false, http.StatusNotFound},
|
||||
{"/api/security/status", false, http.StatusNotFound},
|
||||
{"/api/agents", false, http.StatusNotFound},
|
||||
{"/api/agents/../security/status", false, http.StatusNotFound},
|
||||
{"/api/agents//agent/report", false, http.StatusNotFound},
|
||||
{"/api/agent/ws/extra", false, http.StatusNotFound},
|
||||
{"/install.sh/extra", false, http.StatusNotFound},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
innerCalled = false
|
||||
@@ -59,6 +72,49 @@ func TestAgentIngestHandler(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentControlPlaneListenerAdmitsCommandWebSocket(t *testing.T) {
|
||||
execServer := agentexec.NewServer(func(token, agentID, hostname string) bool {
|
||||
return token == "exec-token" && agentID == "docker-agent" && hostname == "docker-host"
|
||||
})
|
||||
t.Cleanup(execServer.Shutdown)
|
||||
server := httptest.NewServer(agentIngestHandler(http.HandlerFunc(execServer.HandleWebSocket)))
|
||||
defer server.Close()
|
||||
|
||||
origin, err := securityutil.HTTPOriginForWebSocketBaseURL(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("origin: %v", err)
|
||||
}
|
||||
headers := http.Header{}
|
||||
headers.Set("Origin", origin)
|
||||
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/api/agent/ws"
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, headers)
|
||||
if err != nil {
|
||||
t.Fatalf("dial dedicated agent command websocket: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
registration, err := agentexec.NewMessage(agentexec.MsgTypeAgentRegister, "", agentexec.AgentRegisterPayload{
|
||||
AgentID: "docker-agent", Hostname: "docker-host", Token: "exec-token",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("registration message: %v", err)
|
||||
}
|
||||
if err := conn.WriteJSON(registration); err != nil {
|
||||
t.Fatalf("write registration: %v", err)
|
||||
}
|
||||
var response agentexec.Message
|
||||
if err := conn.ReadJSON(&response); err != nil {
|
||||
t.Fatalf("read registration acknowledgement: %v", err)
|
||||
}
|
||||
var acknowledged agentexec.RegisteredPayload
|
||||
if err := response.DecodePayload(&acknowledged); err != nil {
|
||||
t.Fatalf("decode acknowledgement: %v", err)
|
||||
}
|
||||
if !acknowledged.Success || !execServer.IsAgentConnected("docker-agent") {
|
||||
t.Fatalf("dedicated listener did not admit command channel: %+v", acknowledged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusinessHooks(t *testing.T) {
|
||||
called := false
|
||||
hook := func(store *metrics.Store) {
|
||||
|
||||
@@ -287,6 +287,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
|
||||
"internal/monitoring/availability_poller_test.go",
|
||||
"internal/monitoring/availability_udp_test.go",
|
||||
"internal/monitoring/canonical_guardrails_test.go",
|
||||
"internal/monitoring/ceph_test.go",
|
||||
"internal/monitoring/issue1485_unraid_lifecycle_test.go",
|
||||
"internal/monitoring/issue1595_collection_trust_test.go",
|
||||
"internal/monitoring/issue1613_contract_test.go",
|
||||
@@ -300,6 +301,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
|
||||
"internal/monitoring/monitor_pve_cluster_refresh_test.go",
|
||||
"internal/monitoring/monitor_pve_guest_lxc_test.go",
|
||||
"internal/monitoring/ratetracker_test.go",
|
||||
"internal/monitoring/truenas_poller_test.go",
|
||||
"internal/unifiedresources/code_standards_test.go",
|
||||
],
|
||||
}
|
||||
@@ -466,6 +468,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
|
||||
"internal/api/resources_test.go",
|
||||
"internal/storagehealth/risk_test.go",
|
||||
"internal/storagehealth/topology_test.go",
|
||||
"internal/storagehealth/zfs_pool_health_contract_test.go",
|
||||
],
|
||||
}
|
||||
],
|
||||
@@ -1163,11 +1166,13 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
|
||||
"frontend-modern/src/types/api.ts",
|
||||
"internal/api/ai_handlers_more_test.go",
|
||||
"internal/api/ai_handlers_patrol_actions_additional_test.go",
|
||||
"internal/api/audit_handlers_test.go",
|
||||
"internal/api/contract_test.go",
|
||||
"internal/api/docker_agents_report_size_test.go",
|
||||
"internal/api/host_agent_removal_lifecycle_integration_test.go",
|
||||
"internal/api/metadata_handlers_test.go",
|
||||
"internal/api/patrol_autopilot_test.go",
|
||||
"pulse-enterprise:test/extensions_contract_test.go",
|
||||
],
|
||||
}
|
||||
],
|
||||
@@ -2572,6 +2577,7 @@ None yet.
|
||||
"exact_files": [
|
||||
"frontend-modern/src/components/Settings/__tests__/dataHandlingPanelModel.test.ts",
|
||||
"frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts",
|
||||
"frontend-modern/src/components/Settings/__tests__/useAuditLogPanelState.test.tsx",
|
||||
],
|
||||
}
|
||||
],
|
||||
@@ -2597,6 +2603,7 @@ None yet.
|
||||
"exact_files": [
|
||||
"frontend-modern/src/components/Settings/__tests__/dataHandlingPanelModel.test.ts",
|
||||
"frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts",
|
||||
"frontend-modern/src/components/Settings/__tests__/useAuditLogPanelState.test.tsx",
|
||||
],
|
||||
}
|
||||
],
|
||||
@@ -2622,6 +2629,7 @@ None yet.
|
||||
"exact_files": [
|
||||
"frontend-modern/src/components/Settings/__tests__/dataHandlingPanelModel.test.ts",
|
||||
"frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts",
|
||||
"frontend-modern/src/components/Settings/__tests__/useAuditLogPanelState.test.tsx",
|
||||
],
|
||||
}
|
||||
],
|
||||
@@ -2653,6 +2661,7 @@ None yet.
|
||||
"exact_files": [
|
||||
"frontend-modern/src/components/Settings/__tests__/dataHandlingPanelModel.test.ts",
|
||||
"frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts",
|
||||
"frontend-modern/src/components/Settings/__tests__/useAuditLogPanelState.test.tsx",
|
||||
],
|
||||
}
|
||||
],
|
||||
@@ -2722,6 +2731,7 @@ None yet.
|
||||
[
|
||||
"frontend-modern/src/components/Settings/__tests__/dataHandlingPanelModel.test.ts",
|
||||
"frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts",
|
||||
"frontend-modern/src/components/Settings/__tests__/useAuditLogPanelState.test.tsx",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -2205,6 +2205,7 @@ class SubsystemLookupTest(unittest.TestCase):
|
||||
[
|
||||
"frontend-modern/src/components/Settings/__tests__/dataHandlingPanelModel.test.ts",
|
||||
"frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts",
|
||||
"frontend-modern/src/components/Settings/__tests__/useAuditLogPanelState.test.tsx",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -2263,6 +2264,7 @@ class SubsystemLookupTest(unittest.TestCase):
|
||||
[
|
||||
"frontend-modern/src/components/Settings/__tests__/dataHandlingPanelModel.test.ts",
|
||||
"frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts",
|
||||
"frontend-modern/src/components/Settings/__tests__/useAuditLogPanelState.test.tsx",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -2294,6 +2296,7 @@ class SubsystemLookupTest(unittest.TestCase):
|
||||
[
|
||||
"frontend-modern/src/components/Settings/__tests__/dataHandlingPanelModel.test.ts",
|
||||
"frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts",
|
||||
"frontend-modern/src/components/Settings/__tests__/useAuditLogPanelState.test.tsx",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -2325,6 +2328,7 @@ class SubsystemLookupTest(unittest.TestCase):
|
||||
[
|
||||
"frontend-modern/src/components/Settings/__tests__/dataHandlingPanelModel.test.ts",
|
||||
"frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts",
|
||||
"frontend-modern/src/components/Settings/__tests__/useAuditLogPanelState.test.tsx",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -2356,6 +2360,7 @@ class SubsystemLookupTest(unittest.TestCase):
|
||||
[
|
||||
"frontend-modern/src/components/Settings/__tests__/dataHandlingPanelModel.test.ts",
|
||||
"frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts",
|
||||
"frontend-modern/src/components/Settings/__tests__/useAuditLogPanelState.test.tsx",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -2387,6 +2392,7 @@ class SubsystemLookupTest(unittest.TestCase):
|
||||
[
|
||||
"frontend-modern/src/components/Settings/__tests__/dataHandlingPanelModel.test.ts",
|
||||
"frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts",
|
||||
"frontend-modern/src/components/Settings/__tests__/useAuditLogPanelState.test.tsx",
|
||||
],
|
||||
)
|
||||
security_match = next(
|
||||
@@ -2435,6 +2441,7 @@ class SubsystemLookupTest(unittest.TestCase):
|
||||
[
|
||||
"frontend-modern/src/components/Settings/__tests__/dataHandlingPanelModel.test.ts",
|
||||
"frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts",
|
||||
"frontend-modern/src/components/Settings/__tests__/useAuditLogPanelState.test.tsx",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -2518,6 +2525,7 @@ class SubsystemLookupTest(unittest.TestCase):
|
||||
[
|
||||
"frontend-modern/src/components/Settings/__tests__/dataHandlingPanelModel.test.ts",
|
||||
"frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts",
|
||||
"frontend-modern/src/components/Settings/__tests__/useAuditLogPanelState.test.tsx",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user