mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-08 10:34:51 +00:00
238 lines
8.7 KiB
Swift
238 lines
8.7 KiB
Swift
import Darwin
|
|
import Foundation
|
|
|
|
/// An isolated process group owns sslocal and every SIP003 child. No formal
|
|
/// runtime file, ACL, activity selection, or system proxy is touched.
|
|
@MainActor
|
|
struct ConnectivityRuntime {
|
|
private struct MeasurementContext {
|
|
let id: UUID
|
|
let url: URL
|
|
let started: ContinuousClock.Instant
|
|
let deadline: ContinuousClock.Instant
|
|
let startupDeadline: ContinuousClock.Instant
|
|
let directory: URL
|
|
}
|
|
|
|
private struct ProbeProcess {
|
|
let pid: pid_t
|
|
let port: Int
|
|
}
|
|
|
|
private struct ProbeRunResult {
|
|
let outcome: ServerConnectivity.Outcome
|
|
let pid: pid_t
|
|
let reaped: Bool
|
|
let startupCompleted: Bool
|
|
}
|
|
|
|
let executable: URL
|
|
var measurement = ConnectivityMeasurement()
|
|
var temporaryRoot: URL = FileManager.default.temporaryDirectory
|
|
|
|
func measure(
|
|
_ server: SslocalServerDocument, url: URL, preparationDeadline deadline: ContinuousClock.Instant
|
|
) async -> ServerConnectivity.Outcome {
|
|
let started = ContinuousClock.now
|
|
let context = MeasurementContext(
|
|
id: UUID(), url: url, started: started, deadline: deadline,
|
|
startupDeadline: min(deadline, started.advanced(by: .seconds(10))),
|
|
directory: temporaryRoot.appendingPathComponent(UUID().uuidString))
|
|
let run = await run(server, context: context)
|
|
await cleanup(pid: run.pid, reaped: run.reaped, directory: context.directory)
|
|
return run.outcome
|
|
}
|
|
|
|
private func run(
|
|
_ server: SslocalServerDocument, context: MeasurementContext
|
|
) async -> ProbeRunResult {
|
|
var pid: pid_t = 0
|
|
var reaped = false
|
|
var startupCompleted = false
|
|
var result: ServerConnectivity.Outcome = .startup
|
|
do {
|
|
try FileManager.default.createDirectory(
|
|
at: context.directory, withIntermediateDirectories: true,
|
|
attributes: [.posixPermissions: 0o700])
|
|
try Task.checkCancellation()
|
|
guard ContinuousClock.now < context.deadline else {
|
|
return ProbeRunResult(
|
|
outcome: .preparationTimeout, pid: 0, reaped: false, startupCompleted: false)
|
|
}
|
|
let process = try launch(server, in: context.directory)
|
|
pid = process.pid
|
|
let readiness = try await waitForServer(process, context: context)
|
|
reaped = readiness.reaped
|
|
startupCompleted = readiness.startupCompleted
|
|
result = readiness.outcome
|
|
} catch is CancellationError {
|
|
result = .network
|
|
} catch {
|
|
result = .startup
|
|
}
|
|
if !startupCompleted {
|
|
emitStartupFailure(result, context: context)
|
|
}
|
|
return ProbeRunResult(
|
|
outcome: result, pid: pid, reaped: reaped, startupCompleted: startupCompleted)
|
|
}
|
|
|
|
private func launch(_ server: SslocalServerDocument, in directory: URL) throws -> ProbeProcess {
|
|
let port = try freePort()
|
|
let config = directory.appendingPathComponent("test.json")
|
|
// A single TCP HTTP inbound, no ACL: every request must use this server.
|
|
let data = try JSONSerialization.data(withJSONObject: [
|
|
"servers": [try JSONSerialization.jsonObject(with: JSONEncoder().encode(server))],
|
|
"locals": [
|
|
[
|
|
"protocol": "http", "local_address": "127.0.0.1",
|
|
"local_port": port, "mode": "tcp_only",
|
|
]
|
|
],
|
|
])
|
|
try AtomicFileWriter.write(data, to: config)
|
|
return ProbeProcess(pid: try spawn(config), port: port)
|
|
}
|
|
|
|
private func waitForServer(
|
|
_ process: ProbeProcess, context: MeasurementContext
|
|
) async throws -> ProbeRunResult {
|
|
var reaped = false
|
|
while ContinuousClock.now < context.startupDeadline {
|
|
try Task.checkCancellation()
|
|
var status: Int32 = 0
|
|
if waitpid(process.pid, &status, WNOHANG) == process.pid {
|
|
reaped = true
|
|
break
|
|
}
|
|
if ownsHealthyListener(process) {
|
|
guard ContinuousClock.now < context.deadline else { break }
|
|
emitStartupSuccess(context)
|
|
let measured = await measurement.measure(
|
|
url: context.url, proxyPort: process.port, preparationDeadline: context.deadline)
|
|
for entry in measured.trace {
|
|
RuntimeLog.emit(.connectivityMeasurement(id: context.id, entry: entry))
|
|
}
|
|
return ProbeRunResult(
|
|
outcome: measured.outcome, pid: process.pid, reaped: reaped, startupCompleted: true)
|
|
}
|
|
try await Task.sleep(for: .milliseconds(25))
|
|
}
|
|
let outcome: ServerConnectivity.Outcome =
|
|
ContinuousClock.now >= context.startupDeadline
|
|
? .preparationTimeout : .startup
|
|
return ProbeRunResult(
|
|
outcome: outcome, pid: process.pid, reaped: reaped, startupCompleted: false)
|
|
}
|
|
|
|
private func ownsHealthyListener(_ process: ProbeProcess) -> Bool {
|
|
RuntimeSocketOwnershipProbe.process(process.pid, ownsTCPListenerOn: process.port)
|
|
&& EndpointHealthProbe.probe(
|
|
host: "127.0.0.1", port: process.port, timeout: 0.02) == .reachable
|
|
}
|
|
|
|
private func emitStartupSuccess(_ context: MeasurementContext) {
|
|
let parts = context.started.duration(to: .now).components
|
|
RuntimeLog.emit(
|
|
.connectivityMeasurement(
|
|
id: context.id,
|
|
entry: ConnectivityTrace(
|
|
phase: .startup, status: .success,
|
|
milliseconds: Double(parts.seconds) * 1000 + Double(parts.attoseconds) / 1e15)))
|
|
}
|
|
|
|
private func emitStartupFailure(
|
|
_ outcome: ServerConnectivity.Outcome, context: MeasurementContext
|
|
) {
|
|
let parts = context.started.duration(to: .now).components
|
|
let status: ConnectivityTrace.Status =
|
|
Task.isCancelled
|
|
? .cancelled : outcome == .preparationTimeout ? .timeout : .network
|
|
RuntimeLog.emit(
|
|
.connectivityMeasurement(
|
|
id: context.id,
|
|
entry: ConnectivityTrace(
|
|
phase: .startup, status: status,
|
|
milliseconds: Double(parts.seconds) * 1000 + Double(parts.attoseconds) / 1e15,
|
|
failureStage: .startup)))
|
|
}
|
|
|
|
private func cleanup(pid child: pid_t, reaped alreadyReaped: Bool, directory: URL) async {
|
|
// Cleanup is not cancelled with the measurement. Slots remain occupied until
|
|
// the group has been terminated and its leader reaped.
|
|
await Task {
|
|
if child > 0 {
|
|
_ = kill(-child, SIGTERM)
|
|
let deadline = ContinuousClock.now.advanced(by: .seconds(2))
|
|
var reaped = alreadyReaped
|
|
while ContinuousClock.now < deadline {
|
|
if !reaped {
|
|
var status: Int32 = 0
|
|
reaped = waitpid(child, &status, WNOHANG) == child
|
|
}
|
|
if kill(-child, 0) != 0 { break }
|
|
try? await Task.sleep(for: .milliseconds(25))
|
|
}
|
|
_ = kill(-child, SIGKILL)
|
|
if !reaped {
|
|
var status: Int32 = 0
|
|
_ = waitpid(child, &status, 0)
|
|
}
|
|
}
|
|
try? FileManager.default.removeItem(at: directory)
|
|
}.value
|
|
}
|
|
|
|
private func freePort() throws -> Int {
|
|
let descriptor = socket(AF_INET, SOCK_STREAM, 0)
|
|
guard descriptor >= 0 else { throw RuntimeError.failed }
|
|
defer { close(descriptor) }
|
|
var address = sockaddr_in()
|
|
address.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
|
|
address.sin_family = sa_family_t(AF_INET)
|
|
address.sin_addr.s_addr = inet_addr("127.0.0.1")
|
|
let bound = withUnsafePointer(to: &address) {
|
|
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
|
bind(descriptor, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
|
|
}
|
|
}
|
|
guard bound == 0 else { throw RuntimeError.failed }
|
|
var length = socklen_t(MemoryLayout<sockaddr_in>.size)
|
|
let read = withUnsafeMutablePointer(to: &address) {
|
|
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(descriptor, $0, &length) }
|
|
}
|
|
guard read == 0 else { throw RuntimeError.failed }
|
|
return Int(UInt16(bigEndian: address.sin_port))
|
|
}
|
|
|
|
private func spawn(_ config: URL) throws -> pid_t {
|
|
var attributes: posix_spawnattr_t?
|
|
posix_spawnattr_init(&attributes)
|
|
defer { posix_spawnattr_destroy(&attributes) }
|
|
posix_spawnattr_setflags(&attributes, Int16(POSIX_SPAWN_SETPGROUP))
|
|
posix_spawnattr_setpgroup(&attributes, 0)
|
|
var actions: posix_spawn_file_actions_t?
|
|
posix_spawn_file_actions_init(&actions)
|
|
defer { posix_spawn_file_actions_destroy(&actions) }
|
|
// Child output can contain plugin options and credentials; discard it.
|
|
for descriptor in [STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO] {
|
|
posix_spawn_file_actions_addopen(&actions, descriptor, "/dev/null", O_RDWR, 0)
|
|
}
|
|
let strings = [executable.path, "-c", config.path].map { strdup($0) }
|
|
defer {
|
|
for string in strings {
|
|
free(string)
|
|
}
|
|
}
|
|
var argv = strings + [nil]
|
|
var pid: pid_t = 0
|
|
let status = argv.withUnsafeMutableBufferPointer {
|
|
posix_spawn(&pid, executable.path, &actions, &attributes, $0.baseAddress!, environ)
|
|
}
|
|
guard status == 0 else { throw RuntimeError.failed }
|
|
return pid
|
|
}
|
|
|
|
private enum RuntimeError: Error { case failed }
|
|
}
|