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
28 changes: 23 additions & 5 deletions BaoLianDengTests/ProxyEngineIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,16 +89,23 @@ struct ProxyEngineIntegrationTests {
let ctx = try ProxyEngineHelper.start(config: TestConfigs.minimal)
defer { ProxyEngineHelper.stop(context: ctx) }

// curl through the SOCKS5 proxy to a reliable endpoint
let target = try #require(LocalHTTPServer(), "could not bind loopback HTTP target")
defer { target.stop() }

// `--socks5` (not `--socks5-hostname`) makes curl resolve the target
// itself, so the engine receives a raw IPv4 literal over SOCKS5 —
// exactly the shape the transparent proxy hands it for every TCP
// flow. The target is local so the assertion measures the proxy
// chain, not whether this network can reach some public host.
let result = ProxyEngineHelper.curlThroughProxy(
url: "http://www.gstatic.com/generate_204",
url: "http://127.0.0.1:\(target.port)/generate_204",
socksPort: ctx.socksPort,
timeout: 10
)

// The HTTP status code is written to stdout via --write-out
#expect(result.exitCode == 0, "curl should exit successfully")
#expect(result.output == "204", "Should receive HTTP 204 from gstatic")
#expect(result.output == "204", "Should receive HTTP 204 from the loopback target")
}

@Test("HTTP request through HTTP proxy (mixed listener)")
Expand All @@ -108,6 +115,12 @@ struct ProxyEngineIntegrationTests {

// The loopback listener is mixed SOCKS5+HTTP; local proxy mode
// points apps at its HTTP side, so exercise that here.
//
// This one deliberately keeps a public hostname: an HTTP proxy is
// handed the name, not an address, so this is the only test covering
// the domain path — rule matching on a hostname and resolution
// through the engine's own `dns:` section. Pointing it at the
// loopback target would turn it into another IP-literal test.
let result = ProxyEngineHelper.curlThroughHTTPProxy(
url: "http://www.gstatic.com/generate_204",
proxyPort: ctx.socksPort,
Expand All @@ -123,9 +136,14 @@ struct ProxyEngineIntegrationTests {
let ctx = try ProxyEngineHelper.start(config: TestConfigs.minimal)
defer { ProxyEngineHelper.stop(context: ctx) }

// Generate some traffic first
let target = try #require(LocalHTTPServer(), "could not bind loopback HTTP target")
defer { target.stop() }

// Generate some traffic first — against the local target, so a
// network that cannot reach the public internet still populates
// /connections instead of burning the full curl timeout.
_ = ProxyEngineHelper.curlThroughProxy(
url: "http://www.gstatic.com/generate_204",
url: "http://127.0.0.1:\(target.port)/generate_204",
socksPort: ctx.socksPort,
timeout: 10
)
Expand Down
151 changes: 151 additions & 0 deletions BaoLianDengTests/Utilities/ProxyEngineHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@ enum ProxyEngineHelper {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/curl")
process.arguments = proxyArgs + [
// curl 7.86+ silently bypasses the proxy for localhost targets.
// Every one of these tests exists to exercise the proxy, and the
// hermetic target IS on localhost, so an empty no-proxy list is
// load-bearing: without it the request never reaches the engine
// and the assertion passes for the wrong reason.
"--noproxy", "",
"--silent",
"--max-time", "\(timeout)",
"--write-out", "%{http_code}",
Expand All @@ -123,3 +129,148 @@ enum ProxyEngineHelper {
return (output, process.terminationStatus)
}
}

/// Minimal loopback HTTP server used as a hermetic target for the proxy-chain
/// tests. Answers every request with `204 No Content` and closes.
///
/// These tests used to curl a public host. `curl --socks5` resolves the name
/// itself and hands the engine a raw IP literal, so on any network that blocks
/// the target by IP the engine's DIRECT dial times out and the test fails for
/// reasons that have nothing to do with the proxy chain. A loopback target
/// exercises the same machinery — SOCKS5 CONNECT with ATYP=0x01, rule match,
/// DIRECT dial to an IPv4 literal, bidirectional relay — without the internet.
///
/// Call `stop()` when done; the accept loop holds a strong reference to the
/// server until the listening socket closes.
final class LocalHTTPServer {

/// Kernel-assigned port the server listens on at 127.0.0.1.
let port: UInt16

private let listenFD: Int32
private let lock = NSLock()
private var isStopped = false

/// Binds 127.0.0.1 on a free port and starts accepting. Nil if the socket
/// could not be set up.
init?() {
let fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)
guard fd >= 0 else { return nil }

var reuse: Int32 = 1
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, socklen_t(MemoryLayout<Int32>.size))

var wanted = sockaddr_in()
wanted.sin_family = sa_family_t(AF_INET)
wanted.sin_port = 0
wanted.sin_addr.s_addr = inet_addr("127.0.0.1")

let didBind = withUnsafePointer(to: &wanted) { raw in
raw.withMemoryRebound(to: sockaddr.self, capacity: 1) {
bind(fd, $0, socklen_t(MemoryLayout<sockaddr_in>.size)) == 0
}
}
guard didBind, listen(fd, 8) == 0 else {
close(fd)
return nil
}

var actual = sockaddr_in()
var length = socklen_t(MemoryLayout<sockaddr_in>.size)
let didName = withUnsafeMutablePointer(to: &actual) { raw in
raw.withMemoryRebound(to: sockaddr.self, capacity: 1) {
getsockname(fd, $0, &length) == 0
}
}
guard didName else {
close(fd)
return nil
}

listenFD = fd
port = UInt16(bigEndian: actual.sin_port)

DispatchQueue.global(qos: .userInitiated).async {
self.acceptLoop()
}
}

deinit {
stop()
}

/// Closes the listening socket, which ends the accept loop. In-flight
/// connections finish on their own. Safe to call more than once.
func stop() {
lock.lock()
let wasRunning = !isStopped
isStopped = true
lock.unlock()
if wasRunning {
close(listenFD)
}
}

private var stopped: Bool {
lock.lock()
defer { lock.unlock() }
return isStopped
}

private func acceptLoop() {
while true {
let client = accept(listenFD, nil, nil)
if client < 0 {
// stop() closed the socket, or the accept failed for good.
if stopped || errno != EINTR { return }
continue
}
DispatchQueue.global(qos: .userInitiated).async {
Self.respond(to: client)
}
}
}

/// Drains the request head, then writes a fixed `204 No Content`.
private static func respond(to fd: Int32) {
defer { close(fd) }

// A half-open peer must not wedge the thread for the whole test run.
var timeout = timeval(tv_sec: 5, tv_usec: 0)
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout<timeval>.size))

var head = [UInt8]()
var chunk = [UInt8](repeating: 0, count: 1024)
while head.count < 8192 {
let got = chunk.withUnsafeMutableBytes { buffer -> Int in
guard let base = buffer.baseAddress else { return -1 }
return read(fd, base, buffer.count)
}
if got <= 0 { break }
head.append(contentsOf: chunk[0..<got])
if headIsComplete(head) { break }
}

let response = Array("HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".utf8)
var sent = 0
while sent < response.count {
let wrote = response.withUnsafeBytes { buffer -> Int in
guard let base = buffer.baseAddress else { return -1 }
return write(fd, base + sent, buffer.count - sent)
}
if wrote <= 0 { break }
sent += wrote
}
}

/// True once `bytes` contains the CRLFCRLF head terminator.
private static func headIsComplete(_ bytes: [UInt8]) -> Bool {
guard bytes.count >= 4 else { return false }
for i in 0...(bytes.count - 4) {
if bytes[i] == 13, bytes[i + 1] == 10, bytes[i + 2] == 13, bytes[i + 3] == 10 {
return true
}
}
return false
}
}
Loading