Skip to content

feat: Windows backends — Hyper-V virtual adapters, and the adapter driver's VLAN keyword - #8

Merged
JustinKovacich merged 13 commits into
mainfrom
feat/windows-backend
Sep 30, 2026
Merged

JustinKovacich merged 13 commits into
mainfrom
feat/windows-backend

Conversation

@JustinKovacich

@JustinKovacich JustinKovacich commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

What

Two Windows backends behind the Platform seam, chosen by the profile's shape, plus what the CLI needs to run on Windows.

  • WindowsHyperV — binds the parent adapter to a Hyper-V external switch named vlanctl (management OS off) and creates one management-OS virtual adapter per [[interface]] entry in access mode for its VLAN; addresses, MTU, routes and static neighbors via netsh. The create command is one atomic PowerShell script that undoes its own work on failure, which is what lets a shared switch fit the per-interface create/teardown model. Interface names are the vEthernet (vlan11) aliases Windows assigns; an untagged entry is a virtual adapter too and is torn down.
  • WindowsDriverVlan — sets the standardized NDIS VlanID keyword on the parent adapter and puts the entry's address on the adapter itself: an access port for one VLAN, no switch. Holds one VLAN per adapter and refuses larger profiles through a new Platform::validate_profile hook, naming the Hyper-V backend as the alternative.
  • host_platform_for(profile) / preview_platform_for(profile) pick between them on Windows by entry count (one → driver, more → Hyper-V) and defer to host_platform() elsewhere. The state file records the backend's name(); platform_named resolves it, so down — and apply's teardown of the previously active profile — run through the backend that applied.
  • Platform::claims_parent_exclusively (default false, both Windows backends true): resolve_device refuses to auto-detect a parent whose takeover would disconnect the host, so --device is required on Windows.
  • apply's untagged arm is unified with the tagged one: an entry is recorded iff its bring-up contains a command records_created_interface recognises. macOS/Linux behaviour unchanged (their untagged bring-ups create nothing); existing tests cover that.
  • CLI: elevation check via windows-sys (TokenElevation), libc now Unix-only; state at %ProgramData%\vlanctl\state.json; WindowsHyperV::is_read_only_probe owns the dry-run allowlist for PowerShell probes statement by statement. Every value entering a script goes through a single-quoted literal escape (ps_literal); scripts carry no double quotes.
  • The parent's own address comes back on down (559daf0). Bench, 2026-09-28: New-VMSwitch -AllowManagementOS $false clears the parent adapter's IPv4 configuration and Remove-VMSwitch never restores it — after Apply → Revert the Realtek was static with no address (APIPA), and nothing on the host could reach the sensor's subnet. apply now reads the parent's DHCP/static addresses and default gateway through a read-only probe (Platform::parent_snapshot, defaulted to None so macOS/Linux record nothing) and stores them in the state file (State.parent); down and a failed apply's rollback wait for the parent to return to the IP stack and put them back with netsh (Platform::parent_restore_commands). Teardown also removes the virtual adapter's persistent routes explicitly before removing the adapter.

Verified

  • 154 tests, clippy --all-targets -D warnings, fmt --check, --no-default-features, rustdoc with warnings denied. Rendering is pinned through explicit &WindowsHyperV / &WindowsDriverVlan, so it holds on every host.
  • On a Windows 11 laptop with a real Iris sensor on a dock's Realtek USB GbE (2026-09-27): vlanctl apply of a one-entry VLAN 11 profile through the driver backend set the keyword, plumbed the address and recorded the backend; the sensor answered on VLAN 11, service discovery arrived, and a plain socket received the full point-cloud stream (40k datagrams / 5 s). vlanctl down returned the adapter to VLAN 0 / DHCP and cleared the state. The Hyper-V topology was exercised by hand on the same bench: unicast datapath delivered to a socket on the VLAN 11 vNIC.

Caveats a reviewer should know

  • Under the Hyper-V switch, this Realtek driver does not pass IP multicast unless its hardware filter already holds the group: service discovery worked only after the groups had been joined on the bare adapter earlier in the session. Unicast is unaffected. Documented in the README as a reason the driver backend is the default for single-VLAN profiles.
  • A peer that cached the address's MAC (a sensor's datapath does) keeps sending to it after a switch between the two backends until its own stream restarts. Neither backend can tell it; the README says so. A follow-up could give the primary Hyper-V vNIC the parent's MAC to keep it stable.
  • The state file gains backend and parent fields, both #[serde(default)]; files written by 0.1.0 still load. A pre-existing parent that already has no address (as the bench adapter does now, having been through the bug) is recorded as "static, no address" and restored as nothing — set the address once by hand before the next apply.

Independent of #7 (host verification / permanence); no overlap in plan.rs or the apply loop's structure.

🤖 Generated with Claude Code

@JustinKovacich
JustinKovacich requested a review from a team as a code owner September 27, 2026 19:06
JustinKovacich and others added 12 commits September 30, 2026 09:21
Windows has no general 802.1Q sub-interface, so `plan::Windows` binds the
parent adapter to a Hyper-V external switch named `vlanctl` (management OS
off) and creates one management-OS virtual adapter per profile entry, in
access mode for a tagged entry and untagged mode otherwise. Addresses, MTU,
routes and static neighbor entries go through `netsh`, in the positional
form proven on the bench; `New-NetIPAddress` was not used because it fails
on a virtual adapter whose parent has no link and does not reliably reach
the persistent store.

The switch is shared by every entry, which does not fit a per-interface
create/teardown model until the create command is made atomic: one
PowerShell script creates the switch if missing, adds the adapter, sets its
VLAN, waits for the alias, and undoes its own work if any step fails.
Teardown removes the adapter and the switch when it was the last. That is
what lets rollback and `down` stay as they are.

Interface names are the aliases Windows assigns (`vEthernet (vlan11)`), for
untagged entries too: the parent has no stack of its own once bound. An
untagged adapter is therefore created and must be torn down, so `apply`'s
untagged arm is unified with the tagged one — an entry is recorded iff its
bring-up contains a command `records_created_interface` recognises. On
macOS and Linux nothing in an untagged bring-up creates anything, so their
behaviour is unchanged; the collision guard now covers every name the
platform returns other than the parent.

Binding the parent takes it away from the host, so a wrong auto-detected
parent would disconnect the machine. `Platform::claims_parent_exclusively`
(default false, Windows true) makes `resolve_device` refuse to guess and
ask for the device by name.

Every value entering a PowerShell script is a single-quoted literal built
by `ps_literal`; scripts carry no double quotes, so the one argv element
that reaches `powershell.exe -Command` is quoted trivially.

CLI: the elevation check reads the process token's `TokenElevation` through
`windows-sys` (libc is now Unix-only), the state file lives under
`%ProgramData%\vlanctl`, and `Windows::is_read_only_probe` owns the dry-run
allowlist for PowerShell probes statement by statement, since argv alone
cannot tell a probe from a mutation there. The address probe ends in
`exit 0`: `-Command` otherwise exits 1 for a missing alias even with the
error suppressed, which would refuse every untagged apply.

Verified on a Windows 11 host: `status`, `show` and `apply --dry-run` run
the real probes and render the plan; `down` refuses from an unelevated
shell before touching anything.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
What the Hyper-V model means for an operator: Hyper-V must be enabled, an
elevated shell is required, `--device` is mandatory because binding the
parent takes it off the network, interface names are the `vEthernet (...)`
aliases, everything survives a reboot except a switch whose parent was
absent at boot, and no script file or execution-policy change is involved.
CONTRIBUTING gains the two rules that keep a PowerShell command line inside
the argv boundary; SECURITY names the literal escape and the Windows state
path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…chosen by profile shape

`WindowsDriverVlan` sets the standardized NDIS `VlanID` keyword on the
parent adapter and puts the entry's address on the adapter itself, so the
physical adapter becomes an access port for one VLAN with no virtual
switch involved. Measured on 2026-09-27 against a real Iris sensor on a
dock's Realtek USB adapter: ARP, ping, the sensor's replies tagged, the
full point-cloud stream and service discovery all reach an ordinary
socket. The keyword holds one id, so the backend refuses a profile with
more than one entry and names the Hyper-V backend as the way to hold
several VLANs on one adapter at once.

The Hyper-V backend is renamed `WindowsHyperV` now that it is one of two.
`host_platform_for(profile)` picks between them on Windows by entry
count; `preview_platform_for` is its preview counterpart, and the CLI
uses both so a preview renders what the apply would run. Off Windows both
defer to `host_platform()`.

Two backends on one host means `down` must run through the one that
applied, not the one the host would pick for a fresh profile: the state
file now records the backend's `name()` (`backend`, defaulting to absent
so older files still load), `platform_named` resolves it, and the CLI's
`down` and `status` go through `platform_for_state`. A new
`Platform::validate_profile` hook lets a backend refuse a profile before
`apply` touches anything — before the active profile's teardown, so a
refusal leaves the host as it was; the default is `Ok`.

The parent adapter is the interface for this backend, and it is recorded
although nothing new appears in the adapter list, because `down` has to
reset the keyword and return the address to DHCP. Only the apply
direction of the keyword records; the teardown sets it too, to 0, and
must not re-record what it is removing. The pinned rendering test caught
a nested quote in the restart-wait's throw message before it shipped.

README and CONTRIBUTING describe both backends, the choice between them,
the adapter restart the keyword causes, and the one thing neither backend
can do: tell a peer that cached the address's MAC that it moved.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`apply` tore down the active profile through the backend applying the
new one. With two Windows backends that is the wrong one whenever the
profile shape changes: a switch-backed profile giving way to a
driver-keyword profile would have had its virtual adapters handed to the
driver backend's teardown, which sets a VLAN keyword on them and leaves
the switch bound. The state file names the backend that applied, so use
it, falling back to the current backend for a file written before the
field existed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…Serialize on Profile

A program that runs vlanctl in a process whose output it cannot read —
EnVision launching it elevated on Windows — needs another way to learn
what happened. `--report <file>` writes the outcome as JSON, `{command,
ok, message, created}`, on success and failure alike; a missing file
afterwards means vlanctl never got as far as running the command. A
failure to write the report is printed but never masks the command's own
result.

`Profile`, `Interface` and `Route` derive `Serialize` and `Profile::to_toml`
renders the TOML `Profile::load` reads, so a profile built in memory can
be written to a `--profiles-dir` and applied by the binary.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The driver backend leaves the adapter in place across applies, and with
it every persistent route and static neighbor the previous apply added.
`netsh interface ipv4 add route` refuses a prefix that is already there,
so the second apply after a `down` rolled back with an empty reason on
the bench (2026-09-27). The Hyper-V backend never saw this because
deleting a vNIC takes its routes with it.

Routes and neighbors now go on through `Remove-NetRoute` /
`New-NetRoute` and `Remove-NetNeighbor` / `New-NetNeighbor`, each a
remove-then-add so an apply replaces whatever is there, and teardown
first strips every administratively added route and permanent neighbor
from the adapter before returning it to DHCP.

`SystemRunner` reports stdout when stderr is empty: netsh explains a
refusal on stdout, and "failed: " told the operator nothing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ne on teardown

The driver backend's teardown removes the permanent neighbor entries an
apply added, but Windows keeps its own multicast group and subnet
broadcast entries in the Permanent state too (measured 2026-09-27), and
the first version would have tried to remove them. A static neighbor
an apply adds is never a multicast or broadcast MAC, so the filter is
on the link-layer address.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…dapter

New-VMSwitch -NetAdapterName -AllowManagementOS \$false can accept a
request it cannot fulfil: no terminating error, just an Internal switch
with no adapter bound, when the requested adapter cannot be claimed as
an external uplink right now (bench-measured 2026-09-28, after the
adapter's Hyper-V extensibility binding was toggled outside this
backend's own create/teardown lifecycle). Every vNIC added to that
switch then reports created successfully and shows Disconnected
forever, and the whole apply reports ok: true. A caller has no way to
tell a working apply from this one without inspecting SwitchType by
hand, which is not something `--report`'s JSON exposes.

The create script now checks SwitchType immediately after the switch
exists (created this run or found already there) and throws if it is
not External, before Add-VMNetworkAdapter ever runs — inside the same
try the adapter-wait timeout uses, so both failure shapes roll back
through the same catch and both surface through vlanctl the same way
a real New-VMSwitch error would have.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…emoved

Bench, 2026-09-28: Apply → Revert on a Realtek USB adapter left the parent
back on its own stack, still marked static, holding no address at all.
`New-VMSwitch -AllowManagementOS $false` clears the parent's IPv4
configuration and `Remove-VMSwitch` never restores it, so the host sat on
APIPA and nothing could bind to the sensor's subnet again: EnVision's
discovery stayed on the deleted vNIC, the directed FindService had no
socket to go out of, and pktmon on the bare adapter showed only gPTP.

`apply` now reads the parent's IPv4 configuration (DHCP or the manual
addresses plus default gateway) through a new read-only probe before the
first bring-up command and records it in the state file; `down` — and a
failed apply's rollback — waits for the parent to return to the IP stack
and puts it back with the same positional `netsh` form bring-up uses. The
hook is a pair of defaulted `Platform` methods, so macOS and Linux, whose
sub-interfaces sit beside the parent's addressing, record and restore
nothing.

Teardown also removes the virtual adapter's persistent routes explicitly
before removing the adapter; the profile's `/32` routes were seen to
linger in the persistent store after a full revert.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…own survivor

`down` now confirms a teardown by listing the host afterward and failing
with `TeardownIncomplete` for any recorded interface still present. The
driver VLAN backend records the parent adapter itself as its interface,
and teardown resets the adapter's keyword and address rather than removing
it, so every clean `down` on that backend reported the parent as a
survivor and left the state file behind.

`Platform::teardown_removes_interface` (default `true`, driver VLAN
`false`) lets `down` skip the absence check for a backend whose teardown
leaves the interface in place by design.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`apply` and `down` now list the host after their commands to confirm the
interfaces arrived or went away. The Windows tests stubbed one fixed
adapter list for the whole run, so a successful apply looked like one
whose adapters never appeared, and a successful `down` like one whose
adapters survived.

`windows_runner_changing` answers the list probe with the host before the
commands on the first call and after them from then on, the same shape
`runner_with_device` gives the macOS tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`Instant` is used only by the Linux/macOS probe module, so a Windows
build warned on the top-level import and failed under `-Dwarnings`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oduct names

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@JustinKovacich
JustinKovacich merged commit 9e9157b into main Sep 30, 2026
16 of 17 checks passed
@JustinKovacich
JustinKovacich deleted the feat/windows-backend branch September 30, 2026 15:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants