Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions Tests/VPNBypassTests/ControlSurfaceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,18 @@ final class ControlSurfaceTests: XCTestCase {

func testRouteSetRepointsLiveListenerKeepingStablePort() async {
let id = UUID()
// A port from TestPorts, not a fixed one: a fixed port fails this test whenever
// something else holds it, because the listener then falls back to a random port.
let stablePort = TestPorts.nextListenPort().rawValue
var cfg = RouteManager.shared.config
cfg.multiRouteEnabled = true
cfg.routes = [Route(id: id, name: "oxy", egress: .proxyHTTP,
proxyHost: "127.0.0.1", proxyPort: 8001, localListenPort: 18099)]
proxyHost: "127.0.0.1", proxyPort: 8001, localListenPort: Int(stablePort))]
RouteManager.shared.config = cfg

await RouteManager.shared.reconcileProxyListeners()
let started = await waitForPort(id)
XCTAssertEqual(started, 18099, "listener up on its stable port")
XCTAssertEqual(started, stablePort, "listener up on its stable port")

// Re-point the upstream port (the canonical "switch the exit IP" command).
let resp = await ControlSurface.handle(ControlRequest(cmd: "route.set",
Expand All @@ -52,7 +55,7 @@ final class ControlSurfaceTests: XCTestCase {
XCTAssertEqual(resp.result?.routes?.first?.proxyPort, 8002)
XCTAssertEqual(RouteManager.shared.config.routes.first?.proxyPort, 8002, "change persisted to config")
let afterRepoint = await waitForPort(id)
XCTAssertEqual(afterRepoint, 18099,
XCTAssertEqual(afterRepoint, stablePort,
"listener re-pointed in place — stable local port survives (HTTPS_PROXY keeps working)")
}

Expand Down
3 changes: 2 additions & 1 deletion Tests/VPNBypassTests/DocScreenshotsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,8 @@ final class DocScreenshotsTests: XCTestCase {
startedListener = true
let deadline = Date().addingTimeInterval(5)
while !up && Date() < deadline { RunLoop.main.run(until: Date().addingTimeInterval(0.05)) }
XCTAssertNotNil(ProxyListenerManager.shared.port(for: proxy.id), "office-proxy's listener is up")
XCTAssertEqual(ProxyListenerManager.shared.port(for: proxy.id), 18168,
"office-proxy's listener is up on the port the docs show")
}

private func route(_ destination: String, via gateway: String, for source: String) -> RouteManager.ActiveRoute {
Expand Down
12 changes: 7 additions & 5 deletions Tests/VPNBypassTests/LiveProxyEgressTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ final class LiveProxyEgressTests: XCTestCase {
throw XCTSkip("no upstream proxy URL in env")
}

let forwarder = ProxyForwarder(listenPort: 0, upstream: upstream)
let forwarder = ProxyForwarder(listenPort: TestPorts.nextListenPort().rawValue, upstream: upstream)
try forwarder.start()
defer { forwarder.stop() }
guard let port = forwarder.boundPort else { return XCTFail("forwarder did not bind a port") }
Expand Down Expand Up @@ -88,9 +88,10 @@ final class LiveProxyEgressTests: XCTestCase {
let up = Self.parseUpstream(proxyURL, iface: nil) else { throw XCTSkip("no upstream") }

let routeId = UUID()
let stablePort = TestPorts.nextListenPort().rawValue
let route = Route(id: routeId, name: "oxy-live", egress: .proxyHTTP,
proxyHost: up.host, proxyPort: Int(up.port),
proxyUser: up.username, proxyPass: up.password, localListenPort: 18443)
proxyUser: up.username, proxyPass: up.password, localListenPort: Int(stablePort))
var cfg = RouteManager.shared.config
cfg.multiRouteEnabled = true
cfg.routes = [route]
Expand All @@ -103,7 +104,7 @@ final class LiveProxyEgressTests: XCTestCase {

let port = ProxyListenerManager.shared.port(for: routeId)
print("APP-RECONCILE listener port: \(port.map(String.init) ?? "nil")")
XCTAssertEqual(port, 18443, "stable per-route port honored")
XCTAssertEqual(port, stablePort, "stable per-route port honored")
guard let port else { return }

let exitIP = try await Self.fetchExitIP(throughLoopbackPort: port)
Expand All @@ -128,9 +129,10 @@ final class LiveProxyEgressTests: XCTestCase {
XCTAssertTrue(ProxyListenerManager.isTailnetHost(peerHost), "peer must be a 100.64/10 tailnet IP")

let routeId = UUID()
let stablePort = TestPorts.nextListenPort().rawValue
let route = Route(id: routeId, name: "ts-live", egress: .tailscaleExit,
proxyHost: peerHost, proxyPort: peerPort,
tailscaleExitNode: "peer", localListenPort: 18944)
tailscaleExitNode: "peer", localListenPort: Int(stablePort))
var cfg = RouteManager.shared.config
cfg.multiRouteEnabled = true
cfg.routes = [route]
Expand All @@ -147,7 +149,7 @@ final class LiveProxyEgressTests: XCTestCase {

let port = ProxyListenerManager.shared.port(for: routeId)
print("TS-RECONCILE listener port: \(port.map(String.init) ?? "nil")")
XCTAssertEqual(port, 18944, "stable per-route port honored")
XCTAssertEqual(port, stablePort, "stable per-route port honored")
guard let port else { return }

let exitIP = try await Self.fetchExitIP(throughLoopbackPort: port)
Expand Down
9 changes: 7 additions & 2 deletions Tests/VPNBypassTests/LoopbackPeerAuthTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ final class LoopbackPeerAuthTests: XCTestCase {
"a different uid must be rejected")
}

// The two listen-socket tests below keep port .any on purpose: no client ever dials
// these listeners, so there is no connect() to collide with a TIME_WAIT, and the kernel
// hands out a port no socket holds. The wildcard one could not use TestPorts anyway:
// TestPorts checks 127.0.0.1 only, and a 0.0.0.0 bind also fails when a socket on any
// other address holds the port.
func testUidLookupFindsOwnListeningPort() throws {
let parameters = NWParameters.tcp
parameters.requiredLocalEndpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: .any)
Expand Down Expand Up @@ -96,8 +101,8 @@ final class LoopbackPeerAuthTests: XCTestCase {
private func acceptOneLoopbackConnection() throws -> (uid: uid_t?, clientPort: UInt16, listenerPort: UInt16) {
let parameters = NWParameters.tcp
// Not port 0: a listener in the ephemeral range can collide with an earlier test's
// TIME_WAIT and leave the client stuck on EADDRINUSE. See ProxyForwarderTests.nextListenPort.
parameters.requiredLocalEndpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: ProxyForwarderTests.nextListenPort())
// TIME_WAIT and leave the client stuck on EADDRINUSE. See TestPorts.nextListenPort.
parameters.requiredLocalEndpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: TestPorts.nextListenPort())
let listener = try NWListener(using: parameters)
let queue = DispatchQueue(label: "test.loopback.peer.accept")
let listening = expectation(description: "listener ready")
Expand Down
90 changes: 20 additions & 70 deletions Tests/VPNBypassTests/ProxyForwarderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,61 +8,11 @@ import Network
// (b) bytes relay end-to-end through the tunnel. Everything is driven off
// XCTestExpectations (no sleeps) so it is deterministic.
//
// Every listener here (forwarders and mock upstreams) binds a port from
// nextListenPort(), never port 0. See that function for why.
// Every listener a client dials here (forwarders and mock upstreams) binds a port from
// TestPorts.nextListenPort(), never port 0. See that function for why.
// testPortZeroReportsTheAssignedPort binds port 0 on purpose and nothing dials it.
final class ProxyForwarderTests: XCTestCase {

// MARK: - Listener ports

/// A loopback port for one listener in this suite: below the ephemeral range, never
/// handed out twice in a run, and free right now.
///
/// Port 0 made these tests flaky. A port 0 listener gets its port from the ephemeral
/// range (49152-65535), the same range client sockets take their source ports from.
/// Every test leaves a TIME_WAIT on its 127.0.0.1 listener/client port pair for 30 s.
/// Once in a while a later test drew the same pair again. In every case caught it was
/// reversed: its listener on an earlier client's port, its client on that earlier
/// listener's port.
/// connect() then fails with EADDRINUSE, NWConnection waits in `.waiting` without
/// retrying, and the test runs out its 5 s timeout. A fresh connection does not get
/// out of it: on macOS it was handed the same source port again.
///
/// Production never mixes the two ranges (route listeners use 18000-18999), and
/// neither does this suite now: ports come from 20000-48999, each one once per run,
/// and each is bound first without SO_REUSEADDR, which also refuses a port that still
/// has a TIME_WAIT on it.
static func nextListenPort() -> NWEndpoint.Port {
listenPortLock.lock(); defer { listenPortLock.unlock() }
for _ in 0..<listenPortSpan {
let candidate = listenPortCursor
listenPortCursor = listenPortCursor >= listenPortFirst + listenPortSpan - 1 ? listenPortFirst : listenPortCursor + 1
if canBindLoopback(port: candidate) { return NWEndpoint.Port(rawValue: candidate)! }
}
fatalError("no free loopback port in \(listenPortFirst)..<\(listenPortFirst + listenPortSpan)")
}

static let listenPortFirst: UInt16 = 20_000
static let listenPortSpan: UInt16 = 29_000 // 20000...48999
private static let listenPortLock = NSLock()
private static var listenPortCursor: UInt16 = listenPortFirst + UInt16.random(in: 0..<listenPortSpan)

/// Plain BSD bind on 127.0.0.1:port with no SO_REUSEADDR, closed straight away.
private static func canBindLoopback(port: UInt16) -> Bool {
let fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)
guard fd >= 0 else { return false }
defer { close(fd) }
var addr = sockaddr_in()
addr.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
addr.sin_family = sa_family_t(AF_INET)
addr.sin_port = port.bigEndian
addr.sin_addr.s_addr = inet_addr("127.0.0.1")
return withUnsafePointer(to: &addr) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
Darwin.bind(fd, $0, socklen_t(MemoryLayout<sockaddr_in>.size)) == 0
}
}
}

private let mockQueue = DispatchQueue(label: "test.proxy.mock-upstream")
private let clientQueue = DispatchQueue(label: "test.proxy.client")

Expand Down Expand Up @@ -128,7 +78,7 @@ final class ProxyForwarderTests: XCTestCase {

// 2. Forwarder chaining through the mock, injecting Basic u:p.
let forwarder = ProxyForwarder(
listenPort: Self.nextListenPort().rawValue,
listenPort: TestPorts.nextListenPort().rawValue,
upstream: .init(host: "127.0.0.1", port: mockPort, username: "u", password: "p", boundInterface: nil)
)
try forwarder.start()
Expand Down Expand Up @@ -168,7 +118,7 @@ final class ProxyForwarderTests: XCTestCase {
var seen = Set<UInt16>()
for _ in 0..<3 {
let forwarder = ProxyForwarder(
listenPort: Self.nextListenPort().rawValue,
listenPort: TestPorts.nextListenPort().rawValue,
upstream: .init(host: "127.0.0.1", port: 1, username: "", password: "", boundInterface: nil)
)
try forwarder.start()
Expand Down Expand Up @@ -197,7 +147,7 @@ final class ProxyForwarderTests: XCTestCase {
/// The route's stable port has to be bindable again, or turning a route off and on
/// moves it to a random port and every app pointed at the old one loses it.
func testStopReleasesThePortWhenTheCallerDropsTheForwarderAtOnce() throws {
let port = Self.nextListenPort().rawValue
let port = TestPorts.nextListenPort().rawValue
let upstream = ProxyForwarder.Upstream(host: "127.0.0.1", port: 1, username: "", password: "", boundInterface: nil)
var first: ProxyForwarder? = ProxyForwarder(listenPort: port, upstream: upstream)
try first?.start()
Expand All @@ -223,7 +173,7 @@ final class ProxyForwarderTests: XCTestCase {
/// `200 Connection established`, then echoes any subsequent bytes back.
private func startMockUpstream(gotConnect: XCTestExpectation, ready: @escaping (UInt16) -> Void) throws {
let parameters = NWParameters.tcp
parameters.requiredLocalEndpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: Self.nextListenPort())
parameters.requiredLocalEndpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: TestPorts.nextListenPort())
let listener = try NWListener(using: parameters)
self.mockListener = listener

Expand Down Expand Up @@ -337,7 +287,7 @@ final class ProxyForwarderTests: XCTestCase {
XCTAssertNotEqual(mockPort, 0, "mock SOCKS5 should report its bound port")

let forwarder = ProxyForwarder(
listenPort: Self.nextListenPort().rawValue,
listenPort: TestPorts.nextListenPort().rawValue,
upstream: .init(host: "127.0.0.1", port: mockPort, username: user, password: pass, boundInterface: nil, isSOCKS5: true)
)
try forwarder.start()
Expand Down Expand Up @@ -370,7 +320,7 @@ final class ProxyForwarderTests: XCTestCase {

// Upstream (port 1) is never dialed — isValidAuthority rejects first, so this is safe.
let forwarder = ProxyForwarder(
listenPort: Self.nextListenPort().rawValue,
listenPort: TestPorts.nextListenPort().rawValue,
upstream: .init(host: "127.0.0.1", port: 1, username: "", password: "", boundInterface: nil)
)
try forwarder.start()
Expand Down Expand Up @@ -415,7 +365,7 @@ final class ProxyForwarderTests: XCTestCase {
XCTAssertNotEqual(portA, portB, "the two mock upstreams must be distinct")

let forwarder = ProxyForwarder(
listenPort: Self.nextListenPort().rawValue,
listenPort: TestPorts.nextListenPort().rawValue,
upstream: .init(host: "127.0.0.1", port: portA, username: "u", password: "p", boundInterface: nil)
)
try forwarder.start()
Expand Down Expand Up @@ -461,7 +411,7 @@ final class ProxyForwarderTests: XCTestCase {
private func startMockSOCKS5(expectAuth: Bool, expectedUser: String, expectedPass: String,
gotConnect: XCTestExpectation, ready: @escaping (UInt16) -> Void) throws {
let parameters = NWParameters.tcp
parameters.requiredLocalEndpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: Self.nextListenPort())
parameters.requiredLocalEndpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: TestPorts.nextListenPort())
let listener = try NWListener(using: parameters)
self.mockListener = listener
listener.stateUpdateHandler = { [weak self] state in
Expand Down Expand Up @@ -570,7 +520,7 @@ final class ProxyForwarderTests: XCTestCase {
storeConnection: @escaping (NWConnection) -> Void,
gotConnect: XCTestExpectation, ready: @escaping (UInt16) -> Void) throws {
let parameters = NWParameters.tcp
parameters.requiredLocalEndpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: Self.nextListenPort())
parameters.requiredLocalEndpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: TestPorts.nextListenPort())
let listener = try NWListener(using: parameters)
storeListener(listener)
listener.stateUpdateHandler = { state in
Expand Down Expand Up @@ -672,7 +622,7 @@ final class ProxyForwarderTests: XCTestCase {
private func assertLocalAuth(clientAuthLine: String?, expect: String) throws {
let got = expectation(description: "client received the expected refusal")
let forwarder = ProxyForwarder(
listenPort: Self.nextListenPort().rawValue,
listenPort: TestPorts.nextListenPort().rawValue,
upstream: .init(host: "127.0.0.1", port: 1, username: "up", password: "pw", boundInterface: nil),
localSecret: Self.testSecret
)
Expand Down Expand Up @@ -717,7 +667,7 @@ final class ProxyForwarderTests: XCTestCase {
wait(for: [mockReady], timeout: 5.0)

let forwarder = ProxyForwarder(
listenPort: Self.nextListenPort().rawValue,
listenPort: TestPorts.nextListenPort().rawValue,
upstream: .init(host: "127.0.0.1", port: mockPort, username: "up", password: "pw", boundInterface: nil),
localSecret: Self.testSecret
)
Expand Down Expand Up @@ -758,7 +708,7 @@ final class ProxyForwarderTests: XCTestCase {
wait(for: [mockReady], timeout: 5.0)

let forwarder = ProxyForwarder(
listenPort: Self.nextListenPort().rawValue,
listenPort: TestPorts.nextListenPort().rawValue,
upstream: .init(host: "127.0.0.1", port: mockPort, username: "up", password: "pw", boundInterface: nil),
localSecret: Self.testSecret
)
Expand Down Expand Up @@ -857,7 +807,7 @@ final class ProxyForwarderTests: XCTestCase {
XCTAssertNotEqual(mockPort, 0)

let forwarder = ProxyForwarder(
listenPort: Self.nextListenPort().rawValue,
listenPort: TestPorts.nextListenPort().rawValue,
upstream: .init(host: "127.0.0.1", port: mockPort, username: "", password: "", boundInterface: nil)
)
try forwarder.start()
Expand Down Expand Up @@ -900,7 +850,7 @@ final class ProxyForwarderTests: XCTestCase {
XCTAssertNotEqual(mockPort, 0)

let forwarder = ProxyForwarder(
listenPort: Self.nextListenPort().rawValue,
listenPort: TestPorts.nextListenPort().rawValue,
upstream: .init(host: "127.0.0.1", port: mockPort, username: "", password: "", boundInterface: nil)
)
try forwarder.start()
Expand All @@ -925,7 +875,7 @@ final class ProxyForwarderTests: XCTestCase {
private func assertMalformedConnectAuthorityReturns400(authority: String) throws {
let got400 = expectation(description: "client received 400 Bad Request for authority '\(authority)'")
let forwarder = ProxyForwarder(
listenPort: Self.nextListenPort().rawValue,
listenPort: TestPorts.nextListenPort().rawValue,
upstream: .init(host: "127.0.0.1", port: 1, username: "", password: "", boundInterface: nil)
)
try forwarder.start()
Expand Down Expand Up @@ -965,7 +915,7 @@ final class ProxyForwarderTests: XCTestCase {
XCTAssertNotEqual(mockPort, 0)

let forwarder = ProxyForwarder(
listenPort: Self.nextListenPort().rawValue,
listenPort: TestPorts.nextListenPort().rawValue,
upstream: .init(host: "127.0.0.1", port: mockPort, username: "", password: "", boundInterface: nil)
)
try forwarder.start()
Expand All @@ -990,7 +940,7 @@ final class ProxyForwarderTests: XCTestCase {
/// forwarder dialed upstream with.
private func startCapturingHTTPMock(onHead: @escaping (String) -> Void, ready: @escaping (UInt16) -> Void) throws {
let parameters = NWParameters.tcp
parameters.requiredLocalEndpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: Self.nextListenPort())
parameters.requiredLocalEndpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: TestPorts.nextListenPort())
let listener = try NWListener(using: parameters)
self.mockListener = listener
listener.stateUpdateHandler = { [weak self] state in
Expand Down
Loading
Loading