mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-08 10:54:50 +00:00
221 lines
8.8 KiB
Swift
221 lines
8.8 KiB
Swift
import Foundation
|
|
import Network
|
|
import Testing
|
|
|
|
@testable import ShadowsocksX_NG2
|
|
|
|
@MainActor
|
|
struct ConnectivityHTTPTests {
|
|
@Test(arguments: [(204, false), (200, false), (302, false), (500, false), (200, true)])
|
|
func sendsGETThroughProxyAndStopsAtHeaders(status: Int, informational: Bool) async throws {
|
|
let headers =
|
|
"HTTP/1.1 \(status) Response\r\nLocation: http://another.invalid/redirect\r\nContent-Length: 99999\r\n\r\n"
|
|
let proxy = try HeaderProxy(
|
|
response: (informational ? "HTTP/1.1 100 Continue\r\n\r\n" : "") + headers)
|
|
defer { proxy.stop() }
|
|
let port = try await proxy.start()
|
|
// This name cannot resolve; a successful response proves proxy routing.
|
|
let id = NodeID.fresh()
|
|
let module = ServerConnectivity(
|
|
configuration: { _ in Self.server },
|
|
measure: { _, url, _ in
|
|
await ConnectivityHTTPRequest().perform(url: url, proxyPort: port)
|
|
})
|
|
module.setURL(URL(string: "http://target.invalid/test")!)
|
|
await module.wait(for: module.test([id]))
|
|
let value = try #require(module.history(for: id).first?.outcome)
|
|
if (200..<300).contains(status) {
|
|
guard case .success = value else {
|
|
Issue.record("Expected successful proxy response, got \(value)")
|
|
return
|
|
}
|
|
} else {
|
|
#expect(value == .http(status))
|
|
}
|
|
#expect(proxy.requests.count == 1)
|
|
#expect(proxy.requests.first?.hasPrefix("GET http://target.invalid/test HTTP/") == true)
|
|
}
|
|
|
|
@Test func informationalAndFragmentedHeadersDoNotWaitForBody() async throws {
|
|
// Leave the socket readable while the reader supplies exact response chunks.
|
|
let proxy = try HeaderProxy(response: "ready")
|
|
defer { proxy.stop() }
|
|
let port = try await proxy.start()
|
|
let result = await Task.detached {
|
|
var chunks = [
|
|
"HTTP/1.1 100 Continue\r\n\r\nHTTP/1.1 200 OK\r\nContent-Len",
|
|
"gth: 999999\r\n\r\n",
|
|
].map { Array($0.utf8) }
|
|
let cancellation = ConnectivityHTTPCreateCancellation()
|
|
defer { ConnectivityHTTPDestroyCancellation(cancellation) }
|
|
return withUnsafeMutablePointer(to: &chunks) { context in
|
|
"GET http://target.invalid/test HTTP/1.1\r\nHost: target.invalid\r\n\r\n".withCString {
|
|
ConnectivityHTTPProbeWithReader(
|
|
$0, Int32(port), 5000, cancellation,
|
|
{ buffer, capacity, context in
|
|
let chunks = context!.assumingMemoryBound(to: [[UInt8]].self)
|
|
guard !chunks.pointee.isEmpty else { return 0 }
|
|
let chunk = chunks.pointee.removeFirst()
|
|
precondition(chunk.count <= capacity)
|
|
chunk.withUnsafeBytes {
|
|
buffer!.copyMemory(from: $0.baseAddress!, byteCount: $0.count)
|
|
}
|
|
return chunk.count
|
|
}, context)
|
|
}
|
|
}
|
|
}.value
|
|
#expect(result.received_headers == 1)
|
|
#expect(result.status == 200)
|
|
#expect(result.timed_out == 0)
|
|
}
|
|
|
|
@Test func fixedAddressPreservesHostAndReportsClientPhases() async throws {
|
|
let proxy = try HeaderProxy(
|
|
response: "HTTP/1.1 204 No Content\r\nContent-Length: 99999\r\n\r\n")
|
|
defer { proxy.stop() }
|
|
let port = try await proxy.start()
|
|
let result = await ConnectivityHTTPRequest().observe(
|
|
url: URL(string: "http://target.invalid:8080/test?q=private%20value")!,
|
|
targetIP: "2001:db8::1", proxyPort: port, timeout: 5)
|
|
guard case .success = result.outcome else {
|
|
Issue.record("Expected successful fixed-address request")
|
|
return
|
|
}
|
|
let request = try #require(proxy.requests.first)
|
|
#expect(request.hasPrefix("GET http://[2001:db8::1]:8080/test?q=private%20value HTTP/1.1\r\n"))
|
|
#expect(request.contains("\r\nHost: target.invalid:8080\r\n"))
|
|
#expect(result.localTCPMilliseconds != nil)
|
|
#expect(result.sendMilliseconds != nil)
|
|
#expect(result.firstByteMilliseconds != nil)
|
|
#expect(result.headersMilliseconds != nil)
|
|
#expect(result.totalMilliseconds >= 0)
|
|
}
|
|
|
|
@Test(arguments: [
|
|
"HTTP/2 200 OK\r\n\r\n",
|
|
"HTTP/1.1 200 OK\r\nInvalid Header\r\n\r\n",
|
|
"HTTP/1.1 200 OK\r\nX-Large: " + String(repeating: "x", count: 65536),
|
|
String(repeating: "HTTP/1.1 100 Continue\r\n\r\n", count: 3000),
|
|
// Each block fits; only the cumulative budget including final headers is exceeded.
|
|
"HTTP/1.1 100 Continue\r\nX-Large: " + String(repeating: "x", count: 60000) + "\r\n\r\n"
|
|
+ "HTTP/1.1 204 No Content\r\nX-Large: " + String(repeating: "x", count: 6000) + "\r\n\r\n",
|
|
])
|
|
func rejectsMalformedOrUnboundedHeaders(response: String) async throws {
|
|
let proxy = try HeaderProxy(response: response)
|
|
defer { proxy.stop() }
|
|
let port = try await proxy.start()
|
|
let result = await ConnectivityHTTPRequest().observe(
|
|
url: URL(string: "http://target.invalid/test")!, targetIP: "192.0.2.1", proxyPort: port,
|
|
timeout: 5)
|
|
#expect(result.outcome == .network)
|
|
#expect(result.failureStage == "headers")
|
|
}
|
|
|
|
@Test func httpsIsRejectedBeforeOpeningProxyConnection() async throws {
|
|
let proxy = try HeaderProxy(
|
|
response: "HTTP/1.1 204 No Content\r\nContent-Length: 99999\r\n\r\n")
|
|
defer { proxy.stop() }
|
|
let port = try await proxy.start()
|
|
let result = await ConnectivityHTTPRequest().perform(
|
|
url: URL(string: "https://target.invalid/test")!, proxyPort: port)
|
|
#expect(result == .configuration)
|
|
#expect(proxy.requests.isEmpty)
|
|
}
|
|
|
|
@Test(.timeLimit(.minutes(1)))
|
|
func timesOutWhileWaitingForFirstByte() async throws {
|
|
let proxy = try HeaderProxy()
|
|
defer { proxy.stop() }
|
|
let port = try await proxy.start()
|
|
let result = await ConnectivityHTTPRequest().observe(
|
|
url: URL(string: "http://target.invalid/slow")!, targetIP: nil, proxyPort: port,
|
|
timeout: 0.5)
|
|
#expect(result.outcome == .requestTimeout)
|
|
#expect(result.failureStage == "first_byte")
|
|
#expect(result.totalMilliseconds >= 500)
|
|
#expect(proxy.requests.count == 1)
|
|
}
|
|
|
|
@Test func cancellationEndsRequestWithoutWaitingForHeaders() async throws {
|
|
let proxy = try HeaderProxy()
|
|
defer { proxy.stop() }
|
|
let port = try await proxy.start()
|
|
let id = NodeID.fresh()
|
|
let module = ServerConnectivity(
|
|
configuration: { _ in Self.server },
|
|
measure: { _, url, _ in
|
|
await ConnectivityHTTPRequest().perform(url: url, proxyPort: port)
|
|
})
|
|
module.setURL(URL(string: "http://target.invalid/test")!)
|
|
let batch = module.test([id])
|
|
let pendingDeadline = ContinuousClock.now.advanced(by: .seconds(2))
|
|
while proxy.requests.isEmpty, ContinuousClock.now < pendingDeadline {
|
|
try await Task.sleep(for: .milliseconds(5))
|
|
}
|
|
#expect(proxy.requests.count == 1)
|
|
let cancelledAt = ContinuousClock.now
|
|
await module.shutdown()
|
|
#expect(ContinuousClock.now - cancelledAt < .seconds(1))
|
|
await module.wait(for: batch)
|
|
#expect(module.history(for: id).isEmpty)
|
|
#expect(module.isCooling(id))
|
|
}
|
|
private static var server: SslocalServerDocument {
|
|
SslocalServerDocument(
|
|
id: "test", server: "example.com", serverPort: 443,
|
|
password: "test", method: "aes-256-gcm", plugin: nil, pluginOpts: nil)
|
|
}
|
|
}
|
|
|
|
/// Controlled HTTP proxy: headers arrive, but the declared body never does.
|
|
private final class HeaderProxy: @unchecked Sendable {
|
|
private let listener: NWListener
|
|
private let response: String?
|
|
private let lock = NSLock()
|
|
private var received: [String] = []
|
|
private var connections: [NWConnection] = []
|
|
var requests: [String] { lock.withLock { received } }
|
|
|
|
init(response: String? = nil) throws {
|
|
self.response = response
|
|
listener = try NWListener(using: .tcp, on: .any)
|
|
}
|
|
|
|
func start() async throws -> Int {
|
|
listener.newConnectionHandler = { [self] connection in
|
|
lock.withLock { connections.append(connection) }
|
|
connection.start(queue: DispatchQueue(label: UUID().uuidString))
|
|
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [self] data, _, _, _ in
|
|
if let data { lock.withLock { received.append(String(data: data, encoding: .utf8) ?? "") } }
|
|
if let response {
|
|
connection.send(content: Data(response.utf8), completion: .contentProcessed { _ in })
|
|
}
|
|
}
|
|
}
|
|
return try await withCheckedThrowingContinuation { continuation in
|
|
listener.stateUpdateHandler = { [self] state in
|
|
switch state {
|
|
case .ready:
|
|
listener.stateUpdateHandler = nil
|
|
continuation.resume(returning: Int(listener.port!.rawValue))
|
|
case .failed(let error):
|
|
listener.stateUpdateHandler = nil
|
|
continuation.resume(throwing: error)
|
|
default: break
|
|
}
|
|
}
|
|
listener.start(queue: DispatchQueue(label: UUID().uuidString))
|
|
}
|
|
}
|
|
|
|
func stop() {
|
|
listener.cancel()
|
|
listener.newConnectionHandler = nil
|
|
let connections = lock.withLock { self.connections }
|
|
for connection in connections {
|
|
connection.cancel()
|
|
}
|
|
}
|
|
}
|