Repository navigation
feat: Windows backends — Hyper-V virtual adapters, and the adapter driver's VLAN keyword - #8
Merged
Merged
Conversation
zheylmun
approved these changes
Sep 29, 2026
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>
JustinKovacich
force-pushed
the
feat/windows-backend
branch
from
September 30, 2026 13:30
559daf0 to
b6c9767
Compare
…oduct names Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Two Windows backends behind the
Platformseam, 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 namedvlanctl(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 vianetsh. 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 thevEthernet (vlan11)aliases Windows assigns; an untagged entry is a virtual adapter too and is torn down.WindowsDriverVlan— sets the standardized NDISVlanIDkeyword 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 newPlatform::validate_profilehook, 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 tohost_platform()elsewhere. The state file records the backend'sname();platform_namedresolves it, sodown— andapply's teardown of the previously active profile — run through the backend that applied.Platform::claims_parent_exclusively(defaultfalse, both Windows backendstrue):resolve_devicerefuses to auto-detect a parent whose takeover would disconnect the host, so--deviceis required on Windows.apply's untagged arm is unified with the tagged one: an entry is recorded iff its bring-up contains a commandrecords_created_interfacerecognises. macOS/Linux behaviour unchanged (their untagged bring-ups create nothing); existing tests cover that.windows-sys(TokenElevation),libcnow Unix-only; state at%ProgramData%\vlanctl\state.json;WindowsHyperV::is_read_only_probeowns 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.down(559daf0). Bench, 2026-09-28:New-VMSwitch -AllowManagementOS $falseclears the parent adapter's IPv4 configuration andRemove-VMSwitchnever restores it — after Apply → Revert the Realtek was static with no address (APIPA), and nothing on the host could reach the sensor's subnet.applynow reads the parent's DHCP/static addresses and default gateway through a read-only probe (Platform::parent_snapshot, defaulted toNoneso macOS/Linux record nothing) and stores them in the state file (State.parent);downand a failed apply's rollback wait for the parent to return to the IP stack and put them back withnetsh(Platform::parent_restore_commands). Teardown also removes the virtual adapter's persistent routes explicitly before removing the adapter.Verified
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.vlanctl applyof 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 downreturned 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
backendandparentfields, 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.rsor the apply loop's structure.🤖 Generated with Claude Code