diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49bf29c..87ae846 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,12 +118,66 @@ 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" } + # 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 + # 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, 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: 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 -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 + 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 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..b0ca64c 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,116 @@ public struct DockerBackend: SandboxBackend { } return ProxyListen(bindHost: bridge.gateway, clientSubnets: [subnet], blockedSubnets: blocked) #else + #if os(Windows) + if let wsl = try 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". + /// 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.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 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" + 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 { + 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 + } + + /// 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 = """ + 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 + /// 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 = 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. \ + 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 +509,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/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 { 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/Sources/MudroomCore/VMTasks.swift b/Sources/MudroomCore/VMTasks.swift index 8e2f5e7..6b77fe4 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); @@ -118,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)); })(); """ } @@ -145,9 +153,16 @@ 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. 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", - 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 +174,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/DockerBackendTests.swift b/Tests/MudroomCoreTests/DockerBackendTests.swift index 79ec03d..a973c8a 100644 --- a/Tests/MudroomCoreTests/DockerBackendTests.swift +++ b/Tests/MudroomCoreTests/DockerBackendTests.swift @@ -47,6 +47,47 @@ 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("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"}]}}]"# 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)