From 9460d64075b2cd25b8cd2f1db05e8ccf714a2d9c Mon Sep 17 00:00:00 2001 From: Karim Masmoudi <125830216+Kernel-Hunter@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:19:14 +0100 Subject: [PATCH 01/10] Give the network check a time limit NetworkCheck.run ran its container with no timeout, so a stuck runtime left `mudroom network check` waiting with nothing on screen. It now gets two minutes, like NetworkProbe: on timeout the container is removed and the check fails with a message. The script's DNS lookup also gives up after 5 seconds, so every probe in it is bounded on its own. --- Sources/MudroomCore/VMTasks.swift | 24 ++++++++++++++++--- .../MudroomCoreTests/NetworkProbeTests.swift | 14 +++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/Sources/MudroomCore/VMTasks.swift b/Sources/MudroomCore/VMTasks.swift index 8e2f5e7..c878d46 100644 --- a/Sources/MudroomCore/VMTasks.swift +++ b/Sources/MudroomCore/VMTasks.swift @@ -97,7 +97,12 @@ public enum NetworkCheck { try { const res = await fetch(url, {signal: AbortSignal.timeout(12000), redirect: 'manual'}); return [`HTTP ${res.status}`, true]; } catch (e) { return [String((e.cause && (e.cause.code || e.cause.message)) || e.message), false]; } } - function lookup(h) { return new Promise(r => dns.lookup(h, (e, a) => r(e ? [e.code, false] : [a, true]))); } + function lookup(h) { + return new Promise(r => { + const t = setTimeout(() => r(['timeout', false]), 5000); + dns.lookup(h, (e, a) => { clearTimeout(t); r(e ? [e.code, false] : [a, true]); }); + }); + } function udpDNS(ip) { return new Promise(r => { const s = dgram.createSocket('udp4'); const t = setTimeout(() => { s.close(); r(['no reply', false]); }, 4000); @@ -145,9 +150,14 @@ public enum NetworkCheck { } } + /// How long the check's container may take. Each probe in the script + /// gives up on its own within seconds (about a minute in all), so this + /// only trips when the runtime itself is stuck. + public static let timeout: TimeInterval = 120 + public static func run(mode: NetworkMode, allowlist: Allowlist, backend: SandboxBackend, image: String = AgentBaseImage.tag, blocked: String = "example.com", - scratch: URL) throws -> Report { + scratch: URL, timeout: TimeInterval = NetworkCheck.timeout) throws -> Report { try backend.checkAvailable() let allowed = allowlist.patterns.first { !$0.isWildcard }?.value ?? "api.anthropic.com" let list = mode == .locked && allowlist.patterns.isEmpty ? Allowlist(strings: [allowed]) : allowlist @@ -159,11 +169,19 @@ public enum NetworkCheck { let gateway = [net.plan.proxyHost, (try? backend.hostOnlyNetwork())?.map(\.gateway), (try? backend.defaultNetwork())?.map(\.gateway)] .compactMap { $0 }.first { !$0.isEmpty } - let spec = SandboxSpec(name: "mudroom-check-\(UInt16.random(in: 0...0xffff))", image: image, workspace: scratch, + var spec = SandboxSpec(name: "mudroom-check-\(UInt16.random(in: 0...0xffff))", image: image, workspace: scratch, command: ["node", "-e", script(allowed: allowed, blocked: blocked, gateway: mode == .open ? nil : gateway)], interactive: false, tty: false, environment: net.environment, network: net.plan.vmNetwork) + // As in NetworkProbe: a stuck runtime would otherwise leave the + // check waiting for good, with nothing on screen. + spec.timeout = timeout let output = try backend.capture(spec) + if output.timedOut { + backend.stop(spec.name) + throw MudroomError.backendUnavailable( + "the network check's container didn't finish within \(Int(timeout)) seconds; \(backend.name) seems stuck (`\(backend.stopHint(spec.name))` stops it if it is still there)") + } guard let line = output.stdout.split(separator: "\n").last(where: { $0.hasPrefix("MUDROOM_CHECK ") }), let arr = try? JSONSerialization.jsonObject(with: Data(line.dropFirst("MUDROOM_CHECK ".count).utf8)) as? [[String: Any]] else { diff --git a/Tests/MudroomCoreTests/NetworkProbeTests.swift b/Tests/MudroomCoreTests/NetworkProbeTests.swift index fe362b1..7342ec2 100644 --- a/Tests/MudroomCoreTests/NetworkProbeTests.swift +++ b/Tests/MudroomCoreTests/NetworkProbeTests.swift @@ -122,6 +122,20 @@ struct NetworkProbeTests { #expect(r == .stuck(seconds: 2) && r.needsRepair && r.summary.contains("stuck")) } + @Test("a network check whose container never finishes fails in time instead of hanging") + func stuckCheck() throws { + let tmp = try TempDir() + let start = Date() + do { + _ = try NetworkCheck.run(mode: .locked, allowlist: Allowlist(strings: []), backend: HangingBackend(), + scratch: tmp.path("check"), timeout: 2) + Issue.record("the check should have failed") + } catch let e as MudroomError { + #expect(e.description.contains("within 2 seconds")) + } + #expect(Date().timeIntervalSince(start) < 15) + } + @Test("an unreachable network stops a session before it starts, with the repair hint") func errorText() { let e = MudroomError.networkUnreachable(NetworkProbe.Result.unreachable(code: "EHOSTUNREACH", proxy: "192.168.128.1:1").summary) From 1dacb7ff8543b5773c51ec4349d2d1a36d57927a Mon Sep 17 00:00:00 2001 From: Karim Masmoudi <125830216+Kernel-Hunter@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:40:32 +0100 Subject: [PATCH 02/10] Network check: exit as soon as the results are printed With Podman on Windows the locked check hung after printing its results. The forwarder can't reach the proxy there, so the proxied fetches time out, and Node keeps the aborted CONNECT tunnel to the forwarder open and doesn't exit. `podman run` then waits on a container that never stops. The same happens on macOS with a proxy that accepts and never answers: the results are out after about 45 seconds and Node is still running at 200. The script now exits once its output is flushed. --- Sources/MudroomCore/VMTasks.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/MudroomCore/VMTasks.swift b/Sources/MudroomCore/VMTasks.swift index c878d46..8207761 100644 --- a/Sources/MudroomCore/VMTasks.swift +++ b/Sources/MudroomCore/VMTasks.swift @@ -123,7 +123,10 @@ public enum NetworkCheck { [res, ok] = await udpDNS('8.8.8.8'); add('direct-udp', 'UDP DNS query to 8.8.8.8:53', res, ok); if (GATEWAY) { [res, ok] = await tcp(GATEWAY, 7000, 3000); add('mac-services', `TCP ${GATEWAY}:7000 (the host on the sandbox network; macOS often listens on 7000)`, res, ok === true || ok === 'refused'); } - console.log('MUDROOM_CHECK ' + JSON.stringify(out)); + // Exit now: a fetch aborted by its timeout can leave its tunnel + // to the forwarder open (seen with Podman on Windows, where the + // forwarder can't reach the proxy), and Node would wait for it. + process.stdout.write('MUDROOM_CHECK ' + JSON.stringify(out) + '\\n', () => process.exit(0)); })(); """ } From d776747c9aa406931f3519518a4fe412c1e0d2a0 Mon Sep 17 00:00:00 2001 From: Karim Masmoudi <125830216+Kernel-Hunter@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:51:30 +0100 Subject: [PATCH 03/10] Locked mode with a Podman machine on Windows The proxy listened on 127.0.0.1, as on macOS, but a Podman machine on WSL 2 can't reach Windows' loopback. `host.containers.internal` points at the machine's default gateway, the "vEthernet (WSL)" address. On Windows with Podman the proxy now listens there and accepts only that subnet, read from `ip -4 route` in the machine. If the gateway isn't an address on this PC (user-mode networking, no machine), it falls back to 127.0.0.1. Windows Firewall drops connections from WSL to Windows unless a rule allows them, so the forwarder now tries one connection to the proxy before the sandbox starts and, if it fails, stops with the New-NetFirewallRule command to run. macOS, Linux and Docker Desktop are unchanged. CI now runs the locked check without a rule (it must fail with the hint), adds the rule, and runs the locked network check and session. --- .github/workflows/ci.yml | 23 ++++-- README.md | 34 ++++++--- Sources/MudroomCore/DockerBackend.swift | 70 ++++++++++++++++++- .../MudroomCoreTests/DockerBackendTests.swift | 22 ++++++ 4 files changed, 134 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49bf29c..fd4e4c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,12 +118,25 @@ jobs: & $podman machine start if ($LASTEXITCODE -ne 0) { throw "podman machine start failed" } & $podman version - # Open network only. In locked mode `mudroom network check` hangs on - # this runner after "checking locked network with podman..." (no output - # for 6+ minutes), and WSL's mirrored networking, which would let the - # machine reach the proxy on the runner's 127.0.0.1, fails here with - # 0x803b0015. Locked mode still needs a run with Docker Desktop on a PC. - name: Sandbox session (podman, open network) timeout-minutes: 15 shell: pwsh run: scripts/windows-sandbox-check.ps1 $env:MUDROOM_EXE podman -Network open + # Locked mode: the Podman machine reaches the proxy on the WSL network's + # gateway, which Windows Firewall blocks until a rule allows it. Without + # the rule the check has to stop quickly and say which rule to add. + - name: Locked network check without a firewall rule + timeout-minutes: 5 + shell: pwsh + run: | + $out = & $env:MUDROOM_EXE network check --backend podman --mode locked 2>&1 | Out-String + Write-Host $out + if ($LASTEXITCODE -eq 0) { throw "the locked check passed without a firewall rule" } + if ($out -notmatch 'New-NetFirewallRule') { throw "no firewall hint in the error" } + - name: Allow Mudroom's proxy through Windows Firewall + shell: pwsh + run: New-NetFirewallRule -DisplayName 'Mudroom proxy' -Direction Inbound -Action Allow -Protocol TCP -Program $env:MUDROOM_EXE | Out-Null + - name: Sandbox session (podman, locked network) + timeout-minutes: 15 + shell: pwsh + run: scripts/windows-sandbox-check.ps1 $env:MUDROOM_EXE podman -SkipImageBuild diff --git a/README.md b/README.md index 3ae5c02..27c9181 100644 --- a/README.md +++ b/README.md @@ -767,10 +767,13 @@ address; `mudroom network check` will tell you. On Windows the CLI works the same way, with Docker Desktop or a Podman machine running Linux containers in a VM. Host paths are passed to `docker run --mount` as Windows paths (`C:\Users\...`), which Docker Desktop -shares into its VM. Locked mode is set up as on macOS: the proxy listens -on 127.0.0.1 and the forwarder reaches it through `host.docker.internal`. -Run `mudroom network check --backend docker` before relying on it. What -is different from macOS and Linux: +shares into its VM. With Docker Desktop, locked mode is set up as on +macOS: the proxy listens on 127.0.0.1 and the forwarder reaches it through +`host.docker.internal`. A Podman machine on WSL 2 can't reach Windows' +127.0.0.1, so there the proxy listens on the WSL network's gateway (the +"vEthernet (WSL)" address, which `host.containers.internal` points to) and +accepts only the WSL network. Run `mudroom network check` before relying +on it. What is different from macOS and Linux: - **No permission bits.** Windows files have no Unix mode, so every file reads as 0644 and every folder as 0755. A script the agent makes @@ -801,15 +804,28 @@ is different from macOS and Linux: sandbox (create, edit, diff, apply, undo). It also unzips each release zip on a runner that never had Swift installed and runs the same check there. On x86_64 it runs a real sandbox with a Podman machine (WSL 2): - the open-network check and a short session, then diff and apply. Locked - mode, the allowlist proxy, hasn't passed in CI yet: with a rootless - Podman machine on the runner, `mudroom network check` hangs in locked - mode. Docker Desktop hasn't been run by CI, and the arm64 runner can't - run a VM at all. Running the Linux build inside WSL2 is untested. + the network check and a short session, then diff and apply, with the open + and the locked network. Docker Desktop hasn't been run by CI, and the + arm64 runner can't run a VM at all. Running the Linux build inside WSL2 + is untested. - **Podman machine.** If Podman says ``controller `pids` is not available``, the machine's user has no pids cgroup controller; set `pids_limit = 0` under `[containers]` in `~/.config/containers/containers.conf` inside the machine (`podman machine ssh`), then restart it. +- **Locked mode with Podman needs a firewall rule.** Windows Firewall drops + connections from WSL to Windows, so the machine can't reach the proxy + until a rule allows it. Mudroom checks before the sandbox starts and, if + the proxy is out of reach, stops with the rule to add, once, from an + administrator PowerShell: + + ```powershell + New-NetFirewallRule -DisplayName 'Mudroom proxy' -Direction Inbound -Action Allow -Protocol TCP -Program (Get-Command mudroom).Source + ``` + + It also needs the machine on WSL's own network: with user-mode + networking (`podman machine set --user-mode-networking`) + `host.containers.internal` doesn't lead to Windows, and locked mode + can't work. ## How it works diff --git a/Sources/MudroomCore/DockerBackend.swift b/Sources/MudroomCore/DockerBackend.swift index e3f25ac..2bdf2fe 100644 --- a/Sources/MudroomCore/DockerBackend.swift +++ b/Sources/MudroomCore/DockerBackend.swift @@ -331,7 +331,10 @@ public struct DockerBackend: SandboxBackend { /// that forwards `host.docker.internal` to the Mac's loopback, so the /// proxy listens on 127.0.0.1 only. On Linux the sidecar reaches the /// host at the default bridge's gateway, so the proxy listens there and - /// accepts only that bridge. + /// accepts only that bridge. On Windows, Docker Desktop works as on + /// macOS; a Podman machine on WSL 2 can't reach Windows' loopback, so + /// the proxy listens on the WSL network's gateway instead (see + /// `podmanMachineGateway`). public func proxyListen(for plan: NetworkPlan) throws -> ProxyListen { let blocked = plan.clientSubnet.map { [$0] } ?? [] #if os(Linux) @@ -340,10 +343,72 @@ public struct DockerBackend: SandboxBackend { } return ProxyListen(bindHost: bridge.gateway, clientSubnets: [subnet], blockedSubnets: blocked) #else + #if os(Windows) + if let wsl = podmanMachineGateway() { + return ProxyListen(bindHost: wsl.gateway, clientSubnets: [wsl.subnet], blockedSubnets: blocked) + } + #endif return ProxyListen(bindHost: "127.0.0.1", clientSubnets: [], blockedSubnets: blocked) #endif } + /// The default gateway in `ip -4 route` output and the on-link subnet + /// it belongs to: ("172.20.224.1", 172.20.224.0/20) for + /// "default via 172.20.224.1 dev eth0" and + /// "172.20.224.0/20 dev eth0 proto kernel scope link src 172.20.234.217". + public static func defaultGateway(routes: String) -> (gateway: String, subnet: IPv4Subnet)? { + let lines = routes.split(whereSeparator: \.isNewline).map { $0.split(separator: " ").map(String.init) } + guard let route = lines.first(where: { $0.first == "default" }), let via = route.firstIndex(of: "via"), + via + 1 < route.count, let gateway = IPAddress(route[via + 1]), gateway.asIPv4 != nil else { return nil } + let subnet = lines.lazy.compactMap { $0.first.flatMap(IPv4Subnet.init) }.first { $0.prefix > 0 && $0.contains(gateway) } + return subnet.map { (route[via + 1], $0) } + } + + #if os(Windows) + /// A Podman machine on WSL 2 reaches Windows only through WSL's NAT + /// network, at the machine's default gateway (Windows' "vEthernet (WSL)" + /// address), which is also what `host.containers.internal` resolves to. + /// Windows' 127.0.0.1 is out of reach from there. Returns that gateway + /// and the subnet the machine's connections come from, or nil when the + /// gateway isn't an address on this PC (no Podman machine, or one with + /// user-mode networking). + func podmanMachineGateway() -> (gateway: String, subnet: IPv4Subnet)? { + guard flavor == .podman, let exe = executable, + let out = try? ProcessRunner.capture(exe, ["machine", "ssh", "ip -4 route"], timeout: 30), out.status == 0, + let found = Self.defaultGateway(routes: out.stdout), + let bound = try? Sock.listen(host: found.gateway, port: 0) else { return nil } + Sock.close(bound.0) + return found + } + + /// Node one-liner run in the sidecar: one connection to the proxy. + static let upstreamCheckScript = """ + const s = require('net').connect({host: process.env.MUDROOM_UPSTREAM_HOST, port: +process.env.MUDROOM_UPSTREAM_PORT}); + const t = setTimeout(() => { console.log('timeout'); process.exit(0); }, 5000); + s.on('connect', () => { console.log('ok'); process.exit(0); }); + s.on('error', e => { console.log(e.code || 'error'); process.exit(0); }); + """ + + /// Checks the sidecar reaches the proxy. Windows Firewall drops + /// connections from WSL to Windows unless a rule allows them, and the + /// sandbox would otherwise see every request time out, with no hint why. + func checkUpstream(_ container: String, port: UInt16, _ exe: String) throws { + let out = try ProcessRunner.capture(exe, ["exec", container, "node", "-e", Self.upstreamCheckScript], timeout: 30) + let result = out.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + if result == "ok" { return } + let why = out.timedOut ? "timeout" : result.isEmpty ? Self.failureDetail(out) : result + let program = Bundle.main.executablePath ?? CommandLine.arguments[0] + throw MudroomError.backendUnavailable(""" + the Podman machine can't reach Mudroom's proxy at \(hostAlias):\(port) (\(why)). \ + Windows Firewall blocks connections from WSL to Windows unless a rule allows them. \ + Allow Mudroom's proxy once, from an administrator PowerShell: + New-NetFirewallRule -DisplayName 'Mudroom proxy' -Direction Inbound -Action Allow -Protocol TCP -Program '\(program)' + Locked mode also needs the machine on WSL's own network: it doesn't work with user-mode networking \ + (`podman machine set --user-mode-networking=false` turns that off). + """) + } + #endif + /// Node one-liner for the sidecar: accept on 3128, pipe each connection /// to the host proxy. Half-closes pass through. static let forwarderScript = """ @@ -400,6 +465,9 @@ public struct DockerBackend: SandboxBackend { } let ip = try sidecarAddress(sidecar, network: internalNet, exe) try waitForSidecar(sidecar, exe) + #if os(Windows) + if flavor == .podman { try checkUpstream(sidecar, port: port, exe) } + #endif return ProxyRoute(host: ip, port: Self.sidecarPort, teardown: teardown) } catch { teardown() diff --git a/Tests/MudroomCoreTests/DockerBackendTests.swift b/Tests/MudroomCoreTests/DockerBackendTests.swift index 79ec03d..2222c10 100644 --- a/Tests/MudroomCoreTests/DockerBackendTests.swift +++ b/Tests/MudroomCoreTests/DockerBackendTests.swift @@ -47,6 +47,28 @@ struct DockerBackendTests { #expect(pargs.contains("keep-id") && !pargs.contains("--user")) } + @Test("a Podman machine's default gateway and its subnet come from `ip -4 route`") + func machineGateway() { + // WSL 2 NAT, from a Podman machine on a GitHub Windows runner. + let nat = """ + default via 172.20.224.1 dev eth0 proto kernel onlink + 172.20.224.0/20 dev eth0 proto kernel scope link src 172.20.234.217 + """ + let found = DockerBackend.defaultGateway(routes: nat) + #expect(found?.gateway == "172.20.224.1" && found?.subnet == IPv4Subnet("172.20.224.0/20")) + // User-mode networking: a gateway, but gvproxy's, not an address on + // Windows (podmanMachineGateway then fails to bind and falls back). + let userMode = """ + default via 192.168.127.1 dev podman-usermode + 172.31.64.0/20 dev eth0 proto kernel scope link src 172.31.79.5 + 192.168.127.0/24 dev podman-usermode proto kernel scope link src 192.168.127.2 + """ + #expect(DockerBackend.defaultGateway(routes: userMode)?.subnet == IPv4Subnet("192.168.127.0/24")) + #expect(DockerBackend.defaultGateway(routes: "172.20.224.0/20 dev eth0 scope link") == nil) + #expect(DockerBackend.defaultGateway(routes: "default via 10.0.0.1 dev eth0") == nil) + #expect(DockerBackend.defaultGateway(routes: "") == nil) + } + @Test("docker and podman network inspect output parse") func parseNetworks() { let docker = #"[{"Name":"mudroom-internal","Internal":true,"IPAM":{"Config":[{"Subnet":"fd00::/64"},{"Subnet":"172.19.0.0/16","Gateway":"172.19.0.1"}]}}]"# From 0c1d5fbc338b5725f4907c50f86227174fa4d2ce Mon Sep 17 00:00:00 2001 From: Karim Masmoudi <125830216+Kernel-Hunter@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:11:28 +0100 Subject: [PATCH 04/10] Network check: allow 300 seconds before calling the runtime stuck A passing open check took 72 seconds on the Windows runner, most of it starting the container, and the next run went past 120 and failed. --- Sources/MudroomCore/VMTasks.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/MudroomCore/VMTasks.swift b/Sources/MudroomCore/VMTasks.swift index 8207761..6b77fe4 100644 --- a/Sources/MudroomCore/VMTasks.swift +++ b/Sources/MudroomCore/VMTasks.swift @@ -155,8 +155,10 @@ public enum NetworkCheck { /// How long the check's container may take. Each probe in the script /// gives up on its own within seconds (about a minute in all), so this - /// only trips when the runtime itself is stuck. - public static let timeout: TimeInterval = 120 + /// only trips when the runtime itself is stuck. Starting the container + /// is slow on a Podman machine (a passing open check took 72 seconds on + /// GitHub's Windows runner), hence the margin. + public static let timeout: TimeInterval = 300 public static func run(mode: NetworkMode, allowlist: Allowlist, backend: SandboxBackend, image: String = AgentBaseImage.tag, blocked: String = "example.com", From 8a878537756b726147ff3cf183ef184edabcfe7a Mon Sep 17 00:00:00 2001 From: Karim Masmoudi <125830216+Kernel-Hunter@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:29:32 +0100 Subject: [PATCH 05/10] CI: the expected locked-check failure no longer fails its step --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd4e4c6..8991b69 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -133,6 +133,9 @@ jobs: Write-Host $out if ($LASTEXITCODE -eq 0) { throw "the locked check passed without a firewall rule" } if ($out -notmatch 'New-NetFirewallRule') { throw "no firewall hint in the error" } + # The failure above is the expected result; without this the + # runner's pwsh wrapper exits with mudroom's status. + exit 0 - name: Allow Mudroom's proxy through Windows Firewall shell: pwsh run: New-NetFirewallRule -DisplayName 'Mudroom proxy' -Direction Inbound -Action Allow -Protocol TCP -Program $env:MUDROOM_EXE | Out-Null From 59c2f051aef631748f43d903b05c94925699dea8 Mon Sep 17 00:00:00 2001 From: Karim Masmoudi <125830216+Kernel-Hunter@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:49:41 +0100 Subject: [PATCH 06/10] CI: print firewall and WSL network state when the locked session fails --- .github/workflows/ci.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8991b69..c966992 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,3 +143,19 @@ jobs: timeout-minutes: 15 shell: pwsh run: scripts/windows-sandbox-check.ps1 $env:MUDROOM_EXE podman -SkipImageBuild + # What the firewall and WSL's network looked like when locked mode + # failed, and whether the check still passes afterwards. + - name: Firewall and network state + if: failure() + timeout-minutes: 5 + shell: pwsh + run: | + Get-NetFirewallRule -Direction Inbound | Where-Object { ($_ | Get-NetFirewallApplicationFilter).Program -like '*mudroom*' } | + Format-Table DisplayName, Action, Enabled, Profile, PolicyStoreSourceType -AutoSize | Out-String -Width 200 + Get-NetFirewallProfile | Format-Table Name, Enabled, DefaultInboundAction, NotifyOnListen -AutoSize | Out-String -Width 200 + Get-NetConnectionProfile | Format-Table InterfaceAlias, NetworkCategory -AutoSize | Out-String -Width 200 + Get-NetIPAddress -AddressFamily IPv4 | Format-Table InterfaceAlias, IPAddress, PrefixLength -AutoSize | Out-String -Width 200 + Get-NetFirewallHyperVVMSetting -PolicyStore ActiveStore -ErrorAction SilentlyContinue | Out-String -Width 200 + podman machine ssh "ip -4 route" + & $env:MUDROOM_EXE network check --backend podman --mode locked + exit 0 From c7e8c883b01231da2a6a1692c97b485bdf86545d Mon Sep 17 00:00:00 2001 From: Karim Masmoudi <125830216+Kernel-Hunter@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:09:56 +0100 Subject: [PATCH 07/10] CI: log dropped packets and test a plain listener when locked mode fails --- .github/workflows/ci.yml | 39 ++++++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c966992..e6713a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,24 +138,45 @@ jobs: exit 0 - name: Allow Mudroom's proxy through Windows Firewall shell: pwsh - run: New-NetFirewallRule -DisplayName 'Mudroom proxy' -Direction Inbound -Action Allow -Protocol TCP -Program $env:MUDROOM_EXE | Out-Null + run: | + New-NetFirewallRule -DisplayName 'Mudroom proxy' -Direction Inbound -Action Allow -Protocol TCP -Program $env:MUDROOM_EXE | Out-Null + # Log dropped packets, so a failure below names the filter. + auditpol /set /subcategory:"Filtering Platform Packet Drop" /failure:enable + auditpol /set /subcategory:"Filtering Platform Connection" /failure:enable - name: Sandbox session (podman, locked network) timeout-minutes: 15 shell: pwsh run: scripts/windows-sandbox-check.ps1 $env:MUDROOM_EXE podman -SkipImageBuild # What the firewall and WSL's network looked like when locked mode - # failed, and whether the check still passes afterwards. + # failed, which filter dropped the connection, and whether a plain + # listener on the WSL gateway is reachable. - name: Firewall and network state if: failure() - timeout-minutes: 5 + timeout-minutes: 10 shell: pwsh run: | Get-NetFirewallRule -Direction Inbound | Where-Object { ($_ | Get-NetFirewallApplicationFilter).Program -like '*mudroom*' } | - Format-Table DisplayName, Action, Enabled, Profile, PolicyStoreSourceType -AutoSize | Out-String -Width 200 - Get-NetFirewallProfile | Format-Table Name, Enabled, DefaultInboundAction, NotifyOnListen -AutoSize | Out-String -Width 200 - Get-NetConnectionProfile | Format-Table InterfaceAlias, NetworkCategory -AutoSize | Out-String -Width 200 - Get-NetIPAddress -AddressFamily IPv4 | Format-Table InterfaceAlias, IPAddress, PrefixLength -AutoSize | Out-String -Width 200 - Get-NetFirewallHyperVVMSetting -PolicyStore ActiveStore -ErrorAction SilentlyContinue | Out-String -Width 200 - podman machine ssh "ip -4 route" + Format-Table DisplayName, Action, Enabled, Profile -AutoSize | Out-String -Width 200 + Get-NetFirewallRule -Direction Inbound -Action Block -Enabled True | Format-Table DisplayName, Profile -AutoSize | Out-String -Width 200 + $rows = Get-WinEvent -FilterHashtable @{LogName='Security'; Id=5152,5157} -MaxEvents 500 -ErrorAction SilentlyContinue | ForEach-Object { + $d = @{}; ([xml]$_.ToXml()).Event.EventData.Data | ForEach-Object { $d[$_.Name] = $_.'#text' } + [pscustomobject]@{ Time = $_.TimeCreated.ToString('HH:mm:ss'); Id = $_.Id; App = $d.Application; Dir = $d.Direction + Src = "$($d.SourceAddress):$($d.SourcePort)"; Dst = "$($d.DestAddress):$($d.DestPort)"; Filter = $d.FilterRTID; Layer = $d.LayerName } + } | Where-Object { $_.Src -like '172.*' -or $_.Dst -like '172.*' } + $rows | Select-Object -First 40 | Format-Table -AutoSize | Out-String -Width 300 + netsh wfp show filters file="$env:RUNNER_TEMP\filters.xml" | Out-Null + $ids = @($rows.Filter | Sort-Object -Unique) + ([xml](Get-Content "$env:RUNNER_TEMP\filters.xml")).SelectNodes('//filters/item') | Where-Object { $ids -contains $_.filterId } | + ForEach-Object { "filter $($_.filterId): $($_.displayData.name) / $($_.displayData.description) / $($_.action.type)" } + $routes = podman machine ssh "ip -4 route" + $routes + $gw = ([regex]'default via (\S+)').Match(($routes | Out-String)).Groups[1].Value + New-NetFirewallRule -DisplayName 'diag listener' -Direction Inbound -Action Allow -Protocol TCP -LocalPort 18080 | Out-Null + $l = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Parse($gw), 18080); $l.Start() + podman machine ssh "timeout 5 bash -c 'echo > /dev/tcp/$gw/18080' && echo 'machine to gateway: ok' || echo 'machine to gateway: failed'" + $js = "for (const h of ['$gw', 'host.containers.internal']) { const s = require('net').connect({host: h, port: 18080}); const t = setTimeout(() => { console.log(h, 'timeout'); s.destroy(); }, 5000); s.on('connect', () => { clearTimeout(t); console.log(h, 'ok'); s.destroy(); }); s.on('error', e => { clearTimeout(t); console.log(h, e.code); }); }" + podman run --rm --network podman mudroom/agent-base:latest cat /etc/hosts + podman run --rm --network podman mudroom/agent-base:latest node -e $js + $l.Stop() & $env:MUDROOM_EXE network check --backend podman --mode locked exit 0 From dc21238ead03c1ffa3fb343d1c015669f61845c5 Mon Sep 17 00:00:00 2001 From: Karim Masmoudi <125830216+Kernel-Hunter@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:28:45 +0100 Subject: [PATCH 08/10] CI: keep the diagnostics going when a query finds nothing --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6713a5..87ae846 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -155,9 +155,10 @@ jobs: timeout-minutes: 10 shell: pwsh run: | + $ErrorActionPreference = 'Continue' Get-NetFirewallRule -Direction Inbound | Where-Object { ($_ | Get-NetFirewallApplicationFilter).Program -like '*mudroom*' } | Format-Table DisplayName, Action, Enabled, Profile -AutoSize | Out-String -Width 200 - Get-NetFirewallRule -Direction Inbound -Action Block -Enabled True | Format-Table DisplayName, Profile -AutoSize | Out-String -Width 200 + Get-NetFirewallRule -Direction Inbound -Action Block -Enabled True -ErrorAction SilentlyContinue | Format-Table DisplayName, Profile -AutoSize | Out-String -Width 200 $rows = Get-WinEvent -FilterHashtable @{LogName='Security'; Id=5152,5157} -MaxEvents 500 -ErrorAction SilentlyContinue | ForEach-Object { $d = @{}; ([xml]$_.ToXml()).Event.EventData.Data | ForEach-Object { $d[$_.Name] = $_.'#text' } [pscustomobject]@{ Time = $_.TimeCreated.ToString('HH:mm:ss'); Id = $_.Id; App = $d.Application; Dir = $d.Direction From cfea87e5340be102d5332ccbd7c1f279a471c290 Mon Sep 17 00:00:00 2001 From: Karim Masmoudi <125830216+Kernel-Hunter@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:06:35 +0100 Subject: [PATCH 09/10] Podman on Windows: say why the proxy can't use the WSL gateway If `podman machine ssh "ip -4 route"` failed, or its output didn't parse, the proxy quietly listened on 127.0.0.1, which a Podman machine on WSL 2 can never reach, and the only symptom was a timeout blamed on the firewall. Now that is an error with the command's output. The unreachable-proxy error also says which address the sidecar tried and where the proxy was listening, and the firewall hint prints the path with backslashes. --- Sources/MudroomCore/DockerBackend.swift | 76 +++++++++++++++---- Sources/MudroomCore/SessionRunner.swift | 7 ++ .../MudroomCoreTests/DockerBackendTests.swift | 19 +++++ 3 files changed, 86 insertions(+), 16 deletions(-) diff --git a/Sources/MudroomCore/DockerBackend.swift b/Sources/MudroomCore/DockerBackend.swift index 2bdf2fe..c4ca93e 100644 --- a/Sources/MudroomCore/DockerBackend.swift +++ b/Sources/MudroomCore/DockerBackend.swift @@ -344,7 +344,7 @@ public struct DockerBackend: SandboxBackend { return ProxyListen(bindHost: bridge.gateway, clientSubnets: [subnet], blockedSubnets: blocked) #else #if os(Windows) - if let wsl = podmanMachineGateway() { + if let wsl = try podmanMachineGateway() { return ProxyListen(bindHost: wsl.gateway, clientSubnets: [wsl.subnet], blockedSubnets: blocked) } #endif @@ -356,37 +356,81 @@ public struct DockerBackend: SandboxBackend { /// it belongs to: ("172.20.224.1", 172.20.224.0/20) for /// "default via 172.20.224.1 dev eth0" and /// "172.20.224.0/20 dev eth0 proto kernel scope link src 172.20.234.217". + /// Tolerates what a Windows console can add: CRLF line ends, a byte + /// order mark, tabs. public static func defaultGateway(routes: String) -> (gateway: String, subnet: IPv4Subnet)? { - let lines = routes.split(whereSeparator: \.isNewline).map { $0.split(separator: " ").map(String.init) } + let lines = routes.replacingOccurrences(of: "\u{FEFF}", with: "").split(whereSeparator: \.isNewline) + .map { $0.split(whereSeparator: \.isWhitespace).map(String.init) } guard let route = lines.first(where: { $0.first == "default" }), let via = route.firstIndex(of: "via"), via + 1 < route.count, let gateway = IPAddress(route[via + 1]), gateway.asIPv4 != nil else { return nil } let subnet = lines.lazy.compactMap { $0.first.flatMap(IPv4Subnet.init) }.first { $0.prefix > 0 && $0.contains(gateway) } return subnet.map { (route[via + 1], $0) } } + /// `text` with line breaks, tabs, byte order marks and other control + /// characters spelled out, for an error message. + static func visible(_ text: String) -> String { + text.unicodeScalars.map { c -> String in + switch c { + case "\n": return "\\n" + case "\r": return "\\r" + case "\t": return "\\t" + case _ where c.value < 0x20 || c.value == 0x7f || c.value == 0xfeff: + return "\\u{" + String(c.value, radix: 16) + "}" + default: return String(c) + } + }.joined() + } + + /// A path with Windows' separators, for a command to paste into + /// PowerShell: Foundation reports the executable's path with `/`. + public static func windowsPath(_ path: String) -> String { + path.replacingOccurrences(of: "/", with: "\\") + } + #if os(Windows) /// A Podman machine on WSL 2 reaches Windows only through WSL's NAT /// network, at the machine's default gateway (Windows' "vEthernet (WSL)" /// address), which is also what `host.containers.internal` resolves to. /// Windows' 127.0.0.1 is out of reach from there. Returns that gateway - /// and the subnet the machine's connections come from, or nil when the - /// gateway isn't an address on this PC (no Podman machine, or one with - /// user-mode networking). - func podmanMachineGateway() -> (gateway: String, subnet: IPv4Subnet)? { - guard flavor == .podman, let exe = executable, - let out = try? ProcessRunner.capture(exe, ["machine", "ssh", "ip -4 route"], timeout: 30), out.status == 0, - let found = Self.defaultGateway(routes: out.stdout), - let bound = try? Sock.listen(host: found.gateway, port: 0) else { return nil } - Sock.close(bound.0) + /// and the subnet the machine's connections come from; nil for Docker, + /// and when the gateway isn't an address on this PC (a machine with + /// user-mode networking, where it is gvproxy's). Throws when the + /// machine's routes can't be read: the proxy would otherwise listen on + /// 127.0.0.1, where the machine can never reach it. + func podmanMachineGateway() throws -> (gateway: String, subnet: IPv4Subnet)? { + guard flavor == .podman, let exe = executable else { return nil } + let command = "ip -4 route" + let out = try ProcessRunner.capture(exe, ["machine", "ssh", command], timeout: 30) + guard out.status == 0, !out.timedOut else { + let how = out.timedOut ? "didn't finish within 30 seconds" : "exited with \(out.status)" + throw MudroomError.backendUnavailable( + "couldn't read the Podman machine's network for the proxy: `podman machine ssh \"\(command)\"` \(how) (\(Self.failureDetail(out))). Is the machine running (`podman machine start`)?") + } + guard let found = Self.defaultGateway(routes: out.stdout) else { + let shown = Self.visible(out.stdout) + throw MudroomError.backendUnavailable( + "couldn't find the Podman machine's default gateway for the proxy in the output of `podman machine ssh \"\(command)\"`: \(shown.isEmpty ? "nothing" : String(shown.prefix(600)))") + } + do { + let bound = try Sock.listen(host: found.gateway, port: 0) + Sock.close(bound.0) + } catch { + return nil + } return found } /// Node one-liner run in the sidecar: one connection to the proxy. + /// Prints "ok", or what went wrong and the address it tried. static let upstreamCheckScript = """ - const s = require('net').connect({host: process.env.MUDROOM_UPSTREAM_HOST, port: +process.env.MUDROOM_UPSTREAM_PORT}); - const t = setTimeout(() => { console.log('timeout'); process.exit(0); }, 5000); - s.on('connect', () => { console.log('ok'); process.exit(0); }); - s.on('error', e => { console.log(e.code || 'error'); process.exit(0); }); + require('dns').lookup(process.env.MUDROOM_UPSTREAM_HOST, {family: 4}, (e, a) => { + if (e) { console.log(e.code || 'error'); process.exit(0); } + const s = require('net').connect({host: a, port: +process.env.MUDROOM_UPSTREAM_PORT}); + setTimeout(() => { console.log('timeout connecting to ' + a); process.exit(0); }, 5000); + s.on('connect', () => { console.log('ok'); process.exit(0); }); + s.on('error', e => { console.log((e.code || 'error') + ' connecting to ' + a); process.exit(0); }); + }); """ /// Checks the sidecar reaches the proxy. Windows Firewall drops @@ -397,7 +441,7 @@ public struct DockerBackend: SandboxBackend { let result = out.stdout.trimmingCharacters(in: .whitespacesAndNewlines) if result == "ok" { return } let why = out.timedOut ? "timeout" : result.isEmpty ? Self.failureDetail(out) : result - let program = Bundle.main.executablePath ?? CommandLine.arguments[0] + let program = Self.windowsPath(Bundle.main.executablePath ?? CommandLine.arguments[0]) throw MudroomError.backendUnavailable(""" the Podman machine can't reach Mudroom's proxy at \(hostAlias):\(port) (\(why)). \ Windows Firewall blocks connections from WSL to Windows unless a rule allows them. \ diff --git a/Sources/MudroomCore/SessionRunner.swift b/Sources/MudroomCore/SessionRunner.swift index 0c9a8eb..056944b 100644 --- a/Sources/MudroomCore/SessionRunner.swift +++ b/Sources/MudroomCore/SessionRunner.swift @@ -146,6 +146,13 @@ public final class NetworkSetup: @unchecked Sendable { route = try backend.attachProxy(port: p.port, plan: plan) } catch { p.stop() + #if os(Windows) + // Where the proxy was, so a connection that never arrives + // can be told apart from one the firewall drops. + if let e = error as? MudroomError, case .backendUnavailable(let why) = e { + throw MudroomError.backendUnavailable(why + "\n(Mudroom's proxy was listening on \(listen.bindHost ?? "all addresses"):\(p.port).)") + } + #endif throw error } teardown.value = route.teardown diff --git a/Tests/MudroomCoreTests/DockerBackendTests.swift b/Tests/MudroomCoreTests/DockerBackendTests.swift index 2222c10..a973c8a 100644 --- a/Tests/MudroomCoreTests/DockerBackendTests.swift +++ b/Tests/MudroomCoreTests/DockerBackendTests.swift @@ -69,6 +69,25 @@ struct DockerBackendTests { #expect(DockerBackend.defaultGateway(routes: "") == nil) } + @Test("the gateway parses from output as a Windows console delivers it") + func machineGatewayWindowsOutput() { + // From the windows-sandbox CI job, with a trailing space on each + // line, then the same with CRLF, a byte order mark and tabs. + let ci = "default via 172.22.192.1 dev eth0 proto kernel onlink \n172.22.192.0/20 dev eth0 proto kernel scope link src 172.22.204.138 \n" + let crlf = "\u{FEFF}default via 172.22.192.1 dev eth0 proto kernel onlink \r\n\r\n172.22.192.0/20\tdev eth0 proto kernel scope link src 172.22.204.138 \r\n" + for routes in [ci, crlf, "\r\n" + crlf.replacingOccurrences(of: "\r\n", with: "\r")] { + let found = DockerBackend.defaultGateway(routes: routes) + #expect(found?.gateway == "172.22.192.1" && found?.subnet == IPv4Subnet("172.22.192.0/20"), "\(DockerBackend.visible(routes))") + } + } + + @Test("control characters are spelled out in error messages, and the firewall hint uses Windows paths") + func messageHelpers() { + #expect(DockerBackend.visible("\u{FEFF}a b\r\n\tc\u{1b}[0m") == "\\u{feff}a b\\r\\n\\tc\\u{1b}[0m") + #expect(DockerBackend.windowsPath("D:/a/_temp/mudroom-0.2.0-windows-x86_64/mudroom.exe") + == #"D:\a\_temp\mudroom-0.2.0-windows-x86_64\mudroom.exe"#) + } + @Test("docker and podman network inspect output parse") func parseNetworks() { let docker = #"[{"Name":"mudroom-internal","Internal":true,"IPAM":{"Config":[{"Subnet":"fd00::/64"},{"Subnet":"172.19.0.0/16","Gateway":"172.19.0.1"}]}}]"# From 6f710a080689bdbd3aedc84104df979f10d26376 Mon Sep 17 00:00:00 2001 From: Karim Masmoudi <125830216+Kernel-Hunter@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:24:11 +0100 Subject: [PATCH 10/10] Windows: give captured commands a stdin they can pass on `podman machine ssh "ip -4 route"` failed under Mudroom with "fork/exec C:\Windows\System32\OpenSSH\ssh.exe: The handle is invalid." Foundation maps FileHandle.nullDevice to a NUL handle that it doesn't mark inheritable, so the child got an invalid stdin, and podman couldn't hand it to ssh.exe. The proxy then listened on 127.0.0.1, out of the Podman machine's reach. Captured commands on Windows now read an empty file. Also stop falling back to 127.0.0.1 when the machine's gateway isn't an address on this PC (user-mode networking): say so instead. --- Sources/MudroomCore/DockerBackend.swift | 12 ++++++------ Sources/MudroomCore/ProcessRunner.swift | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/Sources/MudroomCore/DockerBackend.swift b/Sources/MudroomCore/DockerBackend.swift index c4ca93e..b0ca64c 100644 --- a/Sources/MudroomCore/DockerBackend.swift +++ b/Sources/MudroomCore/DockerBackend.swift @@ -393,11 +393,10 @@ public struct DockerBackend: SandboxBackend { /// network, at the machine's default gateway (Windows' "vEthernet (WSL)" /// address), which is also what `host.containers.internal` resolves to. /// Windows' 127.0.0.1 is out of reach from there. Returns that gateway - /// and the subnet the machine's connections come from; nil for Docker, - /// and when the gateway isn't an address on this PC (a machine with - /// user-mode networking, where it is gvproxy's). Throws when the - /// machine's routes can't be read: the proxy would otherwise listen on - /// 127.0.0.1, where the machine can never reach it. + /// and the subnet the machine's connections come from, or nil for + /// Docker. Throws when the routes can't be read, or when the gateway + /// isn't an address on this PC (user-mode networking, where it is + /// gvproxy's): a proxy on 127.0.0.1 would be out of the machine's reach. func podmanMachineGateway() throws -> (gateway: String, subnet: IPv4Subnet)? { guard flavor == .podman, let exe = executable else { return nil } let command = "ip -4 route" @@ -416,7 +415,8 @@ public struct DockerBackend: SandboxBackend { let bound = try Sock.listen(host: found.gateway, port: 0) Sock.close(bound.0) } catch { - return nil + throw MudroomError.backendUnavailable( + "the Podman machine's gateway, \(found.gateway), isn't an address Mudroom's proxy can listen on (\(error)). Locked mode needs the machine on WSL's own network; with user-mode networking it can't reach Windows (`podman machine set --user-mode-networking=false` turns that off).") } return found } diff --git a/Sources/MudroomCore/ProcessRunner.swift b/Sources/MudroomCore/ProcessRunner.swift index ee46bd9..2f467ec 100644 --- a/Sources/MudroomCore/ProcessRunner.swift +++ b/Sources/MudroomCore/ProcessRunner.swift @@ -57,7 +57,23 @@ public enum ProcessRunner { } process.standardOutput = outHandle process.standardError = errHandle + #if os(Windows) + // An empty file, not FileHandle.nullDevice: Foundation gives the + // child a NUL handle it doesn't mark inheritable, so the child's + // stdin is invalid. Most programs never notice, but one that hands + // its stdin on fails: `podman machine ssh` stops with "fork/exec + // ssh.exe: The handle is invalid". + let inURL = tmp.appendingPathComponent("mudroom-in-\(UUID().uuidString)") + _ = FileManager.default.createFile(atPath: inURL.path, contents: nil) + let inHandle = try FileHandle(forReadingFrom: inURL) + defer { + try? inHandle.close() + try? FileManager.default.removeItem(at: inURL) + } + process.standardInput = inHandle + #else process.standardInput = FileHandle.nullDevice + #endif try start(process) let timedOut = LockedBox(false) if let timeout {