BeyondTrust/POV

← 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.

Powering a selection

Every hypervisor page, and the Workstation page, has a checkbox on each row. Tick some and the toolbar above the table offers Start, Shutdown, Force Off and that product's restart — the same four operations as the row buttons, over the whole selection. It queues one job per VM, all sharing a batch id, and takes you to /jobs?batch_id=…, which counts them by status as they go.

Nothing about the verbs changes. Each VM in a selection goes through the same code as its own row button, so the same policy.yaml grant is required, an op with no verb for that product is greyed out in the toolbar exactly as it is on the row, and a VM you cannot reach fails on its own without stopping the rest — the response names each one that could not be queued, and the reason.

Four things worth knowing before you tick fifty boxes:

Only Start goes without a confirmation, matching the row button. Force Off and the restarts name the count and say plainly that the guests are not being asked.

Not every button on those pages has one of them behind it, and the ones that do not are refused rather than approximated. restart is why the mapping is per product rather than shared: each kind resolves it differently — Proxmox /status/shutdown (graceful), vSphere ?action=reset (a hard reset), XCP-ng VM.clean_reboot (a reboot), Hyper-V nothing at all. shutdown and reboot exist because that made every Shutdown button unmappable, and one Reboot button too:

Button Proxmox vCenter XCP-ng Hyper-V
Power On / Force Off yes yes yes yes
Shutdown (graceful) yes — /status/shutdown yes — guest/power?action=shutdown, needs VMware Tools yes — VM.clean_shutdown yes — Stop-VM, needs Integration Services
Reboot / Restart yes — /status/reboot not offered yes — VM.clean_reboot yes — Restart-VM -Force, a hard restart
Reset / Hard Reboot not offered yes yes not offered
Suspend / Resume / Pause / Unpause / Save not offered refused refused refused

Two of those are graceful in the strict sense — they ask software inside the guest. A vCenter Shutdown is not a power action at all but a call to /api/vcenter/vm/{vm}/guest/ power, and it answers 503 when VMware Tools is not running; the agent turns that into a message saying so, because "answered 503" on a plainly-running VM points nowhere. Hyper-V Shutdown is bare Stop-VM, which Microsoft documents as shutting down "through the guest operating system" — it carries neither -TurnOff (the power cut) nor -Force, which on Stop-VM means "regardless of any unsaved application data" and would quietly make it a different promise. Use Force Off when the guest cannot answer.

Hyper-V is the one product with no graceful reboot at all: Restart-VM is documented as a "hard" restart, "like powering the computer down, then back up again", so it is what the Restart button's power_reset runs and there is nothing left for reboot to be. The agent refuses that combination by name rather than letting the runner answer "unknown verb", which would read as an agent too old for the dashboard.

The mapping is one table, agent_hypervisor_meta.PAGE_OPS, keyed by kind — it was once a copy per router, and three identical copies is how shutdown came to hard -reset a vCenter VM. A refused button is greyed out on the page with the reason in its tooltip, and the endpoint answers 501 naming the substitution that would have been wrong and the buttons that do work. The refusal happens before a job row exists, so nothing appears on /jobs.

Adding a verb is a three-file change, and a partial one is worse than none. The dashboard normalizes an unrecognised verb to inventory_sync, so a verb granted here but missing from an agent would run a discovery scan and report success. The three are this allowlist, the agent's per-kind maps, and the sibling runner.

Version skew across them is safe in the direction it actually happens — the agent is deployed separately and lags. An old agent given a new verb refuses it out loud: it reads the verb raw and never normalizes, so policy.check_verb rejects it first, naming policy.yaml and the line to add. This is why shutdown and reboot are new verbs rather than a redefinition of restart: redefining a verb an old agent already implements, and an old policy.yaml already grants, would silently change what that agent does with a button — the exact failure the whole table exists to prevent.

The corollary is operational: after upgrading the dashboard, every deployed agent must be re-pulled before its Shutdown button works, and until then those agents refuse the verb in Live Output. Proxmox Shutdown is the one button this affects that worked before — it used to ride restart, which really is /status/shutdown on Proxmox alone. It was moved anyway, because leaving it would have kept "restart means shutdown here" alive as a per-kind special case, and that reading is what made Reboot unmappable in the first place.

snapshot creates a snapshot named dash-<job id>. The name is generated, never supplied — which is exactly why it was held back at first: a created thing needs a name, and a name is a free-form string. There is still no field in the protocol through which operator text could reach a hypervisor, and the job id makes the snapshot traceable back to the row that made it, which a typed name would not be.

Deliberately absent: deploy / clone / delete / console — they need sizes, networks and cloud-init, a payload shape indistinguishable from a config file, and a config file is one step from a script. Those stay dashboard-direct.

power_off and power_reset are separate verbs rather than one verb with a force flag, because a boolean on a destructive verb gets defaulted wrong exactly once.

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 three-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

VMware Workstation Pro

Workstation is the one hypervisor here that runs on somebody's desktop — nearly always a Windows desktop, so read Running on Windows alongside this. It was twice written off as unreachable because the dashboard drives it with vmrun against local VMX paths. That was true of vmrun and wrong overall: Workstation Pro ships vmrest, a REST daemon that is plain JSON over HTTP. An agent on that host reaches it with no extra dependency and no container.

On the Workstation host:

vmrest -C     # set the API credentials, once
vmrest        # run the daemon — 127.0.0.1:8697

Neither of those lines survives a reboot, and the first one is a prerequisite rather than a nicety — with no stored credential vmrest refuses to listen and exits ("Not listening to either Unix Socket or TCP port"), so the symptom is a closed port and not a 401. And because vmrest is a foreground console program, it also stops when its window is closed or when you log out, while the agent container restarts at logon perfectly happily — an agent that is online and failing every Workstation job on a refused connection is what that looks like from the dashboard. The agent cannot start it: a Linux container in Docker Desktop's VM can dial the host but cannot launch a process on it. Register the host-side autostart once, with scripts\Enable-VmrestAutostart.ps1 — see Keep it running.

Then add a workstation connection bound to that host's agent. It is agent-bound only: the dashboard has no transport for Workstation, so a connection without an agent is refused rather than created and left broken.

vmrest binds 127.0.0.1, and that address means something different inside a container. The agent denies loopback unconditionally — that deny is what stops a discovery sweep probing the agent's own container or a cloud metadata endpoint, and it is re-added even if an operator deletes it. But lifting the deny is only half the problem: inside the container 127.0.0.1 is the container, so the address has to be one that actually reaches the host as well as one the policy permits. Which of the two you need depends on how the agent is attached to the network:

yaml targets: - fqdn: host.docker.internal ports: [8697]

The name is resolved once when the policy loads and pinned to the address it returned, so the allow-list is in IPs by the time a connection is checked.

yaml connections: - name: my-workstation verbs: [inventory_sync, power_on, power_off] allow_loopback: true

That exempts that connection, on the port its connections.yaml entry names, and nothing else. Discovery still refuses loopback however this is set.

power_on and power_off are not optional if you want the buttons. With only inventory_sync the VMs list correctly on the Workstation page and Start/Stop are refused by the agent — the refusal names this file and the verb to add, but it is the most common "I set it all up and the buttons still do not work" outcome. Grant them when you write the entry, not after.

If you can make vmrest listen on a routable address instead, do that and skip both exceptions — it is the only one of the three that needs nothing special from the container runtime. Note that vmrest documents a -p port option but no bind-address option, so on a stock install this usually is not available to you.

Inventory plus power on and off — and nothing more. vmrest's API is on/off/shutdown/suspend/pause/unpause with no reset, no reboot and no snapshot, so restart, power_reset and snapshot are refused with a message saying so. Mapping restart onto shutdown would quietly do something other than what was asked.

Synced VMs appear on the Workstation page alongside the ones this host scans locally, badged with the agent's name. They are tagged into workstreams the same way Proxmox and Nutanix VMs are, and an untagged VM is admin-only — which is what stops an agent widening what a non-admin can see.

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.