126 lines
5.1 KiB
Swift
126 lines
5.1 KiB
Swift
import Foundation
|
|||
|
|
import Network
|
||
|
|
|
||
|
|
/**
|
||
|
|
Small connection probes, used for two jobs:
|
||
|
|
|
||
|
|
* is something already listening on ProdDash's port (a server started over
|
||
|
|
SSH or from the .command file, which the launcher offers to take over), and
|
||
|
|
* can this machine actually reach the gear the modules are configured for —
|
||
|
|
which on macOS 15+ is the only practical way to tell whether the Local
|
||
|
|
Network permission was granted, because there is no API that reports it.
|
||
|
|
|
||
|
|
Network.framework rather than a bare socket on purpose: a blocked local
|
||
|
|
network connection surfaces here as a plain unreachable/timeout instead of a
|
||
|
|
silent hang.
|
||
|
|
*/
|
||
|
|
enum Net {
|
||
|
|
|
||
|
|
enum Probe: Equatable {
|
||
|
|
case open // something accepted the connection
|
||
|
|
case refused // nothing listening, but the host answered
|
||
|
|
case unreachable // no route — or macOS is withholding local network access
|
||
|
|
case timedOut
|
||
|
|
case failed(String)
|
||
|
|
|
||
|
|
var isReachableHost: Bool { self == .open || self == .refused }
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Probe host:port, calling back on the main queue exactly once.
|
||
|
|
static func probe(host: String, port: UInt16, timeout: TimeInterval = 2.0,
|
||
|
|
completion: @escaping (Probe) -> Void) {
|
||
|
|
guard let nwPort = NWEndpoint.Port(rawValue: port) else {
|
||
|
|
return DispatchQueue.main.async { completion(.failed("invalid port")) }
|
||
|
|
}
|
||
|
|
let tcp = NWProtocolTCP.Options()
|
||
|
|
tcp.connectionTimeout = max(1, Int(timeout))
|
||
|
|
tcp.noDelay = true
|
||
|
|
let connection = NWConnection(host: NWEndpoint.Host(host), port: nwPort,
|
||
|
|
using: NWParameters(tls: nil, tcp: tcp))
|
||
|
|
|
||
|
|
var settled = false
|
||
|
|
let finish: (Probe) -> Void = { result in
|
||
|
|
guard !settled else { return }
|
||
|
|
settled = true
|
||
|
|
connection.cancel()
|
||
|
|
DispatchQueue.main.async { completion(result) }
|
||
|
|
}
|
||
|
|
|
||
|
|
connection.stateUpdateHandler = { state in
|
||
|
|
switch state {
|
||
|
|
case .ready:
|
||
|
|
finish(.open)
|
||
|
|
case .waiting(let error):
|
||
|
|
// "waiting" means it would keep retrying — for a probe that is the answer.
|
||
|
|
finish(map(error))
|
||
|
|
case .failed(let error):
|
||
|
|
finish(map(error))
|
||
|
|
case .cancelled:
|
||
|
|
finish(.failed("cancelled"))
|
||
|
|
default:
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
connection.start(queue: .global(qos: .utility))
|
||
|
|
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + timeout) { finish(.timedOut) }
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Blocking form, for preflight on a background queue. Never call it on main.
|
||
|
|
static func probeSync(host: String, port: UInt16, timeout: TimeInterval = 2.0) -> Probe {
|
||
|
|
let semaphore = DispatchSemaphore(value: 0)
|
||
|
|
var result: Probe = .timedOut
|
||
|
|
probe(host: host, port: port, timeout: timeout) { outcome in
|
||
|
|
result = outcome
|
||
|
|
semaphore.signal()
|
||
|
|
}
|
||
|
|
_ = semaphore.wait(timeout: .now() + timeout + 1.0)
|
||
|
|
return result
|
||
|
|
}
|
||
|
|
|
||
|
|
private static func map(_ error: NWError) -> Probe {
|
||
|
|
if case .posix(let code) = error {
|
||
|
|
switch code {
|
||
|
|
case .ECONNREFUSED: return .refused
|
||
|
|
case .EHOSTUNREACH, .ENETUNREACH, .EHOSTDOWN, .ENETDOWN: return .unreachable
|
||
|
|
case .ETIMEDOUT: return .timedOut
|
||
|
|
default: return .failed(String(describing: code))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return .failed(error.localizedDescription)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Is this address on this building's network (so the Local Network
|
||
|
|
/// permission applies) rather than out on the internet?
|
||
|
|
static func isLocalAddress(_ host: String) -> Bool {
|
||
|
|
let name = host.lowercased()
|
||
|
|
if name == "localhost" || name.hasSuffix(".local") || name.hasSuffix(".lan") { return true }
|
||
|
|
let parts = name.split(separator: ".").map(String.init)
|
||
|
|
guard parts.count == 4, let a = Int(parts[0]), let b = Int(parts[1]),
|
||
|
|
parts.allSatisfy({ Int($0) != nil }) else {
|
||
|
|
// A bare hostname with no dots is a LAN name; anything else is the internet.
|
||
|
|
return !name.contains(".")
|
||
|
|
}
|
||
|
|
if a == 10 || a == 127 { return true }
|
||
|
|
if a == 192 && b == 168 { return true }
|
||
|
|
if a == 172 && (16...31).contains(b) { return true }
|
||
|
|
if a == 169 && b == 254 { return true }
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
/// PIDs listening on a TCP port, so a stray ProdDash can be handed over.
|
||
|
|
static func listenerPIDs(port: Int) -> [pid_t] {
|
||
|
|
let task = Process()
|
||
|
|
task.executableURL = URL(fileURLWithPath: "/usr/sbin/lsof")
|
||
|
|
task.arguments = ["-t", "-iTCP:\(port)", "-sTCP:LISTEN"]
|
||
|
|
let pipe = Pipe()
|
||
|
|
task.standardOutput = pipe
|
||
|
|
task.standardError = FileHandle.nullDevice
|
||
|
|
do { try task.run() } catch { return [] }
|
||
|
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||
|
|
task.waitUntilExit()
|
||
|
|
return (String(data: data, encoding: .utf8) ?? "")
|
||
|
|
.split(whereSeparator: \.isNewline)
|
||
|
|
.compactMap { pid_t($0.trimmingCharacters(in: .whitespaces)) }
|
||
|
|
}
|
||
|
|
}
|