DEMO/Infrastructure

← Back to dashboard

Hypervisor connections through an agent

Audience: operator · Profile: both · Read this when: a hypervisor the dashboard cannot route to needs managing.

Part of Remote Agents. The verbs, what the agent can reach, the sibling runner and VMware Workstation Pro.

Hypervisor connections

Once an agent is enrolled it can also broker hypervisor operations — inventory sync on a schedule, and power verbs on demand — for vCenter, Proxmox, Nutanix, XCP-ng, Hyper-V and bare ESXi endpoints the dashboard has no route to. Which of the two each product gets is its own table; Nutanix syncs but cannot be powered, and a page whose button has no agent verb says so rather than sending the nearest one.

The dashboard may hold the secret, never the target

A dashboard connection row bound to an agent stores the agent's name for it and never a host or a username. The job says "run inventory_sync on dc1-vcenter" and the agent resolves the endpoint from its own connections.yaml.

This binding decides which agent discovers and powers the estate. Which agent configures the guests it finds is a separate question, because the two are not always the same host — see which agent executes a run.

That asymmetry is the difference between this and a proxy, and it is not about secrecy — it is about aiming. A dashboard that could set host could redirect the agent's authenticated session at an endpoint of its choosing and harvest the credential on first use; one that could set username could spray a known password across accounts. So those stay yours, in your file, gated by your policy.yaml, which no dashboard API can reach.

The credential is the part that can move. The recommended place for it is the dashboard, fetched per job by an agent attested through SPIRE. That is the last row below, and Central storage with SPIRE explains why. The other rows stay supported:

Where the credential lives An attacker who reads files on the agent host gets An attacker who compromises the dashboard gets
password / password_file the hypervisor password. Offline, permanent, and a file read is not an event anybody sees a verb and a name
password_sealed the hypervisor password, if they can read the state volume as well as the config file — and nothing from a copy of the config file alone. See Sealing a credential this host keeps a verb and a name
ps_managed_account a Password Safe OAuth client — usually entitled to more than the one account a verb and a name
dashboard_secret + a stored password the agent's identity key: the ability to request a credential while a job runs, audited and revocable the password
dashboard_secret + ps_account:// the same narrow request ability a Password Safe account id, subject to its policy, approval workflow and rotation
dashboard_secret + ps_account://, agent attested through SPIRE, release limited to SPIRE-attested agents no agent key: it is only in memory and is replaced on every restart. On a cloud VM, nothing at all. On a join-token host, the SPIRE agent's own key, which works only until SPIRE rotates it a Password Safe account id, subject to its policy, approval workflow and rotation

The last two rows are the only configurations in which neither side holds a standing hypervisor credential. The last row is the only one in which a copy of the agent host's disk yields nothing durable either. It is the recommended configuration.

A host-side credential (password, password_file, password_sealed) is no longer the recommendation, but it stays available. Nothing is migrated for you: a connection takes its credential from the dashboard only once its entry in your connections.yaml says dashboard_secret: true.

The cost is two files joined by a string, and a typo in either yields unknown connection 'dc1-vcenter'. The connection form mitigates that by offering an agent picker rather than a free-text uuid.

Four grants, all required

Nothing runs unless all four agree, and they belong to different people:

Grant Who owns it Where
this agent may run agent_hypervisor the dashboard operator Agents page
this verb is allowed on this connection you policy.yaml → connections:
this connection exists, and where its credential comes from you connections.yaml
the credential itself, when dashboard_secret is set the dashboard operator Connections tab

Withhold any one and nothing happens. The fourth is the only one the dashboard owns, and it owns it only because you said so in the third — a credential set on the Connections tab for an agent-bound connection does nothing at all until your file opts that entry in. A refusal from the second or third arrives in Live Output naming the file and the line to add — the dashboard cannot fix it and does not pretend to.

The verbs

inventory_sync is read-only and runs on a schedule (default every 30 minutes; override per connection with options.sync_interval_minutes). power_on, power_off, power_reset, shutdown, reboot and restart are on-demand, issued by the existing power buttons on the hypervisor pages when the resolved connection is agent-bound.

set_tags writes a guest's tags, and is Proxmox only — it is the one product here whose tags the dashboard reads, and vSphere, Nutanix and XCP-ng each model them differently. It is also the first verb to carry a value an operator typed, so two things are true of it that are not true of the others:

set_tags also queues an inventory_sync on completion, like the power verbs and unlike snapshot, because tags is a column the cache stores: without it the page would keep showing the pre-edit tags and the next edit would be computed against them.

It needs a re-pulled agent and a policy line. Add set_tags to that connection's verbs: list in your policy.yaml. An agent that predates the verb refuses it out loud — policy.yaml cannot grant a verb that did not exist when it was written, so the refusal names the file and the line to add rather than failing silently.

A sync that comes back with no VMs at all is not applied to a connection that already has some. A zero-VM pass is how a deleted VM stops being listed — the sync removes every row it did not just write — and it is also exactly what an agent hands back when it could not read the host: on Hyper-V, an unloaded PowerShell module and a service account that cannot enumerate VMs both produce the same empty list. The agent therefore states which one it is, and an agent too old to say so never empties a cache. The rows stay, and the connection carries the reason on the Connections tab (hover the red Error) instead of the page going quietly blank. The cost is the mirror image: a host you have genuinely emptied keeps showing its last VMs until you re-pull the agent — and, for Hyper-V or ESXi, the chrweav/hypervisor-runner image with it.

One inventory_sync is also queued automatically whenever a power job finishes, against the connection it acted on, so the page reflects a Start or a Stop without anyone pressing Sync Now. It is attributed to whoever pressed the button and appears on the Jobs page as Inventory sync: <connection> (page 1) — after power_off. Two things follow from where it sits: a power op that failed queues one too (an agent that lost the response to a call it did make is indistinguishable from one that never made it, and the VM may well have moved), and a burst of power ops queues one sync rather than one each — the last op to finish queues it, and it sees everything the burst moved. A graceful shutdown or reboot is the case where one press is not enough: the sync runs seconds later, while the guest is still on its way down, so it honestly records a VM that is still running.

Syncs are hidden on the Jobs page

A connection polled every 30 minutes writes 48 job rows a day, more if its inventory pages, and every one of them completes green with nothing for anyone to do. So the Jobs page hides completed inventory syncs by default — tick Show hypervisor check-ins to see them. It is a separate box from Show routine sweeps because these are a cadence you set per connection, and a stale inventory is diagnosed by reading the syncs without the dashboard's own housekeeping on top.

Three things are never hidden, which is what makes the default safe to leave on:

Nothing is deleted. Unlike the auto-delete sweep's rows, a hidden check-in stays in the database and comes back the moment you tick the box.

Pages

The larger parts have a page each:

Page Read it when
Powering hypervisor VMs through an agent you want VMs on an agent-run hypervisor started, stopped or suspended, one at a time or as a selection
VMware Workstation Pro through an agent your VMs run on VMware Workstation Pro on a machine the dashboard reaches through an agent

What the agent can and cannot reach

Product Transport Inventory Power
vCenter vSphere Automation REST API yes on/off/reset, plus shutdown via the separate guest/power endpoint (needs VMware Tools) — Suspend is refused, see above
Proxmox VE /api2/json + API token yes on/off/shutdown/reboot — the full set the page offers
XCP-ng XAPI (stdlib XML-RPC) yes on/off/shutdown/reboot/hard reboot — Suspend, Resume, Pause and Unpause are refused, see above
Nutanix Prism Prism v3 REST yes no — a v3 power change is a full spec PUT with a metadata version, not an action
VMware Workstation Pro vmrest, on the same host yes on/off only — vmrest has no reset, reboot or snapshot
bare ESXi SOAP, via the sibling runner yes the same verbs as the runner carries
Hyper-V WinRM, via the sibling runner yes Start, Force Off, Shutdown (Stop-VM, needs Integration Services) and Restart (Restart-VM -Force, a hard restart). Reboot has no cmdlet at all; Pause, Resume and Save have no verb — all refused rather than approximated, see above

esxi is a distinct connection kind from vsphere on the agent's side even though the dashboard has only vsphere: same product, different transport, and the agent is the only side that knows which one a given endpoint is. A job for vsphere is served by an esxi connection; the reverse is refused, because that direction would send SOAP work to a vCenter.

Four of six products over REST, and no new agent dependency for any of them. That is why the agent image still installs only requests, PyYAML and cryptography: those two audit tests are the security argument in executable form. The remaining two get their heavy dependencies in a container that lives for seconds, which keeps the long-lived supervisor inert — the property test_the_agent_imports_no_execution_machinery protects.

The sibling runner

Hyper-V and bare ESXi are the only two the agent cannot talk to itself: WinRM is SOAP with NTLM/Negotiate, and a standalone ESXi host serves only the SOAP API. Rather than put a real auth stack and pyVmomi into an image whose four-dependency restraint is the security argument, those two run in a one-shot container the agent creates, reads one line of JSON from, and deletes.

This needs the Docker socket, and the socket is root on the host. It is not mounted by the default deployment — docker-compose.yml still promises the agent launches nothing, and that stays true. Turning it on is a separate, deliberate act:

Grant Who Where
the agent_hypervisor job type dashboard operator Agents page
sibling: {enabled, image} you policy.yaml
the socket itself you docker-compose.sibling.yml

Withhold any one and nothing runs. Prefer the rootless Docker or Podman user socket; the example overlay uses the rootless path deliberately, so reaching for the root socket has to be a conscious edit. On Docker Desktop that path does not exist and the socket needs its group set — see The sibling runner on Docker Desktop.

What the agent does with it is deliberately narrow. Every field of the container spec is a constant or comes from your policy — the image, the network, and a HostConfig with no bind mounts, no capabilities, a read-only root filesystem and no-new-privileges. None of it is derived from anything the dashboard sends, because there is no field through which to ask; a test asserts that. The credential rides in the environment of the create call rather than argv, so it never appears in ps on the host. Containers are labelled and orphans from a crashed agent are swept at startup.

The agent will not pull the image for you. A pull is a network fetch of executable content, and that is your decision rather than a job's:

docker pull chrweav/hypervisor-runner:latest

Large inventories

A sync returns one page plus an opaque cursor; the dashboard applies it and enqueues the next. The chain is capped at 40 pages (10 000 VMs), which is what stops a misbehaving agent making the dashboard enqueue work forever. Every page of one sync shares a batch_id, so N job rows roll up as one run on /jobs.

The cap that forces this is MAX_RESULT_BYTES (256 KB), and raising it is not an option — it is the only bound on an agent's write path into the database.