Skip to content

Commit fb2f2f5

Browse files
Skip the default route when an interface has no gateway
The Interface protocol documents ipv4Gateway and ipv6Gateway as the address for the default route, or nil for no default route. setupInterface logged the nil-gateway case and then called routeAddDefault anyway, so the guest received an on-link default route that made every external destination look directly reachable on the link. Return early instead when both gateways are nil, and cover the routing decisions with a recording VirtualMachineAgent test double so the v4-only, v6-only, and no-gateway cases are all asserted.
1 parent 5796abe commit fb2f2f5

2 files changed

Lines changed: 131 additions & 2 deletions

File tree

Sources/Containerization/VirtualMachineAgent+Interface.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,14 @@ extension VirtualMachineAgent {
7373
)
7474
}
7575

76-
if ipv4Gateway == nil && ipv6Gateway == nil {
77-
logger?.debug("no gateway for \(name)")
76+
guard ipv4Gateway != nil || ipv6Gateway != nil else {
77+
// `Interface` documents a nil gateway as "no default route", so
78+
// installing one anyway would give the guest an on-link default
79+
// route that makes every destination look directly reachable.
80+
logger?.debug("no gateway for \(name), skipping the default route")
81+
return
7882
}
83+
7984
try await routeAddDefault(
8085
name: name,
8186
route: .init(ipv4Gateway: ipv4Gateway, ipv6Gateway: ipv6Gateway)

Tests/ContainerizationTests/InterfaceTests.swift

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@
1414
// limitations under the License.
1515
//===----------------------------------------------------------------------===//
1616

17+
import ContainerizationError
1718
import ContainerizationExtras
19+
import ContainerizationOCI
20+
import Foundation
1821
import Testing
1922

2023
@testable import Containerization
@@ -56,4 +59,125 @@ struct InterfaceTests {
5659
#expect(nat.ipv6Address == nil)
5760
#expect(nat.ipv6Gateway == nil)
5861
}
62+
63+
/// Records the routing calls `setupInterface` makes so the routing decisions can be
64+
/// asserted without booting a sandbox. Everything outside the networking surface is
65+
/// unsupported, which is what the protocol asks unimplemented operations to report.
66+
private actor RecordingAgent: VirtualMachineAgent {
67+
private(set) var addresses: [InterfaceAddress] = []
68+
private(set) var linkRoutes: [LinkRoute] = []
69+
private(set) var defaultRoutes: [DefaultRoute] = []
70+
private(set) var linksBroughtUp: [String] = []
71+
72+
func addressAdd(name: String, address: InterfaceAddress) async throws {
73+
addresses.append(address)
74+
}
75+
76+
func up(name: String, mtu: UInt32?) async throws {
77+
linksBroughtUp.append(name)
78+
}
79+
80+
func routeAddLink(name: String, route: LinkRoute) async throws {
81+
linkRoutes.append(route)
82+
}
83+
84+
func routeAddDefault(name: String, route: DefaultRoute) async throws {
85+
defaultRoutes.append(route)
86+
}
87+
88+
private func unsupported(_ operation: String) -> ContainerizationError {
89+
ContainerizationError(.unsupported, message: operation)
90+
}
91+
92+
func standardSetup() async throws { throw unsupported("standardSetup") }
93+
func close() async throws { throw unsupported("close") }
94+
func filesystemOperation(operation: FilesystemOperation, path: String) async throws { throw unsupported("filesystemOperation") }
95+
func getenv(key: String) async throws -> String { throw unsupported("getenv") }
96+
func setenv(key: String, value: String) async throws { throw unsupported("setenv") }
97+
func mount(_ mount: ContainerizationOCI.Mount) async throws { throw unsupported("mount") }
98+
func umount(path: String, flags: Int32) async throws { throw unsupported("umount") }
99+
func mkdir(path: String, all: Bool, perms: UInt32) async throws { throw unsupported("mkdir") }
100+
func kill(pid: Int32, signal: Int32) async throws -> Int32 { throw unsupported("kill") }
101+
func down(name: String) async throws { throw unsupported("down") }
102+
func configureDNS(config: DNS, location: String) async throws { throw unsupported("configureDNS") }
103+
104+
func createProcess(
105+
id: String,
106+
containerID: String?,
107+
stdinPort: UInt32?,
108+
stdoutPort: UInt32?,
109+
stderrPort: UInt32?,
110+
ociRuntimePath: String?,
111+
configuration: ContainerizationOCI.Spec,
112+
options: Data?
113+
) async throws { throw unsupported("createProcess") }
114+
func startProcess(id: String, containerID: String?) async throws -> Int32 { throw unsupported("startProcess") }
115+
func signalProcess(id: String, containerID: String?, signal: Int32) async throws { throw unsupported("signalProcess") }
116+
func resizeProcess(id: String, containerID: String?, columns: UInt32, rows: UInt32) async throws { throw unsupported("resizeProcess") }
117+
func waitProcess(id: String, containerID: String?, timeoutInSeconds: Int64?) async throws -> Containerization.ExitStatus { throw unsupported("waitProcess") }
118+
func deleteProcess(id: String, containerID: String?) async throws { throw unsupported("deleteProcess") }
119+
}
120+
121+
/// `Interface` documents a nil gateway as "no default route", so an interface with
122+
/// neither a v4 nor a v6 gateway must not get one. Installing it anyway leaves the
123+
/// guest with an on-link default route that makes every destination look local.
124+
@Test func noDefaultRouteWhenBothGatewaysAreNil() async throws {
125+
let agent = RecordingAgent()
126+
let interface = V4OnlyInterface(
127+
ipv4Address: try CIDRv4("172.16.0.3/24"),
128+
ipv4Gateway: nil,
129+
macAddress: nil)
130+
131+
try await agent.setupInterface(interface, name: "eth0", setDefaultRoute: true, logger: nil)
132+
133+
#expect(await agent.linksBroughtUp == ["eth0"])
134+
#expect(await agent.addresses.count == 1)
135+
#expect(await agent.defaultRoutes.isEmpty)
136+
#expect(await agent.linkRoutes.isEmpty)
137+
}
138+
139+
@Test func defaultRouteInstalledForIPv4Gateway() async throws {
140+
let agent = RecordingAgent()
141+
let interface = V4OnlyInterface(
142+
ipv4Address: try CIDRv4("172.16.0.3/24"),
143+
ipv4Gateway: try IPv4Address("172.16.0.1"),
144+
macAddress: nil)
145+
146+
try await agent.setupInterface(interface, name: "eth0", setDefaultRoute: true, logger: nil)
147+
148+
let routes = await agent.defaultRoutes
149+
#expect(routes.count == 1)
150+
#expect(routes.first?.ipv4Gateway == (try IPv4Address("172.16.0.1")))
151+
#expect(routes.first?.ipv6Gateway == nil)
152+
}
153+
154+
/// A v6-only gateway still needs the default route, so the nil v4 gateway alone must
155+
/// not suppress it.
156+
@Test func defaultRouteInstalledForIPv6OnlyGateway() async throws {
157+
let agent = RecordingAgent()
158+
let interface = NATInterface(
159+
ipv4Address: try CIDRv4("192.0.2.2/24"),
160+
ipv4Gateway: nil,
161+
ipv6Address: try CIDRv6("fd00::2/64"),
162+
ipv6Gateway: try IPv6Address("fd00::1"))
163+
164+
try await agent.setupInterface(interface, name: "eth0", setDefaultRoute: true, logger: nil)
165+
166+
let routes = await agent.defaultRoutes
167+
#expect(routes.count == 1)
168+
#expect(routes.first?.ipv4Gateway == nil)
169+
#expect(routes.first?.ipv6Gateway == (try IPv6Address("fd00::1")))
170+
}
171+
172+
@Test func noDefaultRouteWhenNotRequested() async throws {
173+
let agent = RecordingAgent()
174+
let interface = V4OnlyInterface(
175+
ipv4Address: try CIDRv4("172.16.0.3/24"),
176+
ipv4Gateway: try IPv4Address("172.16.0.1"),
177+
macAddress: nil)
178+
179+
try await agent.setupInterface(interface, name: "eth0", setDefaultRoute: false, logger: nil)
180+
181+
#expect(await agent.defaultRoutes.isEmpty)
182+
}
59183
}

0 commit comments

Comments
 (0)