From b5893b12f4c5906bbdacb08c934f01a2a7f18072 Mon Sep 17 00:00:00 2001 From: Tyler South Date: Tue, 29 Sep 2026 23:13:39 -0400 Subject: [PATCH] Own experimental wired bridge setup and recovery in the launcher --- .github/workflows/ci.yml | 4 + app/go.mod | 4 + app/lan_bridge.go | 97 +++++ app/lan_bridge_test.go | 64 +++ app/lan_bridge_windows.go | 390 ++++++++++++++++++ app/lan_bridge_windows_test.go | 369 +++++++++++++++++ app/main.go | 86 ++++ app/qemu.go | 4 +- app/qemu_nonwindows_test.go | 4 + docs/LAN-BRIDGE.md | 78 +++- docs/MAC-PARITY.md | 2 +- scripts/network/NpcapFramePump.cs | 21 +- scripts/network/OwnedBridgeInput.cs | 39 ++ scripts/network/go.mod | 3 + .../network/launcher-bridge-transaction.psm1 | 64 +++ scripts/network/launcher-bridge.ps1 | 174 ++++++++ scripts/network/payload.go | 8 + .../network/test-launcher-bridge-input.ps1 | 36 ++ scripts/network/test-launcher-bridge.ps1 | 41 ++ 19 files changed, 1480 insertions(+), 8 deletions(-) create mode 100644 app/lan_bridge.go create mode 100644 app/lan_bridge_test.go create mode 100644 app/lan_bridge_windows.go create mode 100644 app/lan_bridge_windows_test.go create mode 100644 scripts/network/OwnedBridgeInput.cs create mode 100644 scripts/network/go.mod create mode 100644 scripts/network/launcher-bridge-transaction.psm1 create mode 100644 scripts/network/launcher-bridge.ps1 create mode 100644 scripts/network/payload.go create mode 100644 scripts/network/test-launcher-bridge-input.ps1 create mode 100644 scripts/network/test-launcher-bridge.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5efe488a..dd8c18c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,6 +86,8 @@ jobs: scripts/network/test-bridge-native.ps1 scripts/network/test-npcap-frames.ps1 scripts/network/test-npcap-segmentation.ps1 + scripts/network/test-launcher-bridge.ps1 + scripts/network/test-launcher-bridge-input.ps1 - name: Test bridge lab safety in Windows PowerShell shell: powershell @@ -96,6 +98,8 @@ jobs: scripts/network/test-bridge-native.ps1 scripts/network/test-npcap-frames.ps1 scripts/network/test-npcap-segmentation.ps1 + scripts/network/test-launcher-bridge.ps1 + scripts/network/test-launcher-bridge-input.ps1 guest-contract: name: Guest contract diff --git a/app/go.mod b/app/go.mod index 3930ee1f..dd72ebe7 100644 --- a/app/go.mod +++ b/app/go.mod @@ -3,3 +3,7 @@ module github.com/omacom/try-omarchy-windows/app go 1.27 require github.com/klauspost/compress v1.19.2 + +require github.com/omacom/try-omarchy-windows/networkpayload v0.0.0 + +replace github.com/omacom/try-omarchy-windows/networkpayload => ../scripts/network diff --git a/app/lan_bridge.go b/app/lan_bridge.go new file mode 100644 index 00000000..8ee0b890 --- /dev/null +++ b/app/lan_bridge.go @@ -0,0 +1,97 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net" + "os" + "regexp" + "strings" +) + +// bridgePlan is an explicit, installation-local lab opt-in. It is not inferred +// from an alias, current default route, or an installed VPN's TAP. +type bridgePlan struct { + Version int `json:"version"` + WiredGuid string `json:"wiredGuid"` + WiredPnp string `json:"wiredPnp"` + TapGuid string `json:"tapGuid"` + TapPnp string `json:"tapPnp"` + LANMac string `json:"lanMac"` + PrivateMac string `json:"privateMac"` + DriverDirectory string `json:"driverDirectory"` + ProbeName string `json:"probeName"` + ProbeAddress string `json:"probeAddress"` + ProbePort int `json:"probePort"` + DisposableLab bool `json:"disposableLab"` + LocalConsole bool `json:"localConsole"` + DedicatedTap bool `json:"dedicatedTap"` + GuestNetworkPrepared bool `json:"guestNetworkPrepared"` +} + +var bridgeGUID = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) + +func (p bridgePlan) validate() error { + if p.Version != 1 || !p.DisposableLab || !p.LocalConsole || !p.DedicatedTap || !p.GuestNetworkPrepared { + return fmt.Errorf("bridge requires an explicit disposable wired lab, local console, dedicated TAP and prepared guest routes") + } + if !bridgeGUID.MatchString(p.WiredGuid) || !bridgeGUID.MatchString(p.TapGuid) || strings.EqualFold(p.WiredGuid, p.TapGuid) { + return fmt.Errorf("select distinct exact wired and TAP GUIDs") + } + for _, v := range []string{p.WiredPnp, p.TapPnp, p.DriverDirectory, p.ProbeName} { + if v == "" || len(v) > 1024 || strings.ContainsAny(v, "\x00\r\n") { + return fmt.Errorf("bridge identity or probe is invalid") + } + } + for _, v := range []string{p.LANMac, p.PrivateMac} { + mac, e := net.ParseMAC(v) + if e != nil || len(mac) != 6 || len(v) != 17 || !strings.Contains(v, ":") || mac[0]&3 != 2 { + return fmt.Errorf("bridge MACs must be locally administered unicast Ethernet addresses") + } + } + if strings.EqualFold(p.LANMac, p.PrivateMac) { + return fmt.Errorf("LAN and private MACs must differ") + } + ip := net.ParseIP(p.ProbeAddress) + if ip == nil || ip.To4() == nil || !ip.IsGlobalUnicast() || ip.IsLoopback() || p.ProbePort < 1 || p.ProbePort > 65535 { + return fmt.Errorf("bridge requires a separate IPv4 wired TCP probe") + } + return nil +} +func loadBridgePlan(path string) (*bridgePlan, error) { + f, e := os.Open(path) + if e != nil { + return nil, e + } + defer f.Close() + data, e := io.ReadAll(io.LimitReader(f, 65537)) + if e != nil { + return nil, e + } + if len(data) > 65536 { + return nil, fmt.Errorf("bridge plan is too large") + } + d := json.NewDecoder(bytes.NewReader(bytes.TrimPrefix(data, []byte{239, 187, 191}))) + d.DisallowUnknownFields() + var p bridgePlan + if e = d.Decode(&p); e != nil { + return nil, e + } + if e = d.Decode(&struct{}{}); e != io.EOF { + return nil, fmt.Errorf("bridge plan has trailing data") + } + if e = p.validate(); e != nil { + return nil, e + } + return &p, nil +} +func bridgeNetworkArgs(p *bridgePlan, name string, forwards []portForward) []string { + if p == nil { + return []string{"-device", "virtio-net-pci,netdev=n0", "-netdev", netdevArg(forwards)} + } + // Place LAN first, but keep the private service network and every existing + // forward. The prepared guest disables private default routes and DNS. + return []string{"-device", "virtio-net-pci,netdev=lan0,mac=" + strings.ToLower(p.LANMac), "-netdev", "tap,id=lan0,ifname=" + qemuOptionValue(name), "-device", "virtio-net-pci,netdev=n0,mac=" + strings.ToLower(p.PrivateMac), "-netdev", netdevArg(forwards)} +} diff --git a/app/lan_bridge_test.go b/app/lan_bridge_test.go new file mode 100644 index 00000000..38dfb4e9 --- /dev/null +++ b/app/lan_bridge_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func validBridgePlan() bridgePlan { + return bridgePlan{Version: 1, WiredGuid: "fbcf0905-41e4-498a-b086-7f7771f57184", WiredPnp: `PCI\OWNED`, TapGuid: "14349899-ada4-4135-b6ae-6acd66845ee2", TapPnp: `ROOT\NET\0000`, LANMac: "52:54:00:16:66:01", PrivateMac: "52:54:00:16:66:02", DriverDirectory: `C:\BridgeLab\driver`, ProbeName: "bridge-peer.test", ProbeAddress: "192.0.2.1", ProbePort: 8080, DisposableLab: true, LocalConsole: true, DedicatedTap: true, GuestNetworkPrepared: true} +} +func TestBridgePlanRejectsUnsafeSelection(t *testing.T) { + cases := map[string]func(*bridgePlan){"version": func(p *bridgePlan) { p.Version = 2 }, "remote": func(p *bridgePlan) { p.LocalConsole = false }, "physical": func(p *bridgePlan) { p.DisposableLab = false }, "borrowed": func(p *bridgePlan) { p.DedicatedTap = false }, "unprepared": func(p *bridgePlan) { p.GuestNetworkPrepared = false }, "same-adapter": func(p *bridgePlan) { p.TapGuid = p.WiredGuid }, "alias": func(p *bridgePlan) { p.WiredGuid = "Ethernet" }, "missing-pnp": func(p *bridgePlan) { p.TapPnp = "" }, "multicast": func(p *bridgePlan) { p.LANMac = "53:54:00:16:66:01" }, "same-mac": func(p *bridgePlan) { p.PrivateMac = p.LANMac }, "global-mac": func(p *bridgePlan) { p.LANMac = "00:54:00:16:66:01" }, "loopback-probe": func(p *bridgePlan) { p.ProbeAddress = "127.0.0.1" }, "ipv6-probe": func(p *bridgePlan) { p.ProbeAddress = "::1" }, "port": func(p *bridgePlan) { p.ProbePort = 0 }} + if e := validBridgePlan().validate(); e != nil { + t.Fatal(e) + } + for name, change := range cases { + t.Run(name, func(t *testing.T) { + p := validBridgePlan() + change(&p) + if p.validate() == nil { + t.Fatal("accepted unsafe plan") + } + }) + } +} +func TestBridgePlanFileValidation(t *testing.T) { + p := validBridgePlan() + data, _ := json.Marshal(p) + path := filepath.Join(t.TempDir(), "plan.json") + for _, bad := range []string{string(data) + " {}", strings.Replace(string(data), `"version":1`, `"unknown":1`, 1), strings.Repeat("x", 65537)} { + os.WriteFile(path, []byte(bad), 0600) + if _, e := loadBridgePlan(path); e == nil { + t.Fatal("accepted invalid file") + } + } + os.WriteFile(path, append([]byte{239, 187, 191}, data...), 0600) + got, e := loadBridgePlan(path) + if e != nil || !reflect.DeepEqual(*got, p) { + t.Fatalf("BOM plan: %v %v", got, e) + } +} +func TestBridgeKeepsPrivateServicesAndForwards(t *testing.T) { + var forwards forwardList + if e := forwards.Set("tcp:18092:8082"); e != nil { + t.Fatal(e) + } + nat := bridgeNetworkArgs(nil, "", forwards) + want := []string{"-device", "virtio-net-pci,netdev=n0", "-netdev", netdevArg(forwards)} + if !reflect.DeepEqual(nat, want) { + t.Fatal(nat) + } + p := validBridgePlan() + args := bridgeNetworkArgs(&p, "Owned, TAP", forwards) + if args[1] != "virtio-net-pci,netdev=lan0,mac="+p.LANMac || args[3] != "tap,id=lan0,ifname=Owned,, TAP" || args[5] != "virtio-net-pci,netdev=n0,mac="+p.PrivateMac || args[7] != nat[3] { + t.Fatal(args) + } + if !strings.Contains(args[7], "hostfwd=tcp:127.0.0.1:18092-:8082") { + t.Fatal("forward escaped private NAT", args) + } +} diff --git a/app/lan_bridge_windows.go b/app/lan_bridge_windows.go new file mode 100644 index 00000000..824d2c8f --- /dev/null +++ b/app/lan_bridge_windows.go @@ -0,0 +1,390 @@ +//go:build windows + +package main + +import ( + "bufio" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net" + "os" + "os/exec" + "strconv" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + "unsafe" + + payload "github.com/omacom/try-omarchy-windows/networkpayload" +) + +// All executable assets come from this binary. No elevated script or module is +// read from the plan, data directory, current directory, PATH or user temp. +const bridgeBootstrap = ` +$ErrorActionPreference='Stop';Set-StrictMode -Version Latest +$root=Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) 'TryOmarchyLauncherBridge' +$acl=[Security.AccessControl.DirectorySecurity]::new() +$acl.SetAccessRuleProtection($true,$false) +$acl.SetOwner([Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')) +foreach($sid in 'S-1-5-18','S-1-5-32-544'){$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new($sid),'FullControl','ContainerInherit,ObjectInherit','None','Allow'))} +function Assert-Trusted($path) { + $item=Get-Item -LiteralPath $path + if($item.Attributes -band [IO.FileAttributes]::ReparsePoint){throw 'Bridge paths must not be reparse points.'} + $a=Get-Acl -LiteralPath $path + if($a.GetOwner([Security.Principal.SecurityIdentifier]).Value -notin @('S-1-5-18','S-1-5-32-544')){throw 'Untrusted bridge path owner.'} + foreach($r in $a.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])){if($r.IdentityReference.Value -notin @('S-1-5-18','S-1-5-32-544')){throw 'Untrusted bridge path permissions.'}} +} +if(-not (Test-Path -LiteralPath $root)){[IO.Directory]::CreateDirectory($root,$acl)|Out-Null} +Assert-Trusted $root +foreach($name in 'operation.json','operation.lock'){ $p=Join-Path $root $name;if(Test-Path -LiteralPath $p){Assert-Trusted $p} } +$operation=Join-Path $root ([guid]::NewGuid().ToString()) +[IO.Directory]::CreateDirectory($operation,$acl)|Out-Null +try { + $assets=[Console]::ReadLine() | ConvertFrom-Json + foreach($entry in $assets.PSObject.Properties){ + if($entry.Name -notmatch '^[a-zA-Z0-9.-]+$'){throw 'Invalid embedded asset name.'} + $file=Join-Path $operation $entry.Name + $stream=[IO.File]::Open($file,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + try{$data=[Convert]::FromBase64String($entry.Value);$stream.Write($data,0,$data.Length)}finally{$stream.Dispose()} + } + $env:TEMP=$operation;$env:TMP=$operation + $request=[Console]::ReadLine() | ConvertFrom-Json + & (Join-Path $operation 'launcher-bridge.ps1') -Request $request -Directory $root +} catch { + [Console]::WriteLine((@{state='error';error=$_.Exception.Message}|ConvertTo-Json -Compress));exit 1 +} finally {Remove-Item -LiteralPath $operation -Recurse -Force} +` + +var bridgeElevate = runElevated + +type bridgeBrokerRequest struct { + Port int `json:"port"` + Token string `json:"token"` + ParentPID int `json:"parentPID"` + Action string `json:"action"` + Plan bridgePlan `json:"plan"` +} +type bridgeMessage struct { + State string `json:"state"` + TapName string `json:"tapName"` + Error string `json:"error"` +} +type bridgeSession struct { + conn net.Conn + encoder *json.Encoder + states chan bridgeMessage + failed chan error + done chan struct{} + elevated chan error + once sync.Once + mu sync.Mutex + closeErr error + closing atomic.Bool + qemuEnded atomic.Bool + TapName string +} + +func (s *bridgeSession) send(v any) error { + s.mu.Lock() + defer s.mu.Unlock() + s.conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) + return s.encoder.Encode(v) +} +func (s *bridgeSession) await(state string, timeout time.Duration) (bridgeMessage, error) { + timer := time.NewTimer(timeout) + defer timer.Stop() + for { + select { + case m := <-s.states: + if m.State == state { + return m, nil + } + if m.State == "complete" { + return m, fmt.Errorf("bridge ended before %s", state) + } + case e := <-s.failed: + return bridgeMessage{}, e + case <-s.done: + // Completion can be buffered before the reader closes done. + // Both are then ready, so do not lose a verified final state. + select { + case m := <-s.states: + if m.State == state { + return m, nil + } + default: + } + select { + case e := <-s.failed: + return bridgeMessage{}, e + default: + } + return bridgeMessage{}, fmt.Errorf("bridge broker disconnected before %s", state) + case <-setupCancelWake: + return bridgeMessage{}, errSetupCancelled + case <-timer.C: + return bridgeMessage{}, fmt.Errorf("bridge broker timed out waiting for %s", state) + } + } +} +func (s *bridgeSession) Attach(pid int, executable string) error { + if err := s.send(map[string]any{"action": "attach", "pid": pid, "executable": executable}); err != nil { + return err + } + _, e := s.await("running", 30*time.Second) + return e +} +func (s *bridgeSession) Close() error { + if s == nil { + return nil + } + s.once.Do(func() { + s.closing.Store(true) + _ = s.send(map[string]string{"action": "stop"}) + select { + case <-s.done: + case <-time.After(35 * time.Second): + s.closeErr = fmt.Errorf("bridge cleanup is still pending; use the local recovery console") + } + s.conn.Close() + select { + case e := <-s.elevated: + if e != nil { + s.closeErr = e + } + case <-time.After(5 * time.Second): + if s.closeErr == nil { + s.closeErr = fmt.Errorf("bridge broker has not exited; recovery must finish before relaunch") + } + } + }) + return s.closeErr +} +func startBridgeBroker(p *bridgePlan, action string) (*bridgeSession, error) { + if os.Getenv("SSH_CONNECTION") != "" || os.Getenv("SSH_CLIENT") != "" || strings.HasPrefix(strings.ToUpper(os.Getenv("SESSIONNAME")), "RDP-") { + return nil, fmt.Errorf("bridge requires an independent local recovery console") + } + + if e := p.validate(); e != nil { + return nil, e + } + listener, e := net.Listen("tcp4", "127.0.0.1:0") + if e != nil { + return nil, e + } + defer listener.Close() + token := make([]byte, 32) + if _, e = rand.Read(token); e != nil { + return nil, e + } + r := bridgeBrokerRequest{Port: listener.Addr().(*net.TCPAddr).Port, Token: hex.EncodeToString(token), ParentPID: os.Getpid(), Action: action, Plan: *p} + data, _ := json.Marshal(r) + encoded := base64.RawURLEncoding.EncodeToString(data) + elevated := make(chan error, 1) + go func() { + code, e := bridgeElevate("-bridge-broker " + encoded) + if e == nil && code != 0 { + if code == errorCancelled { + e = fmt.Errorf("bridge permission was cancelled; no VM was started") + } else { + e = fmt.Errorf("bridge broker exited with code %d", code) + } + } + elevated <- e + }() + accepted := make(chan net.Conn, 1) + go func() { + conn, e := listener.Accept() + if e == nil { + accepted <- conn + } else { + accepted <- nil + } + }() + var conn net.Conn + select { + case conn = <-accepted: + if conn == nil { + return nil, fmt.Errorf("bridge connection failed") + } + case e = <-elevated: + listener.Close() + if c := <-accepted; c != nil { + c.Close() + } + if e == nil { + e = fmt.Errorf("bridge broker ended before connecting") + } + return nil, e + case <-time.After(150 * time.Second): + listener.Close() + if c := <-accepted; c != nil { + c.Close() + } + return nil, fmt.Errorf("bridge permission timed out; no VM was started") + } + conn.SetReadDeadline(time.Now().Add(10 * time.Second)) + reader := bufio.NewReader(io.LimitReader(conn, 4<<20)) + line, e := reader.ReadString('\n') + if e != nil || line != r.Token+"\n" { + conn.Close() + return nil, fmt.Errorf("bridge broker authentication failed") + } + conn.SetReadDeadline(time.Time{}) + if _, e = fmt.Fprintln(conn, "OK "+r.Token); e != nil { + conn.Close() + return nil, e + } + s := &bridgeSession{conn: conn, encoder: json.NewEncoder(conn), states: make(chan bridgeMessage, 8), failed: make(chan error, 1), done: make(chan struct{}), elevated: elevated} + go func() { + defer close(s.done) + for { + line, e := reader.ReadString('\n') + if e != nil { + if !s.closing.Load() && !s.qemuEnded.Load() { + s.failed <- fmt.Errorf("bridge broker disconnected: %w", e) + } + return + } + var m bridgeMessage + if len(line) > 65536 || json.Unmarshal([]byte(line), &m) != nil { + s.failed <- fmt.Errorf("invalid bridge broker response") + return + } + if m.State == "error" { + s.failed <- fmt.Errorf("bridge: %s", m.Error) + return + } + select { + case s.states <- m: + default: + s.failed <- fmt.Errorf("unexpected bridge broker messages") + return + } + if m.State == "complete" { + if !s.closing.Load() && !s.qemuEnded.Load() && action != "Recover" { + s.failed <- fmt.Errorf("bridge forwarding ended while its VM was still owned") + } + return + } + } + }() + if action == "Recover" { + _, e = s.await("complete", 60*time.Second) + } else { + var m bridgeMessage + m, e = s.await("forwarding", 60*time.Second) + s.TapName = m.TapName + if e == nil && (m.TapName == "" || len(m.TapName) > 256 || strings.ContainsAny(m.TapName, "\x00\r\n")) { + e = fmt.Errorf("invalid TAP connection name") + } + } + if e != nil { + _ = s.Close() + return nil, e + } + return s, nil +} +func runBridgeBroker(encoded string) error { + if len(encoded) > 16000 { + return fmt.Errorf("bridge request too large") + } + data, e := base64.RawURLEncoding.DecodeString(encoded) + if e != nil { + return e + } + var r bridgeBrokerRequest + if e = json.Unmarshal(data, &r); e != nil { + return e + } + if e = r.Plan.validate(); e != nil { + return e + } + if r.Port < 1 || r.Port > 65535 || len(r.Token) != 64 || r.ParentPID < 1 || (r.Action != "Start" && r.Action != "Recover") { + return fmt.Errorf("invalid bridge broker endpoint") + } + if _, e = hex.DecodeString(r.Token); e != nil { + return e + } + conn, e := net.DialTimeout("tcp4", net.JoinHostPort("127.0.0.1", strconv.Itoa(r.Port)), 5*time.Second) + if e != nil { + return e + } + defer conn.Close() + pid, e := loopbackPeerPID(conn) + if e != nil || int(pid) != r.ParentPID { + return fmt.Errorf("bridge parent identity does not match") + } + fmt.Fprintln(conn, r.Token) + conn.SetReadDeadline(time.Now().Add(10 * time.Second)) + reader := bufio.NewReader(conn) + ack, e := reader.ReadString('\n') + if e != nil || ack != "OK "+r.Token+"\n" { + return fmt.Errorf("bridge parent did not authorize this connection") + } + conn.SetReadDeadline(time.Time{}) + system := bridgeSystemDirectory() + cmd := exec.Command(system+`\WindowsPowerShell\v1.0\powershell.exe`, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", bridgeBootstrap) + cmd.Env = []string{"SystemRoot=" + system[:len(system)-9], "WINDIR=" + system[:len(system)-9], "SystemDrive=" + system[:2], "OS=Windows_NT", "PROCESSOR_ARCHITECTURE=AMD64", "PATH=" + system, "PATHEXT=.COM;.EXE;.BAT;.CMD", "COMSPEC=" + system + `\cmd.exe`, "PSModulePath=" + system + `\WindowsPowerShell\v1.0\Modules`, "SESSIONNAME=" + os.Getenv("SESSIONNAME"), "SSH_CONNECTION=" + os.Getenv("SSH_CONNECTION"), "SSH_CLIENT=" + os.Getenv("SSH_CLIENT")} + configureDiskTool(cmd) + cmd.Stdout = conn + var errors diskToolErrors + cmd.Stderr = &errors + stdin, e := cmd.StdinPipe() + if e != nil { + return e + } + assets := map[string]string{} + entries, e := payload.Files.ReadDir(".") + if e != nil { + return e + } + for _, entry := range entries { + data, e := payload.Files.ReadFile(entry.Name()) + if e != nil { + return e + } + assets[entry.Name()] = base64.StdEncoding.EncodeToString(data) + } + if e = cmd.Start(); e != nil { + return e + } + encoder := json.NewEncoder(stdin) + if e = encoder.Encode(assets); e == nil { + e = encoder.Encode(map[string]any{"Action": r.Action, "Plan": r.Plan}) + } + if e != nil { + stdin.Close() + cmd.Wait() + return e + } + go func() { io.Copy(stdin, io.LimitReader(reader, 65536)); stdin.Close() }() + e = cmd.Wait() + if e != nil { + fmt.Fprintln(conn, `{"state":"error","error":"Elevated bridge helper failed. Inspect the protected recovery journal."}`) + return fmt.Errorf("bridge helper: %w: %s", e, errors.String()) + } + return nil +} + +func bridgeSystemDirectory() string { + var buffer [32768]uint16 + proc := syscall.NewLazyDLL("kernel32.dll").NewProc("GetSystemDirectoryW") + n, _, _ := proc.Call(uintptr(unsafe.Pointer(&buffer[0])), uintptr(len(buffer))) + if n == 0 || n >= uintptr(len(buffer)) { + return `C:\Windows\System32` + } + return syscall.UTF16ToString(buffer[:n]) +} + +func (s *bridgeSession) NoteQemuEnded() { + if s != nil { + s.qemuEnded.Store(true) + } +} diff --git a/app/lan_bridge_windows_test.go b/app/lan_bridge_windows_test.go new file mode 100644 index 00000000..c88de92c --- /dev/null +++ b/app/lan_bridge_windows_test.go @@ -0,0 +1,369 @@ +//go:build windows + +package main + +import ( + "bufio" + "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" + "time" +) + +func TestBridgePermissionCancellationDoesNotStartHelper(t *testing.T) { + old := bridgeElevate + defer func() { bridgeElevate = old }() + bridgeElevate = func(string) (int, error) { return errorCancelled, nil } + p := validBridgePlan() + s, e := startBridgeBroker(&p, "Start") + if s != nil || e == nil || !strings.Contains(e.Error(), "cancelled") { + t.Fatalf("%v %v", s, e) + } +} +func TestBridgeBufferedCompletion(t *testing.T) { + for i := 0; i < 100; i++ { + s := &bridgeSession{states: make(chan bridgeMessage, 1), failed: make(chan error, 1), done: make(chan struct{})} + s.states <- bridgeMessage{State: "complete"} + close(s.done) + if _, err := s.await("complete", time.Second); err != nil { + t.Fatal(err) + } + } +} +func TestBridgeNativeOwnedFixture(t *testing.T) { + exe := os.Getenv("BRIDGE_LAB_LAUNCHER") + planPath := os.Getenv("BRIDGE_LAB_PLAN") + qemu := os.Getenv("QEMU_SYSTEM") + if exe == "" || planPath == "" || qemu == "" { + t.Skip("requires explicitly owned disposable Windows lab fixture") + } + p, e := loadBridgePlan(planPath) + if e != nil { + t.Fatal(e) + } + old := bridgeElevate + defer func() { bridgeElevate = old }() + // The lab test already runs as SYSTEM. Execute the real candidate broker, + // including protected extraction and IPC, without a second UI prompt. + bridgeElevate = func(args string) (int, error) { + cmd := exec.Command(exe, strings.Fields(args)...) + if e := cmd.Run(); e != nil { + return 1, e + } + return 0, nil + } + for cycle := 0; cycle < 2; cycle++ { + t.Run(fmt.Sprint(cycle), func(t *testing.T) { + s, e := startBridgeBroker(p, "Start") + if e != nil { + t.Fatal(e) + } + defer func() { + if e := s.Close(); e != nil { + t.Error(e) + } + }() + if cycle == 0 { + other, err := startBridgeBroker(p, "Start") + if err == nil { + other.Close() + t.Fatal("duplicate broker accepted") + } + t.Log("duplicate setup refused while original owner remained active") + } + serial := filepath.Join(filepath.Dir(planPath), fmt.Sprintf("launcher-serial-%d.log", cycle)) + os.Remove(serial) + root := filepath.Dir(planPath) + args := []string{"-machine", "q35,accel=tcg", "-cpu", "qemu64", "-m", "384", "-nodefaults", "-no-user-config", "-display", "none", "-serial", "file:" + serial, "-kernel", filepath.Join(root, "bridge-fixture-kernel"), "-initrd", filepath.Join(root, "fixture-initrd-v20.gz"), "-append", "console=ttyS0 panic=1 net.ifnames=0 noapic", "-no-reboot"} + var f forwardList + f.Set("tcp:18092:8082") + args = append(args, bridgeNetworkArgs(p, s.TapName, f)...) + cmd := exec.Command(qemu, args...) + log, e := os.Create(filepath.Join(root, fmt.Sprintf("launcher-qemu-%d.log", cycle))) + if e != nil { + t.Fatal(e) + } + defer log.Close() + cmd.Stdout = log + cmd.Stderr = log + if e = cmd.Start(); e != nil { + t.Fatal(e) + } + done := make(chan error, 1) + go func() { e := cmd.Wait(); s.NoteQemuEnded(); done <- e }() + defer func() { + cmd.Process.Kill() + select { + case <-done: + case <-time.After(10 * time.Second): + } + }() + if e = s.Attach(cmd.Process.Pid, qemu); e != nil { + t.Fatal(e) + } + deadline := time.Now().Add(100 * time.Second) + var text string + for time.Now().Before(deadline) { + data, _ := os.ReadFile(serial) + text = string(data) + if strings.Contains(text, "FIXTURE_READY") { + break + } + select { + case e := <-s.failed: + t.Fatal(e) + case e := <-done: + t.Fatalf("QEMU ended: %v", e) + case <-time.After(time.Second): + } + } + for _, marker := range []string{"FIXTURE_READY", "LAN_PEER: BridgeLabPeer", "PRIVATE_SERVICE: BridgePrivateService", "DNS_A_QUERY_PASSED"} { + if !strings.Contains(text, marker) { + t.Fatalf("missing %s in %s", marker, text) + } + } + lease := regexp.MustCompile(`lease of (192\.0\.2\.[0-9]+) obtained`).FindStringSubmatch(text) + if len(lease) != 2 { + t.Fatal("LAN lease missing", text) + } + guestIP := lease[1] + t.Log("guest LAN address:", guestIP) + client := &http.Client{Timeout: 5 * time.Second} + for _, url := range []string{"http://" + guestIP + ":8082/", "http://127.0.0.1:18092/"} { + resp, e := client.Get(url) + if e != nil { + t.Fatal(e) + } + body, e := io.ReadAll(resp.Body) + resp.Body.Close() + if e != nil || strings.TrimSpace(string(body)) != guestIP { + t.Fatalf("private/direct route: %s %v", body, e) + } + } + t.Log("DHCP, DNS, direct host TCP, private services and loopback forwarding passed") + if cycle == 0 { + for _, size := range []int{1, 1048576, 2097152} { + conn, err := net.DialTimeout("tcp4", net.JoinHostPort(guestIP, "8083"), 5*time.Second) + if err != nil { + t.Fatal(err) + } + conn.SetDeadline(time.Now().Add(20 * time.Second)) + data := make([]byte, size) + for i := range data { + data[i] = byte((i*31 + 17) % 251) + } + var packet bytes.Buffer + binary.Write(&packet, binary.BigEndian, uint32(size)) + packet.Write(data) + _, err = io.Copy(conn, &packet) + if err != nil { + conn.Close() + t.Fatal(err) + } + reply := make([]byte, size) + _, err = io.ReadFull(conn, reply) + conn.Close() + if err != nil || sha256.Sum256(data) != sha256.Sum256(reply) { + t.Fatal("bulk TCP payload differs", size, err) + } + t.Log("direct host TCP exact payload passed", size) + } + } + if cycle == 0 { + if e = s.Close(); e != nil { + t.Fatal(e) + } + if e = s.Close(); e != nil { + t.Fatal("repeated cleanup", e) + } + t.Log("explicit stop and repeated cleanup passed") + } else { + cmd.Process.Kill() + if _, e = s.await("complete", 40*time.Second); e != nil { + t.Fatal("QEMU-exit recovery", e) + } + if e = s.Close(); e != nil { + t.Fatal(e) + } + t.Log("QEMU-exit cleanup passed") + } + }) + } + t.Run("wrong-pnp", func(t *testing.T) { + changed := *p + changed.TapPnp += "-replacement" + session, e := startBridgeBroker(&changed, "Start") + if e == nil { + session.Close() + t.Fatal("replacement PNP was accepted") + } + t.Log("replacement identity refused before preparation") + }) + t.Run("parent-exit", func(t *testing.T) { + self, e := os.Executable() + if e != nil { + t.Fatal(e) + } + child := exec.Command(self, "-test.run=^TestBridgeOrphanedBrokerChild$", "-test.v") + child.Env = append(os.Environ(), "BRIDGE_LAB_ORPHAN_CHILD=1") + output, e := child.CombinedOutput() + if e != nil { + t.Fatalf("orphan child: %v %s", e, output) + } + waitBridgeJournal(t, "Complete", 45*time.Second) + s, e := startBridgeBroker(p, "Start") + if e != nil { + t.Fatal("orphan kept handles or lock", e) + } + if e = s.Close(); e != nil { + t.Fatal(e) + } + t.Log("actual parent-process exit recovered TAP and released capture handles") + }) + if os.Getenv("BRIDGE_LAB_ALLOW_LINK_FLAP") == "1" { + t.Run("link-loss", func(t *testing.T) { + s, e := startBridgeBroker(p, "Start") + if e != nil { + t.Fatal(e) + } + // Explicit owned VM-only switch. No production path changes Ethernet. + flap := exec.Command(bridgeSystemDirectory()+`\WindowsPowerShell\v1.0\powershell.exe`, "-NoProfile", "-NonInteractive", "-Command", `$ErrorActionPreference='Stop';$p=Get-Content $env:BRIDGE_LAB_PLAN -Raw|ConvertFrom-Json;$vm=Get-CimInstance Win32_ComputerSystem;if($vm.Manufacturer -notmatch 'QEMU|Bochs'){throw 'Virtual QEMU lab required'};$a=Get-NetAdapter|Where-Object {([guid]$_.InterfaceGuid) -eq [guid]$p.wiredGuid};if($a.MacAddress -ne '52-54-00-16-60-01' -or $a.PnPDeviceID -ne $p.wiredPnp){throw 'Owned virtual Ethernet identity differs'};try{$a|Disable-NetAdapter -Confirm:$false;Start-Sleep 12}finally{Get-NetAdapter|Where-Object {([guid]$_.InterfaceGuid) -eq [guid]$p.wiredGuid}|Enable-NetAdapter -Confirm:$false}`) + if e = flap.Start(); e != nil { + t.Fatal(e) + } + select { + case e = <-s.failed: + t.Log("link loss stopped capture:", e) + case <-time.After(35 * time.Second): + t.Error("link loss did not stop capture") + } + _ = s.Close() + if e = flap.Wait(); e != nil { + t.Fatal(e) + } + waitBridgeJournal(t, "RecoveryRequired", 10*time.Second) + recovered, e := startBridgeBroker(p, "Recover") + if e != nil { + t.Fatal(e) + } + if e = recovered.Close(); e != nil { + t.Fatal(e) + } + waitBridgeJournal(t, "Complete", 5*time.Second) + t.Log("link loss retained recovery; reconnect and explicit recovery passed") + }) + } + +} + +func TestBridgeUnexpectedBrokerCompletion(t *testing.T) { + for _, mode := range []string{"complete", "eof", "qemu-ended"} { + t.Run(mode, func(t *testing.T) { + old := bridgeElevate + defer func() { bridgeElevate = old }() + finish := make(chan struct{}) + bridgeElevate = func(args string) (int, error) { + data, _ := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(args, "-bridge-broker ")) + var request bridgeBrokerRequest + json.Unmarshal(data, &request) + c, e := net.Dial("tcp4", fmt.Sprintf("127.0.0.1:%d", request.Port)) + if e != nil { + return 1, e + } + defer c.Close() + fmt.Fprintln(c, request.Token) + r := bufio.NewReader(c) + r.ReadString('\n') + fmt.Fprintln(c, `{"state":"forwarding","tapName":"Owned TAP"}`) + r.ReadString('\n') + fmt.Fprintln(c, `{"state":"running"}`) + <-finish + if mode != "eof" { + fmt.Fprintln(c, `{"state":"complete"}`) + } + return 0, nil + } + p := validBridgePlan() + s, e := startBridgeBroker(&p, "Start") + if e != nil { + t.Fatal(e) + } + if e = s.Attach(123, `C:\owned\qemu-system-x86_64w.exe`); e != nil { + t.Fatal(e) + } + if mode == "qemu-ended" { + s.NoteQemuEnded() + } + close(finish) + select { + case <-s.done: + case <-time.After(5 * time.Second): + t.Fatal("completion stuck") + } + select { + case e = <-s.failed: + if mode == "qemu-ended" { + t.Fatal("reported an expected QEMU exit", e) + } + default: + if mode != "qemu-ended" { + t.Fatal("unexpected exit did not stop VM ownership") + } + } + if e = s.Close(); e != nil { + t.Fatal(e) + } + }) + } +} + +func waitBridgeJournal(t *testing.T, phase string, timeout time.Duration) { + t.Helper() + path := filepath.Join(os.Getenv("ProgramData"), "TryOmarchyLauncherBridge", "operation.json") + deadline := time.Now().Add(timeout) + var current struct { + Phase string + Error string + } + for time.Now().Before(deadline) { + data, _ := os.ReadFile(path) + json.Unmarshal(data, ¤t) + if current.Phase == phase { + return + } + time.Sleep(200 * time.Millisecond) + } + t.Fatalf("journal phase %s, wanted %s: %s", current.Phase, phase, current.Error) +} +func TestBridgeOrphanedBrokerChild(t *testing.T) { + if os.Getenv("BRIDGE_LAB_ORPHAN_CHILD") != "1" { + t.Skip("owned subprocess only") + } + p, e := loadBridgePlan(os.Getenv("BRIDGE_LAB_PLAN")) + if e != nil { + t.Fatal(e) + } + bridgeElevate = func(args string) (int, error) { + cmd := exec.Command(os.Getenv("BRIDGE_LAB_LAUNCHER"), strings.Fields(args)...) + if e := cmd.Run(); e != nil { + return 1, e + } + return 0, nil + } + if _, e = startBridgeBroker(p, "Start"); e != nil { + t.Fatal(e) + } + os.Exit(0) +} diff --git a/app/main.go b/app/main.go index 46de682b..561042e3 100644 --- a/app/main.go +++ b/app/main.go @@ -29,6 +29,10 @@ import ( const appTitle = "Try Omarchy" type config struct { + bridge *bridgePlan + bridgeTapName string + bridgeFailure <-chan error + bridgeQemu *os.Process desktop desktopPreferences audioDevices audioPreferences dir, hostDir, payloadDir string @@ -122,6 +126,9 @@ func finishSetupCancellation(cfg *config, err error) bool { func main() { cfg := &config{} + bridgePlanPath := flag.String("bridge-lab-plan", "", "opt in to prepared dual-network forwarding in a disposable wired Windows lab") + bridgeRecover := flag.Bool("bridge-lab-recover", false, "recover the owned lab TAP bindings without starting a VM") + bridgeBroker := flag.String("bridge-broker", "", "internal: isolated elevated bridge broker") removeDataOnCancel := false defaultDir := filepath.Join(os.Getenv("LOCALAPPDATA"), defaultDataDirectoryName) flag.StringVar(&cfg.dir, "dir", defaultDir, "Try Omarchy data directory (virtual machine, runtime, and settings)") @@ -179,6 +186,35 @@ func main() { updateWaitPID := flag.Int("update-wait-pid", 0, "internal: process to wait for before replacing the launcher") updateRestartArgs := flag.String("update-restart-args", "", "internal: encoded launcher restart arguments") flag.Parse() + if *bridgePlanPath != "" && !*bridgeRecover && !*startImmediately { + fatal("Use -start with the explicit bridge lab plan.") + } + if *bridgeBroker != "" { + if err := runBridgeBroker(*bridgeBroker); err != nil { + os.Exit(1) + } + return + } + if *bridgePlanPath != "" { + var err error + cfg.bridge, err = loadBridgePlan(*bridgePlanPath) + if err != nil { + fatal("Cannot use bridge lab plan: %v", err) + } + } + if *bridgeRecover { + if cfg.bridge == nil { + fatal("Bridge recovery requires the exact saved -bridge-lab-plan.") + } + broker, err := startBridgeBroker(cfg.bridge, "Recover") + if err != nil { + fatal("Bridge recovery failed: %v", err) + } + if err = broker.Close(); err != nil { + fatal("Bridge recovery failed: %v", err) + } + return + } if *openAbout { runAbout() return @@ -846,11 +882,23 @@ func loadLaunchAudioPreferences(dir string) (audioPreferences, error) { func supervise(cfg *config, cmdline string) bool { var proc *exec.Cmd var qmp *qmpConn + var bridge *bridgeSession + stopBridge := func() { + if bridge != nil { + if err := bridge.Close(); err != nil { + logf("bridge cleanup: %v", err) + } + bridge = nil + } + cfg.bridgeFailure = nil + } + defer stopBridge() // The worst case can consume one attempt each for nested virtualization, // audio, runtime rollback, and GPU fallback before walking 64 GiB down to a // final 1 GiB memory attempt. Keep a small margin without allowing a loop. const maxLaunchAttempts = 12 for attempt := 1; attempt <= maxLaunchAttempts; attempt++ { + stopBridge() if setupCancelled() { return false } @@ -875,6 +923,14 @@ func supervise(cfg *config, cmdline string) bool { // Local forwards changed while running (forward_live.go) carry into a // reboot instead of reverting to the launch list. cfg.forwards = forwardsForBoot(cfg.launchForwards) + if cfg.bridge != nil { + bridge, err = startBridgeBroker(cfg.bridge, "Start") + if err != nil { + fatal("Bridge lab startup failed: %v", err) + } + cfg.bridgeTapName = bridge.TapName + cfg.bridgeFailure = bridge.failed + } proc = exec.Command(cfg.qemu, buildQemuArgs(cfg, cmdline)...) audioSelection := cfg.audio == "sdl" && audioRuntimeSupportsSelection(cfg.qemu) if !audioSelection && (cfg.audioDevices.Output != "" || (!cfg.desktop.MicrophoneDisabled && cfg.audioDevices.Input != "")) { @@ -901,18 +957,34 @@ func supervise(cfg *config, cmdline string) bool { defer ef.Close() } if err := proc.Start(); err != nil { + stopBridge() fatal("QEMU failed to start: %v", err) } qemuPid.Store(uint32(proc.Process.Pid)) exited := make(chan error, 1) + attemptBridge := bridge + if bridge != nil { + cfg.bridgeQemu = proc.Process + } go func() { err := proc.Wait() + attemptBridge.NoteQemuEnded() if err != nil { logf("QEMU process exited with error: %v", err) } exited <- err }() + if bridge != nil { + if err := bridge.Attach(proc.Process.Pid, cfg.qemu); err != nil { + proc.Process.Kill() + <-exited + qemuPid.Store(0) + stopBridge() + fatal("Bridge attach failed: %v", err) + } + } + // Do NOT touch QMP during early guest boot: a monitor connection in // the first seconds reliably wedges QEMU's main loop under WHPX (the // "launch wedge" - near-certain nested, intermittent on hardware). @@ -923,6 +995,12 @@ func supervise(cfg *config, cmdline string) bool { probe: for qmp == nil && time.Now().Before(deadline) { select { + case err := <-cfg.bridgeFailure: + proc.Process.Kill() + <-exited + qemuPid.Store(0) + stopBridge() + fatal("Bridge forwarding stopped: %v", err) case <-setupCancelWake: proc.Process.Kill() <-exited @@ -1049,6 +1127,14 @@ func watch(cfg *config, qmp *qmpConn, exited <-chan error) bool { movedBootPending = false } select { + case err := <-cfg.bridgeFailure: + logf("Bridge forwarding stopped: %v", err) + if cfg.bridgeQemu != nil { + cfg.bridgeQemu.Kill() + } + waitExit(exited, 15*time.Second, cfg) + errorBox(fmt.Sprintf("Bridge forwarding stopped: %v\n\nThe lab VM was stopped. Recover from the independent console before relaunching.", err)) + return false case <-exited: procDown = true case line, ok := <-lines: diff --git a/app/qemu.go b/app/qemu.go index 4f911760..9933015b 100644 --- a/app/qemu.go +++ b/app/qemu.go @@ -89,7 +89,9 @@ func buildQemuArgs(cfg *config, cmdline string) []string { "-initrd", filepath.Join(cfg.guestDir, "initramfs-linux.img"), "-append", cmdline+" tryomarchy.render="+render, "-device", "virtio-keyboard-pci", "-device", "virtio-tablet-pci", - "-device", "virtio-net-pci,netdev=n0", "-netdev", netdevArg(cfg.forwards), + ) + args = append(args, bridgeNetworkArgs(cfg.bridge, cfg.bridgeTapName, cfg.forwards)...) + args = append(args, "-device", "virtio-rng-pci", // The camera bridge needs a bulk channel the host can write without // going through slirp. The launcher listens on loopback (as it does diff --git a/app/qemu_nonwindows_test.go b/app/qemu_nonwindows_test.go index 57f88f89..a54293c8 100644 --- a/app/qemu_nonwindows_test.go +++ b/app/qemu_nonwindows_test.go @@ -16,6 +16,10 @@ const ( ) type config struct { + bridge *bridgePlan + bridgeTapName string + bridgeFailure <-chan error + bridgeQemu *os.Process desktop desktopPreferences audioDevices audioPreferences dir, hostDir, payloadDir string diff --git a/docs/LAN-BRIDGE.md b/docs/LAN-BRIDGE.md index e6989784..776b4105 100644 --- a/docs/LAN-BRIDGE.md +++ b/docs/LAN-BRIDGE.md @@ -25,9 +25,9 @@ administrator action. The bridge helper never elevates, downloads or installs a driver, removes a TAP, or uninstalls a shared VPN driver. Record the new device's GUID and PNP instance before setup. Do not borrow an existing VPN device. -QEMU opens Windows TAP by its connection name. A future launcher path must -resolve the selected GUID and PNP identity to the current name immediately -before launch. Adapter rename or loss is not authority to select another device. +QEMU opens Windows TAP by its connection name. The experimental launcher path +resolves the selected GUID and PNP identity to the current name before launch. +Adapter rename or loss is not authority to select another device. ## Read-only preflight @@ -250,7 +250,7 @@ Secure Boot/HVCI, Windows 10, administrator cancellation and normal guest integration still need validation. `BridgeAccepted` remains false. NAT and existing forwarding remain the launcher defaults. -## Launcher integration still required +## Normal guest and Settings work Keep the LAN NIC separate from a private NAT service NIC. Existing guest integrations use `10.0.2.2`. Guest route configuration must prefer LAN for normal @@ -275,3 +275,73 @@ saved NAT/forwarding configuration and verify host recovery before offering NAT. - Stable guest MACs, private integration channels and NAT/forwarding regression. - Physical wired Ethernet and signed-driver checks with Secure Boot/HVCI enabled on supported Windows versions. A virtual lab does not replace these checks. + +## Experimental launcher ownership + +The launcher can now own the Npcap helper in an explicitly declared disposable +Windows lab. This is a development option, not a supported network preference. +It requires the separately installed, pinned TAP and Npcap dependencies, an +unused dedicated TAP, wired DHCP and an independent local recovery console. +Driver installation remains manual. Npcap binaries are not bundled. + +Save an installation-local JSON plan with `version: 1`, the exact `wiredGuid`, +`wiredPnp`, `tapGuid` and `tapPnp`, distinct locally administered `lanMac` and +`privateMac`, `driverDirectory`, `probeName`, `probeAddress` and `probePort`. +Retain the MACs and plan across boots. Set `disposableLab`, `localConsole`, +`dedicatedTap` and `guestNetworkPrepared` only after satisfying those conditions. +The guest must already configure both NICs by MAC: LAN DHCP provides the default +route and DNS; the private NIC keeps its connected `10.0.2.0/24` route without a +private default route or private DNS. Ordinary released guests have not been +accepted with this routing configuration. + +Start only that candidate installation: + +```powershell +TryOmarchy.exe -start -dir C:\BridgeCandidate -bridge-lab-plan C:\BridgeCandidate\bridge.json +``` + +A separate launcher broker requests Windows permission. It executes the helper +embedded in the candidate binary, extracted under administrator/SYSTEM-only +permissions, rather than loading elevated code from the installation or plan. +The broker does not elevate QEMU. Its authenticated loopback connection checks +the parent process and completes a handshake before preparation. Cancelling +permission leaves the VM stopped and does not prepare the TAP. + +Preparation changes the exact dedicated TAP's IPv4/IPv6 and dependent Microsoft +client, server and NetBIOS bindings. Windows can change these dependencies when +[IP bindings change](https://learn.microsoft.com/en-us/powershell/module/netadapter/set-netadapterbinding?view=windowsserver2025-ps). Recovery verifies the complete saved binding inventory. The wired +adapter's bindings, addressing and offload settings are not changed. A protected, +durable journal records the original TAP bindings before changes. Capture opens +before QEMU starts. QEMU gets the selected TAP's current connection name and a +separate private NAT NIC with the existing forwards. The broker verifies QEMU's +PID, creation time, executable and selected dual-network command line. Another +launcher operation or lab frame pump cannot take the same TAP. + +The helper has no periodic lab-duration restart. Explicit stop, parent connection +loss and QEMU exit release capture handles and recover owned TAP bindings. +Adapter loss, changed identity/bindings or packet failure stops forwarding. +The launcher stops that lab VM rather than silently switching networks. Failed +cleanup or failed host connectivity keeps `RecoveryRequired` and blocks another +start. Recover through the independent console after reconnecting the selected +wired adapter: + +```powershell +TryOmarchy.exe -bridge-lab-plan C:\BridgeCandidate\bridge.json -bridge-lab-recover +``` + +Recovery does not need Npcap running, destroy a Windows bridge, remove a device, +uninstall a shared driver or select another adapter. It tolerates removal of the +owned TAP and refuses replacement identities or unrelated TAP binding edits. + +In the disposable Windows VM, launcher-owned forwarding passes guest DHCP/DNS, +direct host TCP, private host services and loopback forwarding. Direct TCP +payloads of 1 byte, 1 MiB and 2 MiB return unchanged. Duplicate setup is refused; +explicit stop, repeated cleanup, QEMU exit and actual parent-process exit recover +the TAP. Virtual Ethernet loss stops capture and retains recovery until the +selected adapter reconnects. A replacement PNP identity is refused before setup. +These checks use a prepared diskless fixture, not the ordinary released guest. + +NAT, port forwarding and the normal launcher menu remain the release defaults. +Physical Ethernet, Windows 10, hardware offload, Secure Boot/HVCI, IPv6 address +configuration, guest migration and a supported Settings preference remain +acceptance work. This option must not be used on a remotely accessed Wi-Fi host. diff --git a/docs/MAC-PARITY.md b/docs/MAC-PARITY.md index 2299d7e9..091fd9b1 100644 --- a/docs/MAC-PARITY.md +++ b/docs/MAC-PARITY.md @@ -50,7 +50,7 @@ fixes. Equivalent behavior is tracked below only where it makes sense on Windows | Trackpad pinch | [r18 bridge](PINCH-ZOOM.md), virtual touchpad and guest rules for new and existing guests ([#184](https://github.com/omacom/try-omarchy-windows/pull/184)); on by default for guest images that declare the device; synthetic and AMD-laptop physical Chromium pinch/scroll tests pass | Shipped in `v0.4.0`; Firefox and broader host/DPI/fullscreen acceptance | | Windows Hello sudo | Opt-in since `v0.5.0`: launcher WebAuthn bridge, guest broker and a single PAM rule; one Hello prompt per sudo with password fallback ([design](WINDOWS-HELLO.md), [laptop run](evidence/HELLO-SUDO-LAPTOP-2026-09-26.md)) | Other Hello hardware (fingerprint, face) and Windows 10 | | 1Password host authentication | Opt-in since `v0.6.0`: 1Password's system authentication unlock asks for Windows Hello through a polkit agent scoped to the installed 1Password process ([#176](https://github.com/omacom/try-omarchy-windows/issues/176)); canceling falls back to the guest password | 1Password still asks for its account password after it restarts | -| Bridged networking | NAT and explicit port forwarding ship; [signed TAP lab helpers](LAN-BRIDGE.md) test and recover selected wired setup, with controlled guest DHCP/TCP passing | [True LAN bridge #166](https://github.com/omacom/try-omarchy-windows/issues/166), with supported adapter, privilege and firewall handling | +| Bridged networking | NAT and explicit port forwarding ship; [signed TAP/Npcap experiments](LAN-BRIDGE.md) preserve guest Ethernet identity, with controlled DHCP/TCP and experimental launcher ownership tested | [True LAN bridge #166](https://github.com/omacom/try-omarchy-windows/issues/166): normal guest routes, Settings, physical Ethernet and supported security policies remain open | | Host battery | Shipped in `v0.2.0`; the AMD laptop's 99% charging state appeared as BAT0/ADP0 and in UPower | Desktop/no-battery transition remains to be observed on a suitable host | | Guest RAM reclamation | Shipped with r19 in `v0.2.0`; three physical touch/free cycles returned about 797 MiB after the third 768 MiB allocation | Follow up on concrete memory reports | | Keyboard and language | Windows time zone, keyboard layout and display language follow the host | Physical ANSI/ISO/JIS geometry and broader input-method acceptance | diff --git a/scripts/network/NpcapFramePump.cs b/scripts/network/NpcapFramePump.cs index 421dbb68..5a5c7acb 100644 --- a/scripts/network/NpcapFramePump.cs +++ b/scripts/network/NpcapFramePump.cs @@ -118,6 +118,17 @@ public static Result Run(string wired, string tap, string guestMac, string wired { if (seconds < 1 || seconds > 600) throw new ArgumentException("Duration must be 1 to 600 seconds"); + Stopwatch duration = Stopwatch.StartNew(); + using (ManualResetEventSlim link = new ManualResetEventSlim(true)) + return RunOwned(wired, tap, guestMac, wiredMac, guard, delegate { }, delegate { return duration.Elapsed.TotalSeconds < seconds; }, link); + } + + // The launcher owns lifetime; no periodic handle teardown or packet loss at + // the lab duration limit. Readiness follows opening all capture handles. + public static Result RunOwned(string wired, string tap, string guestMac, string wiredMac, Action guard, Action ready, Func keepRunning, ManualResetEventSlim tapReady) + { + if (ready == null || keepRunning == null || tapReady == null) + throw new ArgumentNullException("Lifetime callbacks are required"); if (new Guid(wired) == new Guid(tap)) throw new ArgumentException("Select two distinct adapters"); byte[] mac = ParseMac(guestMac), host = ParseMac(wiredMac); @@ -155,7 +166,6 @@ public static Result Run(string wired, string tap, string guestMac, string wired HostTcpSegmentation tcp = new HostTcpSegmentation(); Exception failure = null; Thread[] threads = new Thread[2]; - Stopwatch timer = Stopwatch.StartNew(); try { h[0] = Capture(tap, "ether src " + text, true); @@ -194,9 +204,15 @@ public static Result Run(string wired, string tap, string guestMac, string wired for (int m = 0; m < 6; m++) if (frame[6 + m] != mac[m]) throw new Exception("Unexpected guest source MAC"); + // A real guest transmit also proves the TAP has opened. + tapReady.Set(); tcp.ObserveGuest(frame); } + // Wired broadcasts can arrive before QEMU opens TAP. + // Keep capture ready without injecting into disconnected media. + if (d == 1 && !tapReady.IsSet) + continue; byte[][] frames = new byte[][] { frame }; if (d == 1) { @@ -235,7 +251,8 @@ public static Result Run(string wired, string tap, string guestMac, string wired threads[d].Start(); } - while (timer.Elapsed.TotalSeconds < seconds && Volatile.Read(ref failure) == null) + ready(); + while (keepRunning() && Volatile.Read(ref failure) == null) { guard(); Thread.Sleep(250); diff --git a/scripts/network/OwnedBridgeInput.cs b/scripts/network/OwnedBridgeInput.cs new file mode 100644 index 00000000..6f4881e8 --- /dev/null +++ b/scripts/network/OwnedBridgeInput.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; + +// Console.In.ReadLineAsync on .NET Framework can synchronously block the +// caller. Keep pipe reads off the PowerShell runspace and frame-pump guard. +public sealed class OwnedBridgeInput +{ + private readonly ConcurrentQueue commands = new ConcurrentQueue(); + private int ended; + private Exception failure; + public Exception Failure { get { return Volatile.Read(ref failure); } } + public bool Ended { get { return Volatile.Read(ref ended) != 0; } } + public OwnedBridgeInput() + { + Thread input = new Thread(delegate () + { + try + { + string line; + while ((line = Console.ReadLine()) != null) + { + if (line.Length > 8192 || commands.Count >= 8) + throw new InvalidOperationException("Bridge command limit exceeded"); + commands.Enqueue(line); + } + } + catch (Exception e) { Interlocked.CompareExchange(ref failure, e, null); } + finally { Interlocked.Exchange(ref ended, 1); } + }); + input.IsBackground = true; + input.Start(); + } + public string Next() + { + string line; + return commands.TryDequeue(out line) ? line : null; + } +} diff --git a/scripts/network/go.mod b/scripts/network/go.mod new file mode 100644 index 00000000..190b6608 --- /dev/null +++ b/scripts/network/go.mod @@ -0,0 +1,3 @@ +module github.com/omacom/try-omarchy-windows/networkpayload + +go 1.27 diff --git a/scripts/network/launcher-bridge-transaction.psm1 b/scripts/network/launcher-bridge-transaction.psm1 new file mode 100644 index 00000000..6ccfc85c --- /dev/null +++ b/scripts/network/launcher-bridge-transaction.psm1 @@ -0,0 +1,64 @@ +Set-StrictMode -Version Latest +$store = Import-Module (Join-Path $PSScriptRoot 'bridge-transaction.psm1') -Force -PassThru +function Write-LauncherBridgeJournal($Path,$Journal) { + & $store { param($p,$j) Write-BridgeJournal $p $j } $Path $Journal +} +function Read-LauncherBridgeJournal($Path) { + $j = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + if ($j.Version -ne 1 -or $j.Kind -ne 'NpcapLauncher' -or $j.Phase -notin @('Preparing','Ready','Running','Restoring','RecoveryRequired','Complete')) { throw 'Unrecognized launcher bridge journal.' } + $j +} +function Restore-LauncherBridge($Backend,$Path) { + $j = Read-LauncherBridgeJournal $Path + & $Backend.ValidateMachine $j + if ($j.Phase -eq 'Complete') { return $j } + try { + # Identity/configuration validation is separate from restoration. A + # foreign PNP device or unrelated binding edit is never undone. + & $Backend.ValidateRecovery $j + $j.Phase = 'Restoring'; Write-LauncherBridgeJournal $Path $j + & $Backend.Restore $j + if (-not (& $Backend.VerifyRestored $j)) { throw 'TAP bindings did not recover.' } + if (-not (& $Backend.Probe $j.Request)) { throw 'Host connectivity has not recovered.' } + $j.Phase = 'Complete'; $j.Error = ''; Write-LauncherBridgeJournal $Path $j + return $j + } catch { + $j.Phase = 'RecoveryRequired'; $j.Error = $_.Exception.Message + Write-LauncherBridgeJournal $Path $j + throw + } +} +function Start-LauncherBridge($Backend,$Path,$Request) { + if (Test-Path -LiteralPath $Path) { + $old = Read-LauncherBridgeJournal $Path + & $Backend.ValidateMachine $old + if ($old.Phase -ne 'Complete') { throw 'Recover the previous launcher bridge before starting another.' } + } + $before = & $Backend.Capture $Request + if (-not (& $Backend.Probe $Request)) { throw 'Wired host connectivity baseline failed.' } + # Recheck after the probe, before intent or any mutation. + & $Backend.ValidateBefore $before $Request + $j = [pscustomobject]@{ Version=1; Kind='NpcapLauncher'; Phase='Preparing'; Before=$before; Request=$Request; Error='' } + Write-LauncherBridgeJournal $Path $j + try { + & $Backend.Prepare $j + if (-not (& $Backend.Probe $Request)) { throw 'Host connectivity failed after TAP preparation.' } + $j.Phase = 'Ready'; Write-LauncherBridgeJournal $Path $j + return $j + } catch { + $failure = $_.Exception.Message + if ($_.Exception -is [TimeoutException]) { + $j.Phase='RecoveryRequired';$j.Error=$failure;Write-LauncherBridgeJournal $Path $j + throw "Binding command completion is uncertain. Inspect locally before recovery: $failure" + } + try { Restore-LauncherBridge $Backend $Path | Out-Null } + catch { throw "Bridge preparation failed: $failure Recovery is pending: $($_.Exception.Message)" } + throw "Bridge preparation failed: $failure TAP bindings recovered." + } +} +function Set-LauncherBridgeRunning($Path) { + $j=Read-LauncherBridgeJournal $Path + if ($j.Phase -ne 'Ready') { throw 'Bridge is not prepared.' } + $j.Phase='Running';Write-LauncherBridgeJournal $Path $j +} +Export-ModuleMember -Function Start-LauncherBridge, Restore-LauncherBridge, Set-LauncherBridgeRunning diff --git a/scripts/network/launcher-bridge.ps1 b/scripts/network/launcher-bridge.ps1 new file mode 100644 index 00000000..0b5b30dc --- /dev/null +++ b/scripts/network/launcher-bridge.ps1 @@ -0,0 +1,174 @@ +# Invoked only from the launcher's embedded, protected payload. +param([Parameter(Mandatory)]$Request,[Parameter(Mandatory)][string]$Directory) +$ErrorActionPreference='Stop' +Set-StrictMode -Version Latest +$native=Import-Module (Join-Path $PSScriptRoot 'bridge-native.psm1') -Force -PassThru +Import-Module (Join-Path $PSScriptRoot 'bridge-preflight.psm1') -Force +Import-Module (Join-Path $PSScriptRoot 'launcher-bridge-transaction.psm1') -Force +$p=$Request.Plan +if ($p.version -ne 1 -or -not $p.disposableLab -or -not $p.localConsole -or -not $p.dedicatedTap -or -not $p.guestNetworkPrepared -or $env:SSH_CONNECTION -or $env:SSH_CLIENT -or $env:SESSIONNAME -like 'RDP-*') { throw 'Explicit disposable wired lab, independent local console, dedicated TAP and prepared guest routes are required.' } +$principal=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()) +if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'The bridge broker requires explicit elevation.' } +$journal=Join-Path $Directory 'operation.json' +$ownedBindings=@('ms_tcpip','ms_tcpip6','ms_msclient','ms_server','ms_netbios','ms_netbt') +$machine=(Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Cryptography').MachineGuid +function Set-OwnedBinding($Binding,[bool]$Enabled) { + try { $Binding | Set-NetAdapterBinding -Enabled $Enabled -Confirm:$false | Out-Null } + catch { throw [TimeoutException]::new("TAP binding command did not complete reliably: $($_.Exception.Message)") } +} +function Get-Exact($Guid,$Pnp) { & $native { param($g,$p) Get-SelectedAdapter $g $p } $Guid $Pnp } +function Get-Bindings($Adapter) { & $native { param($a) Get-SelectedBindings $a } $Adapter } +function Assert-Npcap { + $lock=Get-Content (Join-Path $PSScriptRoot 'npcap-1.89.lock.json') -Raw | ConvertFrom-Json + foreach ($entry in $lock.files.PSObject.Properties) { + $path=Join-Path ([Environment]::SystemDirectory) $entry.Name + if ((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -ne $entry.Value -or (Get-AuthenticodeSignature -LiteralPath $path).Status -ne 'Valid') { throw 'Npcap installed files are not pinned and signed.' } + } + $drivers=@(Get-CimInstance Win32_SystemDriver -Filter "Name='npcap'") + if ($drivers.Count -ne 1 -or $drivers[0].State -ne 'Running' -or [IO.Path]::GetFullPath(([string]$drivers[0].PathName).Trim('"')) -ne (Join-Path ([Environment]::SystemDirectory) 'drivers\npcap.sys')) { throw 'Pinned Npcap kernel service is not running at its expected path.' } + $options=Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\npcap\Parameters' + if ($options.AdminOnly -ne 1 -or $options.Dot11Support -ne 0 -or $options.WinPcapCompatible -ne 0) { throw 'Npcap requires administrator-only capture, without wireless or WinPcap compatibility.' } +} +function Assert-Selection($r,[switch]$Opened) { + $snapshot=Get-BridgeHostSnapshot $r.driverDirectory + $assessment=Get-BridgeLabAssessment $snapshot $r.wiredGuid $r.tapGuid -DisposableLab -LocalConsole -DedicatedTap + $blockers=@($assessment.Blockers | Where-Object { -not ($Opened -and $_ -eq 'tap-not-idle') }) + if ($blockers.Count) { throw ('Bridge preflight failed: '+($blockers -join ', ')) } + $wired=Get-Exact $r.wiredGuid $r.wiredPnp; $tap=Get-Exact $r.tapGuid $r.tapPnp + & $native { param($a) Assert-InstalledTapDriver $a } $tap + Assert-Npcap + if (([string]$wired.MacAddress).Replace('-',':') -ieq $r.lanMac -or ([string]$wired.MacAddress).Replace('-',':') -ieq $r.privateMac) { throw 'Guest and host MACs must differ.' } + if (@(Get-NetIPAddress -AddressFamily IPv4 | Where-Object IPAddress -eq $r.probeAddress).Count) { throw 'The wired probe must be a separate peer, not this Windows host.' } + $other=@(Get-NetAdapter | Where-Object { $_.Status -eq 'Up' -and ([guid]$_.InterfaceGuid) -notin @([guid]$r.wiredGuid,[guid]$r.tapGuid) }) + if (-not $other.Count) { throw 'An independent management adapter must remain up.' } + foreach ($a in $wired,$tap) { if ('nmap_npcap' -notin @(Get-Bindings $a | Where-Object Enabled | Select-Object -ExpandProperty ComponentID)) { throw 'Npcap is not attached to the selected adapter.' } } +} +$backend=@{ + Capture={ param($r) Assert-Selection $r; [pscustomobject]@{ MachineGuid=$machine; Tap=(& $native { param($g) Get-AdapterBaseline $g } $r.tapGuid); Wired=(& $native { param($g) Get-AdapterBaseline $g } $r.wiredGuid) } } + ValidateMachine={ param($j) if ($j.Before.MachineGuid -ne $machine) { throw 'Journal belongs to a different Windows installation.' } } + Probe={ param($r) & $native { param($g,$r) Test-WiredProbe $g ([pscustomobject]@{ProbeName=$r.probeName;ProbeAddress=$r.probeAddress;ProbePort=$r.probePort}) 5 } $r.wiredGuid $r } + ValidateBefore={ param($before,$r) + Assert-Selection $r + foreach ($saved in $before.Tap,$before.Wired) { + if (-not (& $native { param($s) Test-AdapterRestored $s } $saved)) { throw 'Adapter baseline changed before setup.' } + } + } + Prepare={ param($j) + foreach ($id in $ownedBindings) { + $tap=Get-Exact $j.Request.tapGuid $j.Request.tapPnp + $binding=@(Get-Bindings $tap | Where-Object ComponentID -eq $id) + if ($binding.Count -eq 0 -and $id -notin @('ms_tcpip','ms_tcpip6')) { continue };if ($binding.Count -ne 1) { throw 'TAP binding inventory is ambiguous.' } + if ($binding[0].Enabled) { Set-OwnedBinding $binding[0] $false } + } + } + ValidateRecovery={ param($j) + $tap=@(Get-NetAdapter -IncludeHidden | Where-Object { ([string]$_.InterfaceGuid).Trim('{}') -eq $j.Before.Tap.Guid }) + if ($tap.Count -gt 1 -or ($tap.Count -eq 1 -and [string]$tap[0].PnPDeviceID -ne $j.Before.Tap.Pnp)) { throw 'Owned TAP identity changed.' } + if ($tap.Count -eq 1) { + $current=@(Get-Bindings $tap[0]);$saved=@($j.Before.Tap.Bindings) + if ($current.Count -ne $saved.Count) { throw 'TAP binding inventory changed.' } + foreach ($b in $current) { + $old=@($saved | Where-Object ComponentID -eq $b.ComponentID) + if ($old.Count -ne 1 -or ($b.ComponentID -notin $ownedBindings -and [bool]$b.Enabled -ne [bool]$old[0].Enabled)) { throw 'Unrelated TAP bindings changed. Inspect locally before recovery.' } + } + } + } + VerifyRestored={ param($j) + $tap=@(Get-NetAdapter -IncludeHidden | Where-Object { ([string]$_.InterfaceGuid).Trim('{}') -eq $j.Before.Tap.Guid }) + if ($tap.Count -eq 0) { return $true } + $current=@(Get-Bindings $tap[0] | Sort-Object ComponentID | Select-Object ComponentID,Enabled) + $saved=@($j.Before.Tap.Bindings | Sort-Object ComponentID | Select-Object ComponentID,Enabled) + return (($current | ConvertTo-Json -Compress) -eq ($saved | ConvertTo-Json -Compress)) + } + Restore={ param($j) + $tap=@(Get-NetAdapter -IncludeHidden | Where-Object { ([string]$_.InterfaceGuid).Trim('{}') -eq $j.Before.Tap.Guid }) + if ($tap.Count -eq 1) { + foreach ($id in $ownedBindings) { + $a=Get-Exact $j.Before.Tap.Guid $j.Before.Tap.Pnp + $binding=@(Get-Bindings $a | Where-Object ComponentID -eq $id) + $saved=@($j.Before.Tap.Bindings | Where-Object ComponentID -eq $id) + if ($binding.Count -eq 0 -and $saved.Count -eq 0 -and $id -notin @('ms_tcpip','ms_tcpip6')) { continue };if ($binding.Count -ne 1 -or $saved.Count -ne 1) { throw 'TAP recovery binding inventory is ambiguous.' } + if ([bool]$binding[0].Enabled -ne [bool]$saved[0].Enabled) { Set-OwnedBinding $binding[0] ([bool]$saved[0].Enabled) } + } + } + } +} +$held=$null;$prepared=$false;$failure=$null;$mutex=$null;$mutexHeld=$false;$tapReady=$null +try { + $held=[IO.File]::Open((Join-Path $Directory 'operation.lock'),[IO.FileMode]::OpenOrCreate,[IO.FileAccess]::ReadWrite,[IO.FileShare]::None) + if ($Request.Action -notin @('Start','Recover')) { throw 'Unknown broker action.' } + if ($Request.Action -eq 'Recover') { + $previous=Get-Content -LiteralPath $journal -Raw|ConvertFrom-Json + foreach ($field in 'tapGuid','tapPnp','wiredGuid','wiredPnp') { if ($previous.Request.$field -ne $p.$field) { throw 'Use the exact saved plan for recovery.' } } + } + $created=$false + $mutex=[Threading.Mutex]::new($false,('Global\TryOmarchyNpcapLab-'+([guid]$p.tapGuid).ToString()),[ref]$created) + try { $mutexHeld=$mutex.WaitOne(0) } catch [Threading.AbandonedMutexException] { $mutexHeld=$true } + if (-not $mutexHeld) { throw 'Another frame pump owns this TAP.' } + if ($Request.Action -eq 'Recover') { Restore-LauncherBridge $backend $journal | Out-Null; [Console]::WriteLine('{"state":"complete"}');return } + $nativeJournal=Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) 'TryOmarchyBridgeLab\operation.json' + if ((Test-Path -LiteralPath $nativeJournal) -and (Get-Content -LiteralPath $nativeJournal -Raw|ConvertFrom-Json).Phase -ne 'Complete') { throw 'Recover the native Windows bridge journal first.' } + $active=Start-LauncherBridge $backend $journal $p;$prepared=$true + $tap=Get-Exact $p.tapGuid $p.tapPnp;$wired=Get-Exact $p.wiredGuid $p.wiredPnp + $wiredMac=([string]$wired.MacAddress).Replace('-',':').ToLowerInvariant() + Add-Type -Path @((Join-Path $PSScriptRoot 'NpcapFramePump.cs'),(Join-Path $PSScriptRoot 'HostTcpSegmentation.cs'),(Join-Path $PSScriptRoot 'OwnedBridgeInput.cs')) + $tapReady=[Threading.ManualResetEventSlim]::new($false) + $state=@{ Input=[OwnedBridgeInput]::new(); Qemu=$null; Stop=$false; TapOpened=$false; Deadline=[DateTime]::UtcNow.AddSeconds(90) } + $guard=[Action]{ + if ($state.Input.Failure) { throw 'Bridge input failed or exceeded its command limits.' } + if ($state.Input.Ended) { $state.Stop=$true;return } + $line=$state.Input.Next() + if ($null -ne $line) { + $command=$line | ConvertFrom-Json + if ($command.action -eq 'stop') { $state.Stop=$true;return } + if ($command.action -ne 'attach' -or $state.Qemu) { throw 'Unexpected broker command.' } + $q=Get-CimInstance Win32_Process -Filter "ProcessId=$([int]$command.pid)" + if (-not $q -or $q.ExecutablePath -ne $command.executable -or $q.Name -notmatch '^qemu-system-x86_64w?\.exe$') { throw 'QEMU identity does not match.' } + $argument='-netdev\s+"?tap,[^\s]*ifname="?'+[regex]::Escape(([string]$tap.Name).Replace(',',',,'))+'(?:"|\s|$)' + if ($q.CommandLine -notmatch $argument -or $q.CommandLine -notmatch ('mac='+[regex]::Escape($p.lanMac)) -or $q.CommandLine -notmatch ('mac='+[regex]::Escape($p.privateMac))) { throw 'QEMU does not own the selected dual-network configuration.' } + $state.Qemu=$q;$state.Deadline=[DateTime]::UtcNow.AddSeconds(20) + } + if ($state.Stop) { return } + $w=Get-Exact $p.wiredGuid $p.wiredPnp;$t=Get-Exact $p.tapGuid $p.tapPnp + if ($w.Status -ne 'Up' -or ([string]$w.MacAddress).Replace('-',':').ToLowerInvariant() -ne $wiredMac) { throw 'Selected wired link or MAC changed.' } + foreach ($a in $w,$t) { + $ids=@(Get-Bindings $a | Where-Object Enabled | Select-Object -ExpandProperty ComponentID) + if ('nmap_npcap' -notin $ids -or 'vms_pp' -in $ids -or (Test-BridgeBinding $ids)) { throw 'Selected adapter bindings changed.' } + } + if (@(Get-Bindings $t | Where-Object { $_.Enabled -and $_.ComponentID -in $ownedBindings }).Count) { throw 'Owned TAP bindings changed.' } + $currentWired=@(Get-Bindings $w|Sort-Object ComponentID|Select-Object ComponentID,Enabled) + $savedWired=@($active.Before.Wired.Bindings|Sort-Object ComponentID|Select-Object ComponentID,Enabled) + if (($currentWired|ConvertTo-Json -Compress) -ne ($savedWired|ConvertTo-Json -Compress)) { throw 'Selected wired bindings changed.' } + if ($state.Qemu) { + $q=Get-CimInstance Win32_Process -Filter "ProcessId=$($state.Qemu.ProcessId)" + if (-not $q -or $q.CreationDate -ne $state.Qemu.CreationDate -or $q.ExecutablePath -ne $state.Qemu.ExecutablePath) { $state.Stop=$true;return } + if ($t.Status -eq 'Up' -and -not $state.TapOpened) { + $state.TapOpened=$true;$tapReady.Set();Set-LauncherBridgeRunning $journal + [Console]::WriteLine('{"state":"running"}') + } elseif ($t.Status -ne 'Up' -and ($state.TapOpened -or [DateTime]::UtcNow -gt $state.Deadline)) { throw 'Owned TAP did not open or lost its link.' } + } elseif ([DateTime]::UtcNow -gt $state.Deadline) { throw 'Launcher did not attach QEMU in time.' } + } + $ready=[Action]{ [Console]::WriteLine((@{state='forwarding';tapName=[string]$tap.Name} | ConvertTo-Json -Compress)) } + $keep=[Func[bool]]{ -not $state.Stop } + try { + [NpcapFramePump]::RunOwned($p.wiredGuid,$p.tapGuid,$p.lanMac,$wiredMac,$guard,$ready,$keep,$tapReady) | Out-Null + } catch { + # QEMU closes TAP before the periodic guard can observe process exit. + # Accept only that disconnect error after independently checking the + # original process identity. Recovery below must still succeed. + $ended=$false + if ($state.Qemu -and $_.Exception.Message -match 'Transmit injection failed:.*network media is disconnected') { + $current=Get-CimInstance Win32_Process -Filter "ProcessId=$($state.Qemu.ProcessId)" + $ended=(-not $current -or $current.CreationDate -ne $state.Qemu.CreationDate -or $current.ExecutablePath -ne $state.Qemu.ExecutablePath) + } + if (-not $ended) { throw } + } +} catch { $failure=$_.Exception.Message } +finally { + if ($prepared) { try { Restore-LauncherBridge $backend $journal | Out-Null } catch { $failure="$failure Recovery is pending: $($_.Exception.Message)" } } + if ($tapReady) { $tapReady.Dispose() } + if ($mutexHeld) { $mutex.ReleaseMutex() };if ($mutex) { $mutex.Dispose() } + if ($held) { $held.Dispose() } +} +if ($failure) { [Console]::WriteLine((@{state='error';error=$failure} | ConvertTo-Json -Compress));exit 1 } +[Console]::WriteLine('{"state":"complete"}') diff --git a/scripts/network/payload.go b/scripts/network/payload.go new file mode 100644 index 00000000..dbb99f3c --- /dev/null +++ b/scripts/network/payload.go @@ -0,0 +1,8 @@ +// Package networkpayload embeds the reviewed bridge code into the launcher. +// Elevated execution never imports scripts from the user's installation. +package networkpayload + +import "embed" + +//go:embed NpcapFramePump.cs OwnedBridgeInput.cs HostTcpSegmentation.cs bridge-native.psm1 bridge-preflight.psm1 bridge-datapath.psm1 bridge-transaction.psm1 tap-windows6.lock.json npcap-1.89.lock.json launcher-bridge.ps1 launcher-bridge-transaction.psm1 +var Files embed.FS diff --git a/scripts/network/test-launcher-bridge-input.ps1 b/scripts/network/test-launcher-bridge-input.ps1 new file mode 100644 index 00000000..767d7753 --- /dev/null +++ b/scripts/network/test-launcher-bridge-input.ps1 @@ -0,0 +1,36 @@ +$ErrorActionPreference='Stop' +Add-Type -Path (Join-Path $PSScriptRoot 'OwnedBridgeInput.cs') +Add-Type -TypeDefinition @' +using System; +using System.IO; +using System.Threading; +public sealed class BlockingBridgeReader : TextReader { + public readonly ManualResetEventSlim Release = new ManualResetEventSlim(false); + private int calls; + public override string ReadLine() { Release.Wait(); return Interlocked.Increment(ref calls) == 1 ? "attach" : null; } +} +'@ +$count=0 +function Assert($ok,$message){if(-not $ok){throw $message};$script:count++} +$original=[Console]::In +try { + $blocking=[BlockingBridgeReader]::new();[Console]::SetIn($blocking) + $timer=[Diagnostics.Stopwatch]::StartNew();$reader=[OwnedBridgeInput]::new() + Assert ($timer.ElapsedMilliseconds -lt 1000) 'constructor blocked on input' + Assert (-not $reader.Ended) 'input ended before release' + $blocking.Release.Set() + for($i=0;$i -lt 100 -and -not $reader.Ended;$i++){[Threading.Thread]::Sleep(10)} + Assert $reader.Ended 'EOF did not finish input' + Assert ($reader.Next() -eq 'attach') 'command not retained' + Assert ($null -eq $reader.Next()) 'command read twice' + Assert ($null -eq $reader.Failure) 'normal EOF failed' + [Console]::SetIn([IO.StringReader]::new((('x'*8193)+"`n")));$reader=[OwnedBridgeInput]::new() + for($i=0;$i -lt 100 -and -not $reader.Ended;$i++){[Threading.Thread]::Sleep(10)} + Assert ($null -ne $reader.Failure) 'oversized command accepted' + Assert ($null -eq $reader.Next()) 'oversized command queued' + [Console]::SetIn([IO.StringReader]::new(("x`n"*9)));$reader=[OwnedBridgeInput]::new() + for($i=0;$i -lt 100 -and -not $reader.Ended;$i++){[Threading.Thread]::Sleep(10)} + Assert ($null -ne $reader.Failure) 'unbounded command queue accepted' + Assert $reader.Ended 'failed input did not end' +} finally {[Console]::SetIn($original)} +Write-Host "$count launcher input checks passed" diff --git a/scripts/network/test-launcher-bridge.ps1 b/scripts/network/test-launcher-bridge.ps1 new file mode 100644 index 00000000..e4b2c0d5 --- /dev/null +++ b/scripts/network/test-launcher-bridge.ps1 @@ -0,0 +1,41 @@ +$ErrorActionPreference='Stop' +Import-Module (Join-Path $PSScriptRoot 'launcher-bridge-transaction.psm1') -Force +$count=0 +function Assert($ok,$message) { if (-not $ok) { throw $message };$script:count++ } +$dir=Join-Path ([IO.Path]::GetTempPath()) ([guid]::NewGuid().ToString());[IO.Directory]::CreateDirectory($dir)|Out-Null +$path=Join-Path $dir 'operation.json' +$state=@{Events=[Collections.Generic.List[string]]::new();Probe=$true;PrepareFails=$false;RestoreFails=$false;Foreign=$false;BeforeChanged=$false} +$backend=@{ + Capture={param($r) $state.Events.Add('capture');@{MachineGuid='machine';Tap=@{Guid='tap';Pnp='owned'}}} + Probe={param($r) $state.Events.Add('probe');$state.Probe} + ValidateMachine={param($j) if($j.Before.MachineGuid -ne 'machine'){throw 'foreign machine'}} + ValidateBefore={param($b,$r) $state.Events.Add('recheck');if($state.BeforeChanged){throw 'changed'}} + Prepare={param($j) $state.Events.Add('prepare');if($state.PrepareFails){throw 'partial setup'}} + ValidateRecovery={param($j) $state.Events.Add('identity');if($state.Foreign){throw 'foreign identity'}} + VerifyRestored={param($j) $true} + Restore={param($j) $state.Events.Add('restore');if($state.RestoreFails){throw 'restore failed'}} +} +function Reset { if(Test-Path $path){Remove-Item $path};$state.Events.Clear();$state.Probe=$true;$state.PrepareFails=$false;$state.RestoreFails=$false;$state.Foreign=$false;$state.BeforeChanged=$false } +function Throws($action) { $thrown=$false;try { &$action | Out-Null } catch {$thrown=$true};Assert $thrown 'expected failure' } +function Phase { (Get-Content $path -Raw|ConvertFrom-Json).Phase } +try { + Reset;$j=Start-LauncherBridge $backend $path @{};Assert ($j.Phase -eq 'Ready') 'not ready' + Assert (($state.Events -join ',') -eq 'capture,probe,recheck,prepare,probe') 'setup order' + Set-LauncherBridgeRunning $path;Assert ((Phase) -eq 'Running') 'not running' + Throws { Start-LauncherBridge $backend $path @{} };Assert ((Phase) -eq 'Running') 'pending journal overwritten' + Restore-LauncherBridge $backend $path|Out-Null;Assert ((Phase) -eq 'Complete') 'not cleaned' + $before=$state.Events.Count;Restore-LauncherBridge $backend $path|Out-Null;Assert ($state.Events.Count -eq $before) 'duplicate cleanup mutated' + Start-LauncherBridge $backend $path @{}|Out-Null;Assert ((Phase) -eq 'Ready') 'repeat setup failed' + Reset;$state.Probe=$false;Throws {Start-LauncherBridge $backend $path @{}};Assert (-not (Test-Path $path)) 'failed baseline journaled';Assert ('prepare' -notin $state.Events) 'baseline mutated' + Reset;$state.BeforeChanged=$true;Throws {Start-LauncherBridge $backend $path @{}};Assert (-not (Test-Path $path)) 'changed baseline journaled';Assert ('prepare' -notin $state.Events) 'changed baseline mutated' + Reset;$state.PrepareFails=$true;Throws {Start-LauncherBridge $backend $path @{}};Assert ((Phase) -eq 'Complete') 'partial setup not recovered';Assert ('restore' -in $state.Events) 'no partial undo' + Reset;$state.PrepareFails=$true;$state.RestoreFails=$true;Throws {Start-LauncherBridge $backend $path @{}};Assert ((Phase) -eq 'RecoveryRequired') 'failed undo not pending' + $state.PrepareFails=$false;$state.RestoreFails=$false;Restore-LauncherBridge $backend $path|Out-Null;Assert ((Phase) -eq 'Complete') 'retry recovery failed' + Reset;Start-LauncherBridge $backend $path @{}|Out-Null;$state.Foreign=$true;$state.Events.Clear();Throws {Restore-LauncherBridge $backend $path};Assert ((Phase) -eq 'RecoveryRequired') 'foreign identity not pending';Assert ('restore' -notin $state.Events) 'foreign identity mutated' + $state.Foreign=$false;$state.Probe=$false;Throws {Restore-LauncherBridge $backend $path};Assert ((Phase) -eq 'RecoveryRequired') 'failed host probe accepted' + $state.Probe=$true;Restore-LauncherBridge $backend $path|Out-Null;Assert ((Phase) -eq 'Complete') 'host recovery retry failed' + Reset;$savedPrepare=$backend.Prepare;$backend.Prepare={param($j) $state.Events.Add('uncertain');throw [TimeoutException]::new('reply lost')} + Throws {Start-LauncherBridge $backend $path @{}};Assert ((Phase) -eq 'RecoveryRequired') 'uncertain command not pending';Assert ('restore' -notin $state.Events) 'undo raced an uncertain command' + $backend.Prepare=$savedPrepare;Restore-LauncherBridge $backend $path|Out-Null;Assert ((Phase) -eq 'Complete') 'explicit uncertain recovery failed' + Write-Host "$count launcher bridge transaction checks passed" +} finally {Remove-Item $dir -Recurse -Force}