diff --git a/app/bridge_boot_budget.go b/app/bridge_boot_budget.go new file mode 100644 index 0000000..30784f5 --- /dev/null +++ b/app/bridge_boot_budget.go @@ -0,0 +1,38 @@ +package main + +import "encoding/json" + +// Charge only observed running VM time. A user pause or host suspend must not +// consume the bridge's guest-readiness budget or cause an automatic resume. +const bridgeBootTimeoutTicks = 300 + +type bridgeBootBudget struct { + running bool + ticks int +} + +func (b *bridgeBootBudget) observe(line string) { + var m qmpMessage + if json.Unmarshal([]byte(line), &m) != nil { + return + } + switch m.Event { + case "STOP": + b.running = false + case "RESUME": + b.running = true + default: + var status struct { + Running *bool `json:"running"` + } + if len(m.Return) > 0 && json.Unmarshal(m.Return, &status) == nil && status.Running != nil { + b.running = *status.Running + } + } +} +func (b *bridgeBootBudget) tick() { + if b.running && b.ticks < bridgeBootTimeoutTicks { + b.ticks++ + } +} +func (b *bridgeBootBudget) expired() bool { return b.running && b.ticks >= bridgeBootTimeoutTicks } diff --git a/app/bridge_boot_budget_test.go b/app/bridge_boot_budget_test.go new file mode 100644 index 0000000..2ebc31b --- /dev/null +++ b/app/bridge_boot_budget_test.go @@ -0,0 +1,55 @@ +package main + +import "testing" + +func TestBridgeBootBudgetPreservesPausedVM(t *testing.T) { + var b bridgeBootBudget + for i := 0; i < 1000; i++ { + b.tick() + } + if b.ticks != 0 { + t.Fatal("unknown startup state consumed the budget") + } + b.observe(`{"return":{"running":true,"status":"running"}}`) + for i := 0; i < 200; i++ { + b.tick() + } + b.observe(`{"event":"STOP"}`) + b.observe(`{"event":"STOP"}`) + for i := 0; i < 1000; i++ { + b.tick() + } + if b.ticks != 200 || b.expired() { + t.Fatal("paused VM expired", b) + } + b.observe(`{"return":{"running":false,"status":"paused"}}`) + b.observe(`{"return":{}}`) + b.observe(`{"event":"UNRELATED"}`) + b.tick() + if b.ticks != 200 { + t.Fatal("unrelated QMP message resumed budget", b) + } + b.observe(`{"event":"RESUME"}`) + for i := 0; i < 99; i++ { + b.tick() + } + if b.expired() { + t.Fatal("budget expired early") + } + b.tick() + if !b.expired() { + t.Fatal("running VM never expired") + } + b.tick() + if b.ticks != bridgeBootTimeoutTicks { + t.Fatal("counter exceeded its bound") + } + b.observe(`{"event":"STOP"}`) + if b.expired() { + t.Fatal("exhausted budget stopped a manually paused VM") + } + b.observe(`{"event":"RESUME"}`) + if !b.expired() { + t.Fatal("resuming an unready VM discarded its exhausted budget") + } +} diff --git a/app/main.go b/app/main.go index 561042e..6bb6134 100644 --- a/app/main.go +++ b/app/main.go @@ -232,7 +232,7 @@ func main() { *recoveryAction = "uninstall" } maintenance := *backupPath != "" || *restorePath != "" || *recoveryAction != "" - if *recoveryAction != "" && (*recoveryAction != "backup" && *recoveryAction != "restore" && *recoveryAction != "reset" && *recoveryAction != "uninstall" && *recoveryAction != "move" && *recoveryAction != "move-cleanup" && *recoveryAction != "snapshots" && *recoveryAction != "portable-create" || *backupPath != "" || *restorePath != "") { + if *recoveryAction != "" && (*recoveryAction != "backup" && *recoveryAction != "restore" && *recoveryAction != "reset" && *recoveryAction != "uninstall" && *recoveryAction != "move" && *recoveryAction != "move-cleanup" && *recoveryAction != "snapshots" && *recoveryAction != "portable-create" && *recoveryAction != "bridge-nat" || *backupPath != "" || *restorePath != "") { fatal("Choose one recovery action: backup, restore, snapshots, portable-create, reset, move, or uninstall.") } if maintenance && (*backupPath != "" && *restorePath != "" || cfg.portable && !portableRecoveryAllowed(*recoveryAction, *backupPath, *restorePath) || cfg.fresh || *openSettings || *diagnostics || *enableWhp || *applyLauncherUpdateFlag || *applyLauncherRollbackFlag) { @@ -745,6 +745,21 @@ func main() { if err := json.Unmarshal(specData, &spec); err != nil { fatal("Cannot parse build-spec.json: %v", err) } + if cfg.bridge == nil { + network, err := loadNetworkPreferences(cfg.dir) + if err != nil { + fatal("Cannot read network preferences: %v", err) + } + if network.Mode == "bridge-lab" { + if cfg.portable { + fatal("Bridge lab preferences cannot be used in a portable installation.") + } + if !guestAcceptsDualNIC(spec) { + fatal("The selected guest image does not support automatic dual-NIC routing. Install a compatible candidate or use Recover TAP / use NAT in Settings.") + } + cfg.bridge = network.Bridge + } + } cfg.guestPinch = guestAcceptsPinch(spec) // Serial log only - no console= on the display, so no kernel text or // blinking cursor flashes in the window before SDDM (boot problems: read @@ -757,6 +772,9 @@ func main() { } cmdline += sshCmdline(cfg.forwards, cfg.sshKey) cmdline += shareCmdline(cfg.share) + if cfg.bridge != nil && guestAcceptsDualNIC(spec) { + cmdline += bridgeGuestCmdline(cfg.bridge) + } zone, layout, variant, locale := hostLocale(*timeZoneFlag, *keyboardFlag, *localeFlag) if words := hostLocaleCmdline(zone, layout, variant, locale); words != "" { cmdline += words @@ -883,14 +901,17 @@ func supervise(cfg *config, cmdline string) bool { var proc *exec.Cmd var qmp *qmpConn var bridge *bridgeSession - stopBridge := func() { + stopBridge := func() error { + var cleanupErr error if bridge != nil { - if err := bridge.Close(); err != nil { + cleanupErr = bridge.Close() + if err := cleanupErr; err != nil { logf("bridge cleanup: %v", err) } bridge = nil } cfg.bridgeFailure = nil + return cleanupErr } defer stopBridge() // The worst case can consume one attempt each for nested virtualization, @@ -909,6 +930,7 @@ func supervise(cfg *config, cmdline string) bool { logf("booting - %s (attempt %d)", mode, attempt) pendingReboot.Store(false) guestReady.Store(false) + guestNetworkFailed.Store(false) controlDir, err := prepareQMPControl() if err != nil { fatal("Cannot prepare private VM controls: %v", err) @@ -994,6 +1016,13 @@ func supervise(cfg *config, cmdline string) bool { startupDead := false probe: for qmp == nil && time.Now().Before(deadline) { + if cfg.bridge != nil && guestNetworkFailed.Load() { + proc.Process.Kill() + <-exited + qemuPid.Store(0) + stopBridge() + fatal("The guest could not establish separate LAN and private routes. TAP recovery was attempted. Inspect the launcher log before relaunching.") + } select { case err := <-cfg.bridgeFailure: proc.Process.Kill() @@ -1086,7 +1115,7 @@ func supervise(cfg *config, cmdline string) bool { // update components rollback-capable until the in-guest readiness // service reaches userspace and networking. defer qmp.close() - return watch(cfg, qmp, exited) + return watch(cfg, qmp, exited, stopBridge) } qemuPid.Store(0) if !startupDead { @@ -1105,7 +1134,23 @@ func supervise(cfg *config, cmdline string) bool { return false } -func watch(cfg *config, qmp *qmpConn, exited <-chan error) bool { +// Recovery must finish before displaying a modal error or offering NAT. +var bridgeFailureNotice = errorBox + +func stopFailedBridge(cfg *config, exited <-chan error, cleanup func() error, message string) { + if cfg.bridgeQemu != nil { + cfg.bridgeQemu.Kill() + } + waitExit(exited, 15*time.Second, cfg) + if err := cleanup(); err != nil { + message += "\n\nTAP recovery is still pending: " + err.Error() + ". Use the independent local console before relaunching." + } else { + message += "\n\nThe lab VM was stopped and TAP recovery completed. Check the launcher log before relaunching." + } + bridgeFailureNotice(message) +} + +func watch(cfg *config, qmp *qmpConn, exited <-chan error, stopBridge func() error) bool { logf("supervisor: watching guest lifecycle and file drops") lines := qmp.readLines() reason := "" @@ -1115,8 +1160,15 @@ func watch(cfg *config, qmp *qmpConn, exited <-chan error) bool { defer ticker.Stop() procDown := false movedBootPending := false + bridgeReady := cfg.bridge == nil + var bridgeBoot bridgeBootBudget for reason == "" && !procDown { + if cfg.bridge != nil && (guestNetworkFailed.Load() || (!bridgeReady && !guestReady.Load() && bridgeBoot.expired())) { + stopFailedBridge(cfg, exited, stopBridge, "The guest could not establish separate LAN and private routes.") + return false + } if guestReady.Swap(false) { + bridgeReady = true commitLauncherUpdate(cfg.dir) commitPayloadUpdates(cfg.dir) commitCheckpointBoot(cfg.dir) @@ -1128,12 +1180,7 @@ func watch(cfg *config, qmp *qmpConn, exited <-chan error) bool { } 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)) + stopFailedBridge(cfg, exited, stopBridge, fmt.Sprintf("Bridge forwarding stopped: %v", err)) return false case <-exited: procDown = true @@ -1144,6 +1191,7 @@ func watch(cfg *config, qmp *qmpConn, exited <-chan error) bool { break } silent = 0 + bridgeBoot.observe(line) if paths, point, ok := droppedFilesEvent(line); ok { logf("file drop: received %d item(s)", len(paths)) if err := sendDroppedFilesAt(paths, guestDropPoint(point), cursorPosition()); err != nil { @@ -1155,6 +1203,7 @@ func watch(cfg *config, qmp *qmpConn, exited <-chan error) bool { } case <-ticker.C: tick++ + bridgeBoot.tick() if tick%5 == 0 { if err := qmp.writeLine(`{"execute":"query-status"}`); err != nil { procDown = waitExit(exited, 15*time.Second, cfg) @@ -1214,8 +1263,9 @@ drained: } var ( - pendingReboot atomic.Bool - guestReady atomic.Bool + pendingReboot atomic.Bool + guestReady atomic.Bool + guestNetworkFailed atomic.Bool ) // runLifecycleListener receives the guest's shutdown intent: the image's @@ -1244,6 +1294,10 @@ func runLifecycleListener() { case "reboot": logf("guest announced reboot") pendingReboot.Store(true) + case "bridge-failed": + guestNetworkFailed.Store(true) + guestReady.Store(false) + logf("guest dual-NIC readiness failed") case "ready": logf("guest userspace announced ready") guestReady.Store(true) @@ -1269,7 +1323,9 @@ func waitExit(exited <-chan error, grace time.Duration, cfg *config) bool { return true case <-time.After(grace): logf("QEMU wedged after guest shutdown (stock WHPX trap) - cleaning up") - if pid := qemuPid.Load(); pid != 0 { + if cfg.bridgeQemu != nil { + cfg.bridgeQemu.Kill() + } else if pid := qemuPid.Load(); pid != 0 { if p, err := os.FindProcess(int(pid)); err == nil { p.Kill() } diff --git a/app/network_preferences.go b/app/network_preferences.go new file mode 100644 index 0000000..0126cbb --- /dev/null +++ b/app/network_preferences.go @@ -0,0 +1,127 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strings" +) + +// Host adapter identities must not travel in a guest backup or portable copy. +// Keep this outside settings.json so older launchers can roll back safely. +const networkPreferencesFilename = "network-preferences.json" +const dualNICCapability = "dual-nic-v1" + +type networkPreferences struct { + SchemaVersion int `json:"schemaVersion"` + Mode string `json:"mode"` + Bridge *bridgePlan `json:"bridge,omitempty"` +} + +func (p networkPreferences) validate() error { + if p.SchemaVersion != 1 || (p.Mode != "nat" && p.Mode != "bridge-lab") { + return fmt.Errorf("unsupported network preference") + } + if p.Mode == "bridge-lab" && p.Bridge == nil { + return fmt.Errorf("choose an explicit bridge lab plan first") + } + if p.Bridge != nil { + return p.Bridge.validate() + } + return nil +} +func loadNetworkPreferences(dir string) (networkPreferences, error) { + defaults := networkPreferences{SchemaVersion: 1, Mode: "nat"} + f, err := os.Open(filepath.Join(dir, networkPreferencesFilename)) + if os.IsNotExist(err) { + return defaults, nil + } + if err != nil { + return defaults, err + } + defer f.Close() + data, err := io.ReadAll(io.LimitReader(f, 65537)) + if err != nil { + return defaults, err + } + if len(data) > 65536 { + return defaults, fmt.Errorf("network preferences are too large") + } + d := json.NewDecoder(bytes.NewReader(bytes.TrimPrefix(data, []byte{239, 187, 191}))) + d.DisallowUnknownFields() + var p networkPreferences + if err = d.Decode(&p); err != nil { + return defaults, err + } + if d.Decode(&struct{}{}) != io.EOF { + return defaults, fmt.Errorf("network preferences contain trailing data") + } + return p, p.validate() +} +func saveNetworkPreferences(dir string, p networkPreferences) error { + if err := p.validate(); err != nil { + return err + } + data, err := json.MarshalIndent(p, "", " ") + if err != nil { + return err + } + if err = os.MkdirAll(dir, 0755); err != nil { + return err + } + f, err := os.CreateTemp(dir, ".network-preferences-*") + if err != nil { + return err + } + defer os.Remove(f.Name()) + if _, err = f.Write(append(data, '\n')); err != nil { + f.Close() + return err + } + if err = f.Sync(); err != nil { + f.Close() + return err + } + if err = f.Close(); err != nil { + return err + } + return os.Rename(f.Name(), filepath.Join(dir, networkPreferencesFilename)) +} +func guestAcceptsDualNIC(spec buildSpec) bool { + return slices.Contains(spec.Runtime.NetworkCapabilities, dualNICCapability) +} +func bridgeGuestCmdline(p *bridgePlan) string { + if p == nil { + return "" + } + return " tryomarchy.network=" + dualNICCapability + " tryomarchy.lan_mac=" + strings.ToLower(p.LANMac) + " tryomarchy.private_mac=" + strings.ToLower(p.PrivateMac) +} +func requireDualNICGuest(guestDir string) error { + data, err := os.ReadFile(filepath.Join(guestDir, "build-spec.json")) + if err != nil { + return fmt.Errorf("install a guest image with dual-NIC support before enabling the bridge: %w", err) + } + var spec buildSpec + if err = json.Unmarshal(data, &spec); err != nil { + return err + } + if !guestAcceptsDualNIC(spec) { + return fmt.Errorf("this guest image does not support automatic dual-NIC routing; install a compatible candidate first") + } + return nil +} + +// Imported plans may change adapter selection, but not this installation's +// existing DHCP identities. NAT retains them for the next explicit opt-in. +func importNetworkPlan(current networkPreferences, plan bridgePlan) networkPreferences { + if current.Bridge != nil { + plan.LANMac = current.Bridge.LANMac + plan.PrivateMac = current.Bridge.PrivateMac + } + current.Bridge = &plan + return current +} diff --git a/app/network_preferences_test.go b/app/network_preferences_test.go new file mode 100644 index 0000000..ce54e90 --- /dev/null +++ b/app/network_preferences_test.go @@ -0,0 +1,121 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestNetworkPreferencesDefaultAndStableIdentity(t *testing.T) { + dir := t.TempDir() + p, err := loadNetworkPreferences(dir) + if err != nil || p.Mode != "nat" || p.Bridge != nil { + t.Fatalf("%+v %v", p, err) + } + plan := validBridgePlan() + p = importNetworkPlan(p, plan) + p.Mode = "bridge-lab" + if err = saveNetworkPreferences(dir, p); err != nil { + t.Fatal(err) + } + got, err := loadNetworkPreferences(dir) + if err != nil || !reflect.DeepEqual(got, p) { + t.Fatalf("%+v %v", got, err) + } + got.Mode = "nat" + if err = saveNetworkPreferences(dir, got); err != nil { + t.Fatal(err) + } + other := plan + other.LANMac = "52:54:00:00:00:03" + other.PrivateMac = "52:54:00:00:00:04" + other.TapPnp = "replacement" + got = importNetworkPlan(got, other) + if got.Bridge.LANMac != plan.LANMac || got.Bridge.PrivateMac != plan.PrivateMac || got.Bridge.TapPnp != "replacement" { + t.Fatal(got) + } + if backupNameAllowed(networkPreferencesFilename) { + t.Fatal("host adapter identities entered guest backups") + } +} +func TestNetworkPreferencesRejectsDamagedAndUnsafeFiles(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, networkPreferencesFilename) + p := networkPreferences{1, "bridge-lab", nil} + if saveNetworkPreferences(dir, p) == nil { + t.Fatal("bridge without explicit plan") + } + p.Bridge = new(bridgePlan) + *p.Bridge = validBridgePlan() + data, _ := json.Marshal(p) + for _, s := range []string{string(data) + " {}", strings.Replace(string(data), `"schemaVersion":1`, `"schemaVersion":2`, 1), strings.Replace(string(data), `"mode":"bridge-lab"`, `"mode":"automatic"`, 1), strings.Replace(string(data), `"schemaVersion":1`, `"unrecognized":1`, 1), strings.Repeat("x", 65537)} { + os.WriteFile(path, []byte(s), 0600) + if _, err := loadNetworkPreferences(dir); err == nil { + t.Fatal("accepted damaged network preferences") + } + } + os.WriteFile(path, append([]byte{239, 187, 191}, data...), 0600) + if _, err := loadNetworkPreferences(dir); err != nil { + t.Fatal(err) + } +} +func TestBridgeGuestCapabilityGateAndBootSelection(t *testing.T) { + var spec buildSpec + if guestAcceptsDualNIC(spec) { + t.Fatal("old image accepted") + } + spec.Runtime.NetworkCapabilities = []string{dualNICCapability} + if !guestAcceptsDualNIC(spec) { + t.Fatal("candidate rejected") + } + if bridgeGuestCmdline(nil) != "" { + t.Fatal("NAT cmdline changed") + } + p := validBridgePlan() + words := bridgeGuestCmdline(&p) + if words != " tryomarchy.network=dual-nic-v1 tryomarchy.lan_mac="+p.LANMac+" tryomarchy.private_mac="+p.PrivateMac { + t.Fatal(words) + } + dir := t.TempDir() + if requireDualNICGuest(dir) == nil { + t.Fatal("missing guest accepted") + } + data, _ := json.Marshal(spec) + os.WriteFile(filepath.Join(dir, "build-spec.json"), data, 0600) + if err := requireDualNICGuest(dir); err != nil { + t.Fatal(err) + } + spec.Runtime.NetworkCapabilities = nil + data, _ = json.Marshal(spec) + os.WriteFile(filepath.Join(dir, "build-spec.json"), data, 0600) + if requireDualNICGuest(dir) == nil { + t.Fatal("old guest accepted") + } +} + +func TestNetworkPreferencesDoNotFollowGuestRestore(t *testing.T) { + dir, archive := backupFixture(t) + plan := validBridgePlan() + prefs := networkPreferences{1, "bridge-lab", &plan} + if err := saveNetworkPreferences(dir, prefs); err != nil { + t.Fatal(err) + } + if err := writeVMBackup(dir, archive); err != nil { + t.Fatal(err) + } + restored := filepath.Join(filepath.Dir(dir), "restored") + if err := restoreVMBackup(archive, restored); err != nil { + t.Fatal(err) + } + got, err := loadNetworkPreferences(restored) + if err != nil || got.Mode != "nat" || got.Bridge != nil { + t.Fatalf("restored host bindings: %+v %v", got, err) + } + got, err = loadNetworkPreferences(dir) + if err != nil || !reflect.DeepEqual(got, prefs) { + t.Fatal("backup changed the original network choice", got, err) + } +} diff --git a/app/network_preferences_windows_test.go b/app/network_preferences_windows_test.go new file mode 100644 index 0000000..aa01d23 --- /dev/null +++ b/app/network_preferences_windows_test.go @@ -0,0 +1,190 @@ +//go:build windows + +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "syscall" + "testing" + "time" + "unsafe" +) + +func TestNetworkSettingsNativeOptIn(t *testing.T) { + launcher := os.Getenv("TRYOMARCHY_LAUNCHER_TEST_EXE") + if os.Getenv("TRYOMARCHY_UI_TEST") != "1" || launcher == "" { + t.Skip("requires isolated Windows UI candidate") + } + dir := t.TempDir() + plan := validBridgePlan() + prefs := networkPreferences{1, "nat", &plan} + if err := saveNetworkPreferences(dir, prefs); err != nil { + t.Fatal(err) + } + var spec buildSpec + spec.Runtime.NetworkCapabilities = []string{dualNICCapability} + data, _ := json.Marshal(spec) + os.MkdirAll(filepath.Join(dir, "guest"), 0755) + os.WriteFile(filepath.Join(dir, "guest", "build-spec.json"), data, 0600) + cmd := exec.Command(launcher, "-dir", dir, "-settings") + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + defer cmd.Process.Kill() + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + class, _ := syscall.UTF16PtrFromString("TryOmarchySettings") + title, _ := syscall.UTF16PtrFromString(appTitle + " settings") + var window, checkbox uintptr + for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); { + window, _, _ = user32.NewProc("FindWindowW").Call(uintptr(unsafe.Pointer(class)), uintptr(unsafe.Pointer(title))) + if window != 0 { + var owner uint32 + procGetWindowThreadProcessId.Call(window, uintptr(unsafe.Pointer(&owner))) + if owner == uint32(cmd.Process.Pid) { + checkbox, _, _ = user32.NewProc("GetDlgItem").Call(window, settingsBridgeOnID) + if checkbox != 0 { + break + } + } + } + time.Sleep(25 * time.Millisecond) + } + if checkbox == 0 { + t.Fatal("bridge lab settings control missing") + } + checked, _, _ := procSendMessageW.Call(checkbox, bmGetcheck, 0, 0) + if checked != 0 { + t.Fatal("NAT default changed") + } + procSendMessageW.Call(checkbox, bmSetcheck, bstChecked, 0) + procPostMessageW.Call(window, wmCommand, settingsSaveID, 0) + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(15 * time.Second): + dialogClass, _ := syscall.UTF16PtrFromString("#32770") + caption, _ := syscall.UTF16PtrFromString(appTitle) + dialog, _, _ := user32.NewProc("FindWindowW").Call(uintptr(unsafe.Pointer(dialogClass)), uintptr(unsafe.Pointer(caption))) + var messages []string + callback := syscall.NewCallback(func(handle, unused uintptr) uintptr { + var text [2048]uint16 + procGetWindowTextW.Call(handle, uintptr(unsafe.Pointer(&text[0])), 2048) + messages = append(messages, syscall.UTF16ToString(text[:])) + return 1 + }) + user32.NewProc("EnumChildWindows").Call(dialog, callback, 0) + t.Fatalf("bridge opt-in did not save: %v", messages) + } + got, err := loadNetworkPreferences(dir) + if err != nil || got.Mode != "bridge-lab" || !reflect.DeepEqual(got.Bridge, &plan) { + t.Fatalf("%+v %v", got, err) + } +} +func TestNetworkRecoveryNativeCompleteAndCancellation(t *testing.T) { + launcher := os.Getenv("BRIDGE_LAB_LAUNCHER") + if launcher == "" { + t.Skip("requires owned disposable Windows broker") + } + dir := t.TempDir() + plan := validBridgePlan() + prefs := networkPreferences{1, "bridge-lab", &plan} + if err := saveNetworkPreferences(dir, prefs); err != nil { + t.Fatal(err) + } + old := bridgeElevate + defer func() { bridgeElevate = old }() + bridgeElevate = func(string) (int, error) { return errorCancelled, nil } + if runRecoveryUI(dir, "bridge-nat") == nil { + t.Fatal("cancelled elevation succeeded") + } + got, err := loadNetworkPreferences(dir) + if err != nil || !reflect.DeepEqual(got, prefs) { + t.Fatal("cancellation changed saved mode", got, err) + } + // The elevated lab runs the real broker. Its completed journal may refer to + // a removed TAP, so no driver or adapter mutation should be needed. + bridgeElevate = func(args string) (int, error) { + err := exec.Command(launcher, strings.Fields(args)...).Run() + return 0, err + } + for i := 0; i < 2; i++ { + if err := runRecoveryUI(dir, "bridge-nat"); err != nil { + t.Fatal(err) + } + got, err = loadNetworkPreferences(dir) + if err != nil || got.Mode != "nat" || !reflect.DeepEqual(got.Bridge, &plan) { + t.Fatalf("%+v %v", got, err) + } + } +} + +func TestNetworkFailureStopsBeforeNotice(t *testing.T) { + old := bridgeFailureNotice + defer func() { bridgeFailureNotice = old; guestNetworkFailed.Store(false) }() + for _, pending := range []bool{false, true} { + t.Run(fmt.Sprint(pending), func(t *testing.T) { + proc := exec.Command(filepath.Join(bridgeSystemDirectory(), "WindowsPowerShell", "v1.0", "powershell.exe"), "-NoProfile", "-NonInteractive", "-Command", "Start-Sleep 60") + proc.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + if err := proc.Start(); err != nil { + t.Fatal(err) + } + defer proc.Process.Kill() + exited := make(chan error, 1) + reaped := make(chan struct{}) + go func() { err := proc.Wait(); close(reaped); exited <- err }() + cfg := &config{bridgeQemu: proc.Process} + plan := validBridgePlan() + cfg.bridge = &plan + left, right := net.Pipe() + defer right.Close() + qmp := &qmpConn{tcp: left, lines: bufio.NewScanner(left), done: make(chan struct{})} + defer qmp.close() + guestNetworkFailed.Store(true) + guestReady.Store(false) + cleaned := false + reported := false + cleanup := func() error { + select { + case <-reaped: + default: + t.Error("cleanup raced the owned process") + } + cleaned = true + if pending { + return fmt.Errorf("owned adapter is missing") + } + return nil + } + bridgeFailureNotice = func(message string) { + reported = true + if !cleaned { + t.Error("modal notice preceded cleanup") + } + if pending && !strings.Contains(message, "still pending") { + t.Error("failed recovery reported success") + } + if !pending && !strings.Contains(message, "recovery completed") { + t.Error("completed recovery not reported") + } + } + if watch(cfg, qmp, exited, cleanup) { + t.Fatal("failed bridge requested reboot") + } + if !cleaned || !reported { + t.Fatal("failed network skipped cleanup or notice") + } + }) + } +} diff --git a/app/recovery_dialog_windows.go b/app/recovery_dialog_windows.go index b7be3f9..2f67ab1 100644 --- a/app/recovery_dialog_windows.go +++ b/app/recovery_dialog_windows.go @@ -44,7 +44,13 @@ func chooseExecutablePath(owner uintptr) (string, bool, error) { return chooseWindowsPath(owner, "Choose a Windows app to make available in Omarchy", "", false, false, true) } +func chooseBridgePlanPath(owner uintptr) (string, bool, error) { + return chooseWindowsPathFiltered(owner, "Choose an explicit disposable bridge lab plan", "", false, false, false, true) +} func chooseWindowsPath(owner uintptr, title, filename string, save, folder, executable bool) (string, bool, error) { + return chooseWindowsPathFiltered(owner, title, filename, save, folder, executable, false) +} +func chooseWindowsPathFiltered(owner uintptr, title, filename string, save, folder, executable, bridge bool) (string, bool, error) { runtime.LockOSThread() defer runtime.UnlockOSThread() init, _, _ := ole32.NewProc("CoInitializeEx").Call(0, 2) @@ -99,6 +105,9 @@ func chooseWindowsPath(owner uintptr, title, filename string, save, folder, exec if executable { filterLabel, filterPattern, extension = "Windows apps (*.exe)", "*.exe", "exe" } + if bridge { + filterLabel, filterPattern, extension = "Bridge lab plans (*.json)", "*.json", "json" + } label, _ := syscall.UTF16PtrFromString(filterLabel) pattern, _ := syscall.UTF16PtrFromString(filterPattern) filter := struct{ label, pattern *uint16 }{label, pattern} diff --git a/app/recovery_windows.go b/app/recovery_windows.go index 7964669..e00f464 100644 --- a/app/recovery_windows.go +++ b/app/recovery_windows.go @@ -36,6 +36,22 @@ func beginRecoveryProgress(status string) { func runRecoveryUI(dir, action string) error { configureSetupCancellation(false) switch action { + case "bridge-nat": + prefs, err := loadNetworkPreferences(dir) + if err != nil { + return err + } + if prefs.Bridge != nil { + broker, err := startBridgeBroker(prefs.Bridge, "Recover") + if err != nil { + return err + } + if err = broker.Close(); err != nil { + return err + } + } + prefs.Mode = "nat" + return saveNetworkPreferences(dir, prefs) case "portable-create": parent, ok, err := chooseRecoveryPath(0, "Choose where to create the portable copy", "", false, true) if err != nil || !ok { diff --git a/app/settings_dialog_windows.go b/app/settings_dialog_windows.go index 77b6907..e0e2c4f 100644 --- a/app/settings_dialog_windows.go +++ b/app/settings_dialog_windows.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "path/filepath" + "reflect" "runtime" "strconv" "strings" @@ -71,6 +72,9 @@ const ( settingsAppRemoveID = 2124 settingsAppListID = 2125 settingsAltTabID = 2126 + settingsBridgeOnID = 2127 + settingsBridgePlanID = 2128 + settingsBridgeRecoverID = 2129 settingsSaveID = 2001 settingsCancelID = 2002 settingsBrowseID = 2003 @@ -162,6 +166,12 @@ func runLauncherSettings(path, dataDir string, portable, launcher bool, beforeRe errorBox("Cannot read launch preferences:\n\n" + err.Error()) return false } + networkPrefs, err := loadNetworkPreferences(dataDir) + if err != nil { + errorBox("Cannot read network preferences:\n\n" + err.Error()) + return false + } + originalNetwork := networkPrefs keyboardPrefs, err := loadKeyboardPreferences(dataDir) if err != nil { errorBox("Cannot read keyboard preferences:\n\n" + err.Error()) @@ -251,6 +261,7 @@ func runLauncherSettings(path, dataDir string, portable, launcher bool, beforeRe var hAudioOutput, hAudioInput uintptr var hResourceProfile, hResourceHelp uintptr var hApprovedApps uintptr + var hBridgeOn, hBridgePlan uintptr var updateResourceControls func() var refreshApprovedApps func() profileValues := []string{resourceBalanced, resourceMaximum, resourceManual} @@ -261,6 +272,7 @@ func runLauncherSettings(path, dataDir string, portable, launcher bool, beforeRe } return profileValues[index] } + var pendingRecovery string var selectPage func(int) var pages [5][]settingsScrollControl var pageHeights [5]int32 @@ -349,6 +361,7 @@ func runLauncherSettings(path, dataDir string, portable, launcher bool, beforeRe return } procAllowSetForeground.Call(uintptr(cmd.Process.Pid)) + pendingRecovery = action procEnableWindow.Call(hwnd, 0) go func() { _ = cmd.Wait(); procPostMessageW.Call(hwnd, settingsRecoveryDone, 0, 0) }() } @@ -366,6 +379,25 @@ func runLauncherSettings(path, dataDir string, portable, launcher bool, beforeRe return 0 } switch wParam & 0xffff { + case settingsBridgePlanID: + if originalNetwork.Mode == "bridge-lab" { + errorBox("Use Recover TAP / use NAT before changing adapter selection.") + return 0 + } + selected, ok, err := chooseBridgePlanPath(hwnd) + if err == nil && ok { + var plan *bridgePlan + plan, err = loadBridgePlan(selected) + if err == nil { + networkPrefs = importNetworkPlan(networkPrefs, *plan) + setText(hBridgePlan, "Plan selected. Existing guest MACs are retained.") + } + } + if err != nil { + errorBox("Cannot use bridge plan:\n\n" + err.Error()) + } + case settingsBridgeRecoverID: + launchRecovery("bridge-nat") case settingsAppAddID: path, ok, err := chooseExecutablePath(hwnd) if err != nil { @@ -422,6 +454,21 @@ func runLauncherSettings(path, dataDir string, portable, launcher bool, beforeRe } } s, err := collect() + updatedNetwork := networkPrefs + bridgeCheck, _, _ := procSendMessageW.Call(hBridgeOn, bmGetcheck, 0, 0) + updatedNetwork.Mode = "nat" + if bridgeCheck == bstChecked { + updatedNetwork.Mode = "bridge-lab" + } + if err == nil { + err = updatedNetwork.validate() + } + if err == nil && updatedNetwork.Mode == "bridge-lab" { + err = requireDualNICGuest(filepath.Join(dataDir, "guest")) + } + if err == nil && originalNetwork.Mode == "bridge-lab" && updatedNetwork.Mode == "nat" { + err = fmt.Errorf("use Recover TAP / use NAT before disabling the bridge") + } diskGiB := storage.DiskGiB if err == nil { diskGiB, err = parseDiskGiB(text(hDisk)) @@ -554,6 +601,12 @@ func runLauncherSettings(path, dataDir string, portable, launcher bool, beforeRe return 0 } } + if !reflect.DeepEqual(updatedNetwork, originalNetwork) { + if err := saveNetworkPreferences(dataDir, updatedNetwork); err != nil { + errorBox("Other settings were saved, but networking could not be saved:\n\n" + err.Error()) + return 0 + } + } saved = true procDestroyWindow.Call(h) case settingsCancelID, idCancel: @@ -605,6 +658,25 @@ func runLauncherSettings(path, dataDir string, portable, launcher bool, beforeRe } return 0 case settingsRecoveryDone: + if pendingRecovery == "bridge-nat" { + if updated, err := loadNetworkPreferences(dataDir); err == nil { + networkPrefs = updated + originalNetwork = updated + checked := uintptr(0) + if updated.Mode == "bridge-lab" { + checked = bstChecked + } + procSendMessageW.Call(hBridgeOn, bmSetcheck, checked, 0) + label := "NAT is the default. Choose a lab plan to opt in." + if updated.Bridge != nil { + label = "Saved lab plan. Guest DHCP identities are retained." + } + setText(hBridgePlan, label) + } else { + errorBox("Cannot reread network preferences after recovery: " + err.Error()) + } + } + pendingRecovery = "" if !portable { if resolved, err := prepareMovedLocation(dataDir, false); err == nil && !pathsEqual(resolved, dataDir) { if beforeRelaunch != nil { @@ -943,6 +1015,29 @@ func runLauncherSettings(path, dataDir string, portable, launcher bool, beforeRe procSendMessageW.Call(hLANPublic, bmSetcheck, bstChecked, 0) } y += 32 + hBridgeOn = mk("BUTTON", "Wired LAN bridge (disposable lab only)", left, y, 450, 24, bsAutocheckbox|wsTabstop, settingsBridgeOnID) + if networkPrefs.Mode == "bridge-lab" { + procSendMessageW.Call(hBridgeOn, bmSetcheck, bstChecked, 0) + } + if portable { + procEnableWindow.Call(hBridgeOn, 0) + } + y += 30 + label := "NAT is the default. Choose a lab plan to opt in." + if networkPrefs.Bridge != nil { + label = "Saved lab plan. Guest DHCP identities are retained." + } + hBridgePlan = mk("STATIC", label, left, y, clientW-2*left, 36, ssNoprefix, 0) + y += 40 + planButton := mk("BUTTON", "Choose lab plan...", left, y, 180, 28, wsTabstop, settingsBridgePlanID) + recoverButton := mk("BUTTON", "Recover TAP / use NAT", left+190, y, 260, 28, wsTabstop, settingsBridgeRecoverID) + if portable { + procEnableWindow.Call(planButton, 0) + procEnableWindow.Call(recoverButton, 0) + } + y += 34 + mk("STATIC", "Requires a compatible guest, manually installed signed TAP and Npcap, wired Ethernet and an independent local console. Direct LAN services follow the guest firewall. No drivers are installed here.", left, y, clientW-2*left, 66, ssNoprefix, 0) + y += 74 mk("STATIC", "SSH public key file\n(blank: your ~/.ssh/id_*.pub)", left, y+3, labelW, 40, ssNoprefix, 0) hKey = mk("EDIT", current.SSHKey, fieldX, y, fieldW, 24, wsBorder|wsTabstop|esAutohscroll, settingsKeyID) // The two-line key label above is 40 px tall from y+3; start the next diff --git a/app/setup_artifacts.go b/app/setup_artifacts.go index 1ff818a..4c11753 100644 --- a/app/setup_artifacts.go +++ b/app/setup_artifacts.go @@ -10,9 +10,10 @@ var ( // buildSpec holds the fields the launcher reads from the guest's build-spec.json. type buildSpec struct { Runtime struct { - KernelCommandLine string `json:"kernelCommandLine"` - OptionalDevices []string `json:"optionalDevices"` - Storage struct { + KernelCommandLine string `json:"kernelCommandLine"` + OptionalDevices []string `json:"optionalDevices"` + NetworkCapabilities []string `json:"networkCapabilities"` + Storage struct { ExpandedSizeMiB int64 `json:"expandedSizeMiB"` } `json:"storage"` } `json:"runtime"` diff --git a/docs/LAN-BRIDGE.md b/docs/LAN-BRIDGE.md index 776b410..cdd079a 100644 --- a/docs/LAN-BRIDGE.md +++ b/docs/LAN-BRIDGE.md @@ -3,7 +3,8 @@ [#166](https://github.com/omacom/try-omarchy-windows/issues/166) remains feature work. NAT and existing port forwarding are the launcher default. The helpers in `scripts/network` prepare and recover a bridge in a disposable Windows lab; -they do not add a launcher network preference or automatically install drivers. +an experimental Settings preference imports an explicit lab plan. Drivers are +installed separately by an administrator, never automatically. `BridgeAccepted` remains false, including after successful host probes. ## Signed adapter path @@ -246,23 +247,44 @@ Hardware-specific offload behavior, fragmented host traffic, IPv6 routing/fragme headers, throughput, VLAN wire behavior, sleep and broader adapter/version coverage remain unaccepted. Unsupported captured host packets stop the helper rather than changing the NIC's offload configuration. Physical wired Ethernet, -Secure Boot/HVCI, Windows 10, administrator cancellation and normal guest +Secure Boot/HVCI, Windows 10, physical administrator interaction and complete Windows guest-image integration still need validation. `BridgeAccepted` remains false. NAT and existing forwarding remain the launcher defaults. -## 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 -traffic while preserving the private service route, saved port forwards, -clipboard, clock, Hello, files and approved app launching. Do not expose launcher -services on LAN to make bridging work. Persist distinct guest MACs per -installation and verify them across relaunch. - -The normal setting also needs visible administrator cancellation, driver setup, -owned-device cleanup and adapter-loss handling. Never silently rebind to Wi-Fi, -a different Ethernet adapter or another TAP. Failed enable must preserve the -saved NAT/forwarding configuration and verify host recovery before offering NAT. +## Guest routing and Settings + +A compatible candidate declares `runtime.networkCapabilities: ["dual-nic-v1"]`. +On bridge boots, the launcher passes the two installation-local MACs to the guest. +Before NetworkManager starts, the guest generates mode-600 profiles in +`/run/NetworkManager/system-connections`, matched by those MACs. LAN supplies DHCP, +the default route and DNS. The private NIC keeps its connected `10.0.2.0/24` +route, rejects DHCP default routes and DNS, and disables private IPv6. +Saved guest profiles are preserved. A NAT boot removes only these owned runtime +profiles. Compatibility revision 43 delivers the helper and unit drop-in to +persistent disks without changing their saved network profiles. + +Guest readiness requires both active profiles, LAN IPv4/default route/DNS, +a private route to `10.0.2.2`, and no overlapping LAN subnet. The Windows +preflight also rejects overlapping wired subnets before TAP changes. Failed +readiness stops the owned VM and completes TAP recovery before showing an error. +If the private channel cannot report failure, the supervisor stops an unready +bridge after 300 ticks with a QMP-confirmed running VM. Manual pause and host +sleep do not consume that startup budget or trigger a resume. Image updates are committed only after guest readiness. + +In **Settings > Advanced**, choose an explicit lab plan and select **Wired LAN +bridge (disposable lab only)**. Saving does not install a driver or change +bindings. Starting the VM requests broker elevation and runs the normal preflight. +Images without the declared capability cannot enable this preference. +**Recover TAP / use NAT** completes recovery before saving NAT; cancellation or +failed recovery retains the prior mode. Recover first before changing adapter +selection. A completed journal remains recoverable after its owned TAP is gone. + +`network-preferences.json` retains the MACs and exact adapter identities across +relaunches and same-installation moves. Guest backups and portable copies omit +this host-bound file, so they default to NAT. Existing private integrations and +port forwards stay on the private NIC. Direct LAN exposure follows the guest +firewall. Normal Windows guest image integration and physical wired acceptance +remain separate checks; this option is still restricted to disposable labs. ## Acceptance gates @@ -279,7 +301,7 @@ saved NAT/forwarding configuration and verify host recovery before offering NAT. ## 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. +Windows lab. The CLI and Settings options remain restricted to disposable labs. 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. @@ -289,7 +311,7 @@ Save an installation-local JSON plan with `version: 1`, the exact `wiredGuid`, `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 +Older fixture images 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. diff --git a/docs/MAC-PARITY.md b/docs/MAC-PARITY.md index 091fd9b..34a319c 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/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 | +| 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; candidate boot profiles and lab Settings opt-in are implemented | [True LAN bridge #166](https://github.com/omacom/try-omarchy-windows/issues/166): complete Windows guest-image integration, 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/docs/NEXT-SESSION.md b/docs/NEXT-SESSION.md index feaa299..dbd042f 100644 --- a/docs/NEXT-SESSION.md +++ b/docs/NEXT-SESSION.md @@ -11,7 +11,7 @@ The record also notes one clean guest poweroff during an early candidate boot. I Thousands of users and relatively few bug reports are a useful positive signal for the everyday experience. Do not use broad Windows 10 or hardware coverage as an automatic gate for future 0.x releases. Fix reproducible reports as they arrive. Keep the realistic feature gaps active: - [Windows Hello sudo #165](https://github.com/omacom/try-omarchy-windows/issues/165): shipped in v0.5.0 as an opt-in; see the [design](WINDOWS-HELLO.md) and the [laptop run](evidence/HELLO-SUDO-LAPTOP-2026-09-26.md). The 1Password unlock in [#176](https://github.com/omacom/try-omarchy-windows/issues/176) builds on it and shipped in v0.6.0. -- [True LAN bridge #166](https://github.com/omacom/try-omarchy-windows/issues/166): [signed TAP lab helpers](LAN-BRIDGE.md) now cover reversible wired setup and controlled guest DHCP/TCP. Ethernet identity, physical acceptance and launcher integration remain open. NAT and explicit LAN port forwarding work today. The laptop's only active connection is Wi-Fi, so changing its adapter bindings remotely is unsuitable. +- [True LAN bridge #166](https://github.com/omacom/try-omarchy-windows/issues/166): [signed TAP lab helpers](LAN-BRIDGE.md) now cover reversible wired setup and controlled guest DHCP/TCP. The Npcap path preserves guest identity, and draft launcher ownership, guest boot routing and lab Settings now exist. Complete Windows guest-image integration and physical Ethernet acceptance remain open. NAT and explicit LAN port forwarding work today. The laptop's only active connection is Wi-Fi, so changing its adapter bindings remotely is unsuitable. - [Live audio switching #167](https://github.com/omacom/try-omarchy-windows/issues/167): shipped in `v0.3.0`. Host Settings and the Omarchy audio picker switch Windows playback and recording devices while the VM runs; saved choices persist across guest reboots, and microphone access changes apply at the next VM start. Only two physical endpoints per direction and hotplug acceptance remain. See the [signed and public acceptance record](evidence/V030-SIGNED-CANDIDATE-2026-09-24.md). - [Embedded Windows app windows #160](https://github.com/omacom/try-omarchy-windows/issues/160): phase 1 launch-and-return ships in v0.2.0. Window capture, input, focus, scaling and lifecycle are a separate milestone. - [Direct application drops #174](https://github.com/omacom/try-omarchy-windows/issues/174) shipped in v0.6.0: a drop lands in the app under the pointer and still saves to Downloads. [Intel/NVIDIA Venus #173](https://github.com/omacom/try-omarchy-windows/issues/173) tracks a concrete older-runtime Vulkan/Godot failure without switching the owner's PC away from Omarchy. [ARM64 #131](https://github.com/omacom/try-omarchy-windows/issues/131), [interface translation #127](https://github.com/omacom/try-omarchy-windows/issues/127), and USB device reports remain open or documented. [PR #184](https://github.com/omacom/try-omarchy-windows/pull/184) brought the pinch touchpad rules to existing guests, and physical pinch passed on the migrated laptop guest. The launcher turns pinch on for guest images that declare the device (patch `0095`), which v0.4.0 ships. diff --git a/guest-build/0109-Configure-boot-only-dual-NIC-routing.patch b/guest-build/0109-Configure-boot-only-dual-NIC-routing.patch new file mode 100644 index 0000000..8525334 --- /dev/null +++ b/guest-build/0109-Configure-boot-only-dual-NIC-routing.patch @@ -0,0 +1,362 @@ +From 4159f6d37d4e531ff6f77a5bf4f0b3b20b0f6edc Mon Sep 17 00:00:00 2001 +From: Tyler South +Date: Tue, 29 Sep 2026 23:56:06 -0400 +Subject: [PATCH] Configure boot-only MAC-bound LAN and private guest networks + +--- + .../try-omarchy-network.conf | 2 + + .../systemd/system/try-omarchy-ready.service | 1 + + .../usr/local/lib/try-omarchy/guest-ready | 19 +++ + .../local/lib/try-omarchy/network-profiles | 132 ++++++++++++++++++ + guest/scripts/finalize-rootfs.sh | 5 +- + guest/spec.json | 3 + + guest/tests/test_network_profiles.py | 82 +++++++++++ + guest/tests/verify.py | 9 +- + 8 files changed, 251 insertions(+), 2 deletions(-) + create mode 100644 guest/factory-overlay/etc/systemd/system/NetworkManager.service.d/try-omarchy-network.conf + create mode 100755 guest/factory-overlay/usr/local/lib/try-omarchy/network-profiles + create mode 100644 guest/tests/test_network_profiles.py + +diff --git a/guest/factory-overlay/etc/systemd/system/NetworkManager.service.d/try-omarchy-network.conf b/guest/factory-overlay/etc/systemd/system/NetworkManager.service.d/try-omarchy-network.conf +new file mode 100644 +index 0000000..405fbe0 +--- /dev/null ++++ b/guest/factory-overlay/etc/systemd/system/NetworkManager.service.d/try-omarchy-network.conf +@@ -0,0 +1,2 @@ ++[Service] ++ExecStartPre=/usr/local/lib/try-omarchy/network-profiles +diff --git a/guest/factory-overlay/etc/systemd/system/try-omarchy-ready.service b/guest/factory-overlay/etc/systemd/system/try-omarchy-ready.service +index 0b51244..e50ff09 100644 +--- a/guest/factory-overlay/etc/systemd/system/try-omarchy-ready.service ++++ b/guest/factory-overlay/etc/systemd/system/try-omarchy-ready.service +@@ -4,6 +4,7 @@ After=NetworkManager.service + + [Service] + Type=oneshot ++TimeoutStartSec=20min + ExecStart=/usr/local/lib/try-omarchy/guest-ready + + [Install] +diff --git a/guest/factory-overlay/usr/local/lib/try-omarchy/guest-ready b/guest/factory-overlay/usr/local/lib/try-omarchy/guest-ready +index 5c5fc83..409eb82 100755 +--- a/guest/factory-overlay/usr/local/lib/try-omarchy/guest-ready ++++ b/guest/factory-overlay/usr/local/lib/try-omarchy/guest-ready +@@ -9,6 +9,25 @@ marker=$(cat /usr/share/try-omarchy/compat-version 2>/dev/null || true) + [[ -f /usr/lib/modules/$kernel/modules.dep.bin ]] || exit 0 + complete=$(cat /usr/lib/modules/$kernel/.tryomarchy-complete 2>/dev/null || true) + [[ $complete == "$marker" ]] || exit 0 ++# The bridge boot is ready only when both MAC-bound profiles are active. ++# A failed DHCP lease or overlapping LAN must never commit an image update. ++if [[ " $(cat /proc/cmdline) " == *" tryomarchy.network="* ]]; then ++ network_ready=false ++ for _ in {1..90}; do ++ if timeout 10 /usr/local/lib/try-omarchy/network-profiles --check; then ++ network_ready=true ++ break ++ fi ++ sleep 2 ++ done ++ if ! $network_ready; then ++ for _ in {1..10}; do ++ printf 'bridge-failed\n' | socat -u - TCP:10.0.2.2:4450,connect-timeout=2 2>/dev/null && break ++ sleep 1 ++ done ++ exit 1 ++ fi ++fi + for _ in {1..10}; do + if printf 'ready\n' | socat -u - TCP:10.0.2.2:4450,connect-timeout=2 2>/dev/null; then + exit 0 +diff --git a/guest/factory-overlay/usr/local/lib/try-omarchy/network-profiles b/guest/factory-overlay/usr/local/lib/try-omarchy/network-profiles +new file mode 100755 +index 0000000..a9c8962 +--- /dev/null ++++ b/guest/factory-overlay/usr/local/lib/try-omarchy/network-profiles +@@ -0,0 +1,132 @@ ++#!/usr/bin/python3 ++"""Boot-only NetworkManager profiles for the Windows dual-NIC option.""" ++import argparse ++import ipaddress ++import json ++import os ++from pathlib import Path ++import re ++import stat ++import subprocess ++import tempfile ++import uuid ++ ++MARKER = '# Try Omarchy boot network v1\n' ++NAMES = ('tryomarchy-lan.nmconnection', 'tryomarchy-private.nmconnection') ++ ++ ++def selection(command_line): ++ values = {} ++ keys = {'tryomarchy.network', 'tryomarchy.lan_mac', 'tryomarchy.private_mac'} ++ for word in command_line.split(): ++ key, sep, value = word.partition('=') ++ if key in keys: ++ if key in values or not sep: ++ raise ValueError('Repeated or incomplete network parameter') ++ values[key] = value.lower() ++ if not values: ++ return None ++ if values.get('tryomarchy.network') != 'dual-nic-v1' or len(values) != 3: ++ raise ValueError('Incomplete or unsupported dual-NIC request') ++ macs = [values[k] for k in ('tryomarchy.lan_mac', 'tryomarchy.private_mac')] ++ for mac in macs: ++ if not re.fullmatch(r'[0-9a-f]{2}(:[0-9a-f]{2}){5}', mac) or int(mac[:2], 16) & 3 != 2: ++ raise ValueError('Locally administered unicast MAC required') ++ if macs[0] == macs[1]: ++ raise ValueError('Distinct NIC identities required') ++ return macs ++ ++ ++def profile(mac, private): ++ role = 'private' if private else 'lan' ++ uid = uuid.uuid5(uuid.NAMESPACE_OID, 'tryomarchy-dual-nic-v1:' + role + ':' + mac) ++ text = MARKER + f'''[connection] ++id=Try Omarchy {role} ++uuid={uid} ++type=ethernet ++autoconnect=true ++autoconnect-priority=999 ++ ++[ethernet] ++mac-address={mac} ++cloned-mac-address=preserve ++ ++[ipv4] ++method=auto ++dhcp-client-id=mac ++may-fail=false ++''' ++ if private: ++ text += 'never-default=true\nignore-auto-routes=true\nignore-auto-dns=true\n\n[ipv6]\nmethod=disabled\n' ++ else: ++ text += 'route-metric=100\n\n[ipv6]\nmethod=auto\nmay-fail=true\n' ++ return text ++ ++ ++def generate(directory, macs): ++ directory.mkdir(parents=True, exist_ok=True, mode=0o700) ++ if directory.is_symlink() or not directory.is_dir(): ++ raise ValueError('Profile directory must be a real directory') ++ # Inspect both destinations before changing either. Never replace user files. ++ for name in NAMES: ++ path = directory / name ++ if path.exists() or path.is_symlink(): ++ info = path.lstat() ++ if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or not path.read_text().startswith(MARKER): ++ raise ValueError('Unowned runtime profile: ' + name) ++ for i, name in enumerate(NAMES): ++ path = directory / name ++ if macs is None: ++ path.unlink(missing_ok=True) ++ continue ++ fd, temp = tempfile.mkstemp(prefix='.tryomarchy-', dir=directory) ++ try: ++ with os.fdopen(fd, 'w') as out: ++ out.write(profile(macs[i], i == 1)); out.flush(); os.fsync(out.fileno()) ++ os.replace(temp, path) ++ finally: ++ Path(temp).unlink(missing_ok=True) ++ ++ ++def healthy(macs, runner=subprocess.check_output, sysnet=Path("/sys/class/net")): ++ def run(*args): ++ return runner(args, text=True, timeout=5).strip() ++ devices = {} ++ for entry in sysnet.iterdir(): ++ try: ++ devices[(entry / 'address').read_text().strip().lower()] = entry.name ++ except OSError: ++ pass ++ if any(mac not in devices for mac in macs): ++ return False ++ lan, private = [devices[mac] for mac in macs] ++ for dev, role in ((lan, 'lan'), (private, 'private')): ++ if run('nmcli', '-g', 'GENERAL.CONNECTION', 'device', 'show', dev) != 'Try Omarchy ' + role: ++ return False ++ addresses = json.loads(run('ip', '-j', '-4', 'address', 'show', 'dev', lan)) ++ networks = [ipaddress.ip_network(f"{a['local']}/{a['prefixlen']}", strict=False) ++ for row in addresses for a in row.get('addr_info', []) if a.get('family') == 'inet'] ++ if not networks or any(net.overlaps(ipaddress.ip_network('10.0.2.0/24')) for net in networks): ++ return False ++ defaults = json.loads(run('ip', '-j', '-4', 'route', 'show', 'default')) ++ if not defaults or any(row.get('dev') != lan for row in defaults): ++ return False ++ host_route = json.loads(run('ip', '-j', '-4', 'route', 'get', '10.0.2.2')) ++ if not host_route or host_route[0].get('dev') != private: ++ return False ++ return bool(run('nmcli', '-g', 'IP4.DNS', 'device', 'show', lan)) and not run('nmcli', '-g', 'IP4.DNS', 'device', 'show', private) ++ ++ ++if __name__ == '__main__': ++ parser = argparse.ArgumentParser() ++ parser.add_argument('--check', action='store_true') ++ parser.add_argument('--cmdline', default='/proc/cmdline') ++ parser.add_argument('--directory', default='/run/NetworkManager/system-connections') ++ args = parser.parse_args() ++ try: ++ macs = selection(Path(args.cmdline).read_text()) ++ if args.check: ++ raise SystemExit(0 if macs is None or healthy(macs) else 1) ++ generate(Path(args.directory), macs) ++ except (OSError, ValueError, subprocess.SubprocessError) as error: ++ parser.exit(1, 'Try Omarchy networking: ' + str(error) + '\n') +diff --git a/guest/scripts/finalize-rootfs.sh b/guest/scripts/finalize-rootfs.sh +index cdbfe12..0936a00 100755 +--- a/guest/scripts/finalize-rootfs.sh ++++ b/guest/scripts/finalize-rootfs.sh +@@ -160,6 +160,9 @@ compat_paths=( + usr/local/lib/try-omarchy/catch-up + usr/share/try-omarchy/quick-start-sshd.conf + usr/local/lib/try-omarchy/guest-ready ++ usr/local/lib/try-omarchy/network-profiles ++ etc/systemd/system/NetworkManager.service.d/try-omarchy-network.conf ++ etc/systemd/system/try-omarchy-ready.service + usr/local/lib/try-omarchy/request-sshd + usr/local/lib/try-omarchy/share-link + usr/local/lib/try-omarchy/show-factory-update-notice +@@ -204,7 +207,7 @@ tar -C / -cf /usr/share/try-omarchy/compat-overlay.tar "${compat_paths[@]}" + # Revision 41 turns off system service watchdogs, which fire after Windows + # sleeps and restart logind under the running desktop. + # Revision 42 makes SSH accept only keys for the quick-start account. +-compat_revision=42 ++compat_revision=43 + printf '%s:%s\n' "$compat_revision" "$kernel_release" >/usr/share/try-omarchy/compat-version + + # Fail the build if DKMS or networking modules were not packaged for this kernel. +diff --git a/guest/spec.json b/guest/spec.json +index 7544f61..f8d0249 100644 +--- a/guest/spec.json ++++ b/guest/spec.json +@@ -221,6 +221,9 @@ + ], + "optionalDevices": [ + "virtio-pinch-pci" ++ ], ++ "networkCapabilities": [ ++ "dual-nic-v1" + ] + } + } +diff --git a/guest/tests/test_network_profiles.py b/guest/tests/test_network_profiles.py +new file mode 100644 +index 0000000..9c72f17 +--- /dev/null ++++ b/guest/tests/test_network_profiles.py +@@ -0,0 +1,82 @@ ++import json ++import importlib.machinery ++import importlib.util ++from pathlib import Path ++import tempfile ++import unittest ++ ++SCRIPT = Path(__file__).resolve().parents[1] / 'factory-overlay/usr/local/lib/try-omarchy/network-profiles' ++spec = importlib.util.spec_from_loader('network_profiles', importlib.machinery.SourceFileLoader('network_profiles', str(SCRIPT))) ++mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod) ++REQUEST = 'tryomarchy.network=dual-nic-v1 tryomarchy.lan_mac=52:54:00:16:66:01 tryomarchy.private_mac=52:54:00:16:66:02' ++ ++class Profiles(unittest.TestCase): ++ def test_request_rejects_ambiguous_or_unsafe_identity(self): ++ self.assertIsNone(mod.selection('root=/dev/vda rw')) ++ for value in [REQUEST+' tryomarchy.network=dual-nic-v1', REQUEST.replace('dual-nic-v1','bridge'), REQUEST.rsplit(' ',1)[0], REQUEST.replace('52:54:00:16:66:02','52:54:00:16:66:01'), REQUEST.replace('52:54','53:54'), REQUEST.replace('52:54','00:54')]: ++ with self.subTest(value=value), self.assertRaises(ValueError): mod.selection(value) ++ ++ def test_repeated_bridge_and_nat_boots_preserve_user_profiles(self): ++ with tempfile.TemporaryDirectory() as root: ++ directory=Path(root); user=directory/'custom.nmconnection';user.write_text('my static settings') ++ macs=mod.selection(REQUEST);mod.generate(directory,macs) ++ first=[(directory/name).read_bytes() for name in mod.NAMES] ++ self.assertIn(b'never-default=true',first[1]);self.assertIn(b'ignore-auto-dns=true',first[1]);self.assertNotIn(b'never-default=true',first[0]) ++ self.assertEqual((directory/mod.NAMES[0]).stat().st_mode & 0o777,0o600) ++ mod.generate(directory,macs);self.assertEqual(first,[(directory/name).read_bytes() for name in mod.NAMES]) ++ mod.generate(directory,None);mod.generate(directory,None) ++ self.assertFalse(any((directory/name).exists() for name in mod.NAMES));self.assertEqual(user.read_text(),'my static settings') ++ ++ def test_foreign_destination_or_symlink_is_not_overwritten(self): ++ for symlink in (False, True): ++ with tempfile.TemporaryDirectory() as root: ++ directory=Path(root);target=directory/mod.NAMES[1] ++ if symlink: target.symlink_to(directory/'missing') ++ else: target.write_text('user profile') ++ with self.assertRaises(ValueError):mod.generate(directory,mod.selection(REQUEST)) ++ self.assertFalse((directory/mod.NAMES[0]).exists()) ++ self.assertTrue(target.is_symlink() if symlink else target.read_text()=='user profile') ++ ++class HealthTests(unittest.TestCase): ++ def setUp(self): ++ self.temp = tempfile.TemporaryDirectory() ++ self.sysnet = Path(self.temp.name) ++ self.macs = ['52:54:00:16:66:01', '52:54:00:16:66:02'] ++ for dev, mac in zip(('eth0', 'eth1'), self.macs): ++ (self.sysnet / dev).mkdir() ++ (self.sysnet / dev / 'address').write_text(mac) ++ self.lan = '192.0.2.48' ++ self.prefix = 24 ++ self.default = 'eth0' ++ self.host = 'eth1' ++ self.private_dns = '' ++ self.lan_dns = '192.0.2.1' ++ self.role = 'lan' ++ ++ def tearDown(self): ++ self.temp.cleanup() ++ ++ def run_command(self, args, **kwargs): ++ if args[0] == 'ip': ++ if 'address' in args: ++ return json.dumps([{'addr_info':[{'family':'inet', 'local':self.lan, 'prefixlen':self.prefix}]}]) ++ if 'default' in args: ++ return json.dumps([] if not self.default else [{'dev':self.default}]) ++ return json.dumps([{'dev':self.host}]) ++ if args[2] == 'GENERAL.CONNECTION': ++ return 'Try Omarchy ' + (self.role if args[-1] == 'eth0' else 'private') ++ return self.lan_dns if args[-1] == 'eth0' else self.private_dns ++ ++ def test_healthy_routes_and_dns(self): ++ self.assertTrue(mod.healthy(self.macs, self.run_command, self.sysnet)) ++ ++ def test_failed_routing_and_lease(self): ++ for field, value in [('default', 'eth1'), ('default', ''), ('host', 'eth0'), ++ ('private_dns', '10.0.2.3'), ('lan_dns', ''), ++ ('lan', '10.0.2.80'), ('prefix', 0), ('role', 'User profile')]: ++ with self.subTest(field=field, value=value): ++ previous = getattr(self, field) ++ setattr(self, field, value) ++ self.assertFalse(mod.healthy(self.macs, self.run_command, self.sysnet)) ++ setattr(self, field, previous) ++ self.assertFalse(mod.healthy([self.macs[0], '52:54:00:00:00:09'], self.run_command, self.sysnet)) +diff --git a/guest/tests/verify.py b/guest/tests/verify.py +index e72e55b..248ce30 100755 +--- a/guest/tests/verify.py ++++ b/guest/tests/verify.py +@@ -293,7 +293,7 @@ def main() -> None: + "system services run without watchdogs, which fire after Windows sleeps", + ) + check( +- "compat_revision=42" in finalize_rootfs ++ "compat_revision=43" in finalize_rootfs + and "usr/share/try-omarchy/quick-start-sshd.conf" in compat_paths + and "etc/systemd/system/service.d/10-try-omarchy-watchdog.conf" in compat_paths + and "usr/local/lib/try-omarchy/catch-up" in compat_paths +@@ -400,6 +400,13 @@ def main() -> None: + ) + + ready_unit = read(GUEST / "factory-overlay/etc/systemd/system/try-omarchy-ready.service") ++ check(spec["runtime"].get("networkCapabilities") == ["dual-nic-v1"], "guest declares automatic dual-NIC routing") ++ network = GUEST / "factory-overlay/usr/local/lib/try-omarchy/network-profiles" ++ py_compile.compile(str(network), doraise=True) ++ check(os.access(network, os.X_OK), "boot network generator is executable") ++ network_dropin = "etc/systemd/system/NetworkManager.service.d/try-omarchy-network.conf" ++ check("ExecStartPre=/usr/local/lib/try-omarchy/network-profiles" in read(GUEST / "factory-overlay" / network_dropin), "profiles exist before NetworkManager activates adapters") ++ check(network_dropin in compat_paths and "usr/local/lib/try-omarchy/network-profiles" in compat_paths, "dual-NIC profiles migrate onto persistent disks") + ready_script = GUEST / "factory-overlay/usr/local/lib/try-omarchy/guest-ready" + initcpio_config = read(GUEST / "factory-overlay/etc/mkinitcpio.conf.d/90-try-omarchy.conf") + compat_install = GUEST / "factory-overlay/etc/initcpio/install/tryomarchy_compat" +-- +2.43.0 + diff --git a/scripts/network/bridge-preflight.psm1 b/scripts/network/bridge-preflight.psm1 index be97281..1f2b60c 100644 --- a/scripts/network/bridge-preflight.psm1 +++ b/scripts/network/bridge-preflight.psm1 @@ -20,6 +20,25 @@ function Get-BridgeGuidsFromListing { $guids } +function Test-BridgePrivateSubnetConflict { + param($Addresses) + # Use subnet ranges, not a 10.0.2 string prefix. A /8 also overlaps. + $privateStart=[uint64]167772672 + $privateEnd=$privateStart+255 + foreach ($entry in $Addresses) { + $ip=[Net.IPAddress]::Parse([string]$entry.IPAddress) + if ($ip.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork) { continue } + $prefix=[int]$entry.PrefixLength + if ($prefix -lt 0 -or $prefix -gt 32) { throw 'Invalid wired prefix.' } + $b=$ip.GetAddressBytes() + $number=([uint64]$b[0] -shl 24)+([uint64]$b[1] -shl 16)+([uint64]$b[2] -shl 8)+[uint64]$b[3] + $size=[uint64]1 -shl (32-$prefix) + $start=$number-($number % $size) + if ($start -le $privateEnd -and ($start+$size-1) -ge $privateStart) { return $true } + } + return $false +} + function Test-BridgeBinding { param([string[]]$EnabledComponents) # Current Windows members use the multiplexor protocol; ms_bridge is on @@ -149,4 +168,4 @@ function Get-BridgeHostSnapshot { } } -Export-ModuleMember -Function Test-BridgeDriverPackage, Get-BridgeLabAssessment, Get-BridgeHostSnapshot, Get-BridgeGuidsFromListing, Test-BridgeBinding +Export-ModuleMember -Function Test-BridgePrivateSubnetConflict, Test-BridgeDriverPackage, Get-BridgeLabAssessment, Get-BridgeHostSnapshot, Get-BridgeGuidsFromListing, Test-BridgeBinding diff --git a/scripts/network/launcher-bridge-transaction.psm1 b/scripts/network/launcher-bridge-transaction.psm1 index 6ccfc85..d8105fb 100644 --- a/scripts/network/launcher-bridge-transaction.psm1 +++ b/scripts/network/launcher-bridge-transaction.psm1 @@ -8,6 +8,17 @@ function Read-LauncherBridgeJournal($Path) { 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 Get-LauncherBridgeRecovery($Backend,$Path,$Request) { + if (-not (Test-Path -LiteralPath $Path)) { return $null } + $j=Read-LauncherBridgeJournal $Path + & $Backend.ValidateMachine $j + if ($j.Phase -ne 'Complete') { + foreach ($field in 'tapGuid','tapPnp','wiredGuid','wiredPnp') { + if ($j.Request.$field -ne $Request.$field) { throw 'Use the exact saved plan for recovery.' } + } + } + return $j +} function Restore-LauncherBridge($Backend,$Path) { $j = Read-LauncherBridgeJournal $Path & $Backend.ValidateMachine $j @@ -61,4 +72,4 @@ function Set-LauncherBridgeRunning($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 +Export-ModuleMember -Function Get-LauncherBridgeRecovery, Start-LauncherBridge, Restore-LauncherBridge, Set-LauncherBridgeRunning diff --git a/scripts/network/launcher-bridge.ps1 b/scripts/network/launcher-bridge.ps1 index 0b5b30d..4d5673d 100644 --- a/scripts/network/launcher-bridge.ps1 +++ b/scripts/network/launcher-bridge.ps1 @@ -37,6 +37,7 @@ function Assert-Selection($r,[switch]$Opened) { $wired=Get-Exact $r.wiredGuid $r.wiredPnp; $tap=Get-Exact $r.tapGuid $r.tapPnp & $native { param($a) Assert-InstalledTapDriver $a } $tap Assert-Npcap + if (Test-BridgePrivateSubnetConflict @(Get-NetIPAddress -InterfaceIndex $wired.ifIndex -AddressFamily IPv4)) { throw 'Wired LAN overlaps the private 10.0.2.0/24 service network.' } 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) }) @@ -98,14 +99,13 @@ 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.' } } + $previous=Get-LauncherBridgeRecovery $backend $journal $p } $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 } + if ($Request.Action -eq 'Recover') { if ($null -ne $previous) { 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 diff --git a/scripts/network/test-bridge-preflight.ps1 b/scripts/network/test-bridge-preflight.ps1 index 34580bc..b931fb9 100644 --- a/scripts/network/test-bridge-preflight.ps1 +++ b/scripts/network/test-bridge-preflight.ps1 @@ -73,3 +73,9 @@ try { if ($bad.Valid -or @($bad.Problems | Where-Object { $_ -like 'hash:*' }).Count -ne 4) { throw 'Substituted package accepted' };$passed++ } finally { Remove-Item -LiteralPath $temp -Recurse -Force } Write-Output "$passed bridge preflight checks passed" + +foreach ($case in @(@('192.0.2.26',24,$false),@('10.0.1.2',24,$false),@('10.0.3.2',24,$false),@('10.0.2.80',24,$true),@('10.99.1.2',8,$true),@('192.0.2.26',0,$true),@('10.0.2.2',32,$true),@('10.0.2.255',32,$true),@('10.0.3.0',32,$false))) { + $overlap=Test-BridgePrivateSubnetConflict @([pscustomobject]@{IPAddress=$case[0];PrefixLength=$case[1]}) + if ($overlap -ne $case[2]) { throw 'Private service subnet overlap missed.' } +} +Write-Host '9 private subnet checks passed' diff --git a/scripts/network/test-launcher-bridge.ps1 b/scripts/network/test-launcher-bridge.ps1 index e4b2c0d..c42429b 100644 --- a/scripts/network/test-launcher-bridge.ps1 +++ b/scripts/network/test-launcher-bridge.ps1 @@ -37,5 +37,16 @@ try { 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' + Reset;Assert ($null -eq (Get-LauncherBridgeRecovery $backend $path @{})) 'missing journal cannot return to NAT' + $request=@{tapGuid='tap';tapPnp='owned';wiredGuid='wired';wiredPnp='wired-pnp'} + Start-LauncherBridge $backend $path $request|Out-Null + $wrong=@{tapGuid='tap';tapPnp='replacement';wiredGuid='wired';wiredPnp='wired-pnp'} + Throws {Get-LauncherBridgeRecovery $backend $path $wrong};Assert ((Phase) -eq 'Ready') 'recovery selection changed pending journal' + $before=$state.Events.Count;$j=Get-LauncherBridgeRecovery $backend $path $request + Assert ($j.Phase -eq 'Ready' -and $state.Events.Count -eq $before) 'recovery preflight mutated' + Restore-LauncherBridge $backend $path|Out-Null + Assert ((Get-LauncherBridgeRecovery $backend $path $wrong).Phase -eq 'Complete') 'completed journal required missing adapter' + $j=Get-Content $path -Raw|ConvertFrom-Json;$j.Before.MachineGuid='foreign';$j|ConvertTo-Json -Depth 10|Set-Content $path + Throws {Get-LauncherBridgeRecovery $backend $path $request} Write-Host "$count launcher bridge transaction checks passed" } finally {Remove-Item $dir -Recurse -Force}