Files

282 lines
12 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import Darwin
import Foundation
import Network
import TermindApplication
import TermindDomain
@MainActor
public final class LANHostDiscovery: LocalHostDiscovering {
public let connectionProtocol: LocalDiscoveryProtocol
public init(connectionProtocol: LocalDiscoveryProtocol = .ssh) {
self.connectionProtocol = connectionProtocol
}
public func discover() async throws -> [DiscoveredLocalHost] {
let round = DiscoveryRound(connectionProtocol: connectionProtocol)
return try await round.run()
}
}
@MainActor
private final class DiscoveryRound: NSObject, @preconcurrency NetServiceDelegate {
private let connectionProtocol: LocalDiscoveryProtocol
private var browsers: [NWBrowser] = []
private var discoveredServices: Set<String> = []
private var services: [NetService] = []
private var results: [DiscoveredLocalHost] = []
private var failure: LocalDiscoveryError?
private var active = true
init(connectionProtocol: LocalDiscoveryProtocol) {
self.connectionProtocol = connectionProtocol
super.init()
}
func run() async throws -> [DiscoveredLocalHost] {
let candidates = Array(Set(LocalIPv4Interface.current().flatMap(\.scanAddresses))).sorted()
let deadline = ContinuousClock.now.advanced(by: .seconds(6))
let policy = LocalDiscoveryProbePolicy(connectionProtocol: connectionProtocol)
for type in policy.bonjourServices {
let parameters = NWParameters.tcp
parameters.prohibitedInterfaceTypes = [.cellular, .loopback, .other]
let browser = NWBrowser(for: .bonjour(type: type, domain: "local."), using: parameters)
browser.browseResultsChangedHandler = { [weak self] results, _ in
Task { @MainActor in self?.found(results) }
}
browser.stateUpdateHandler = { [weak self] state in
Task { @MainActor in
guard let self, self.active else { return }
switch state {
case .failed(let error), .waiting(let error):
self.failure = error == .dns(-65570) ? .permissionDenied : .failed
default: break
}
}
}
browsers.append(browser)
browser.start(queue: .main)
}
defer {
active = false
for browser in browsers {
browser.browseResultsChangedHandler = nil
browser.stateUpdateHandler = nil
browser.cancel()
}
for service in services {
service.stop()
service.delegate = nil
}
}
await withTaskGroup(of: (DiscoveredLocalHost?, LocalDiscoveryError?).self) { group in
var iterator = candidates.makeIterator()
func enqueue(_ address: String) {
group.addTask {
guard !Task.isCancelled, ContinuousClock.now < deadline else { return (nil, nil) }
let probe = await LocalPortProbe(policy: policy)
do {
let found = try await probe.run(address, deadline: deadline)
let host: DiscoveredLocalHost? =
found
? .init(
name: address, address: address, port: policy.port, source: .probe,
connectionProtocol: policy.connectionProtocol) : nil
return (host, nil)
} catch {
return (nil, error as? LocalDiscoveryError)
}
}
}
for _ in 0..<min(24, candidates.count) {
if let address = iterator.next() { enqueue(address) }
}
for await (result, error) in group {
if let result { results.append(result) }
if let error { failure = error }
if !Task.isCancelled, ContinuousClock.now < deadline, let address = iterator.next() {
enqueue(address)
}
}
}
if !policy.bonjourServices.isEmpty {
try await Task.sleep(until: deadline, clock: .continuous)
}
try Task.checkCancellation()
if results.isEmpty, let failure { throw failure }
// Bonjour can discover IPv6-only peers even when there is no IPv4 interface to sweep.
if !LocalIPv4Interface.hasPhysicalNetwork, results.isEmpty { throw LocalDiscoveryError.unavailable }
return results
}
private func found(_ results: Set<NWBrowser.Result>) {
guard active else { return }
for result in results {
guard services.count < 200,
result.interfaces.contains(where: { $0.type == .wifi || $0.type == .wiredEthernet }),
case .service(let name, let type, let domain, _) = result.endpoint,
discoveredServices.insert("\(name)|\(type)|\(domain)").inserted
else { continue }
let service = NetService(domain: domain, type: type, name: name)
services.append(service)
service.delegate = self
service.resolve(withTimeout: 2)
}
}
func netServiceDidResolveAddress(_ sender: NetService) {
guard active, let hostname = sender.hostName, !hostname.isEmpty, (1...65_535).contains(sender.port) else {
return
}
let addresses = Set((sender.addresses ?? []).compactMap(Self.numericAddress))
guard !addresses.isEmpty else { return }
results.append(
.init(
name: sender.name, address: hostname, port: sender.port, source: .bonjour,
resolvedAddresses: addresses))
}
private static func numericAddress(_ data: Data) -> String? {
data.withUnsafeBytes { bytes in
guard bytes.count >= MemoryLayout<sockaddr>.size, let base = bytes.baseAddress else { return nil }
let address = base.assumingMemoryBound(to: sockaddr.self)
guard address.pointee.sa_len <= bytes.count,
address.pointee.sa_family == AF_INET || address.pointee.sa_family == AF_INET6
else { return nil }
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
guard
getnameinfo(
address, socklen_t(address.pointee.sa_len), &buffer, socklen_t(buffer.count),
nil, 0, NI_NUMERICHOST) == 0
else { return nil }
return String(decoding: buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }, as: UTF8.self).lowercased()
}
}
}
@MainActor
private final class LocalPortProbe {
private let policy: LocalDiscoveryProbePolicy
private var connection: NWConnection?
private var continuation: CheckedContinuation<Bool, any Error>?
private var timeout: Task<Void, Never>?
private var bytes = Data()
init(policy: LocalDiscoveryProbePolicy) { self.policy = policy }
func run(_ address: String, deadline: ContinuousClock.Instant) async throws -> Bool {
try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
guard !Task.isCancelled else {
continuation.resume(returning: false)
return
}
self.continuation = continuation
let parameters = NWParameters.tcp
// Wired Ethernet is also allowed; excluded interfaces keep probes off VPN/cellular paths.
parameters.prohibitedInterfaceTypes = [.cellular, .loopback, .other]
let connection = NWConnection(
host: .init(address), port: NWEndpoint.Port(rawValue: UInt16(policy.port))!, using: parameters)
self.connection = connection
connection.stateUpdateHandler = { [weak self] state in
Task { @MainActor in
switch state {
case .ready:
guard let self else { return }
if self.policy.acceptsConnection(received: self.bytes) {
self.finish(true)
} else {
self.receive()
}
case .waiting(let error), .failed(let error):
guard let self else { return }
if self.connection?.currentPath?.unsatisfiedReason == .localNetworkDenied
|| error == .posix(.EACCES) || error == .posix(.EPERM) || error == .dns(-65570)
{
self.finish(false, error: .permissionDenied)
} else if case .failed = state {
self.finish(false)
}
case .cancelled: self?.finish(false)
default: break
}
}
}
timeout = Task { [weak self] in
try? await Task.sleep(
until: min(deadline, .now.advanced(by: .milliseconds(450))), clock: .continuous)
if !Task.isCancelled { self?.finish(false) }
}
connection.start(queue: .global(qos: .utility))
}
} onCancel: {
Task { @MainActor [weak self] in self?.finish(false) }
}
}
private func receive() {
guard continuation != nil else { return }
connection?.receive(minimumIncompleteLength: 1, maximumLength: 2048 - bytes.count) {
[weak self] data, _, complete, error in
Task { @MainActor in
guard let self, self.continuation != nil else { return }
if let data { self.bytes.append(data) }
if self.policy.acceptsConnection(received: self.bytes) {
self.finish(true)
} else if complete || error != nil || self.bytes.count >= 2048 {
self.finish(false)
} else {
self.receive()
}
}
}
}
private func finish(_ result: Bool, error: LocalDiscoveryError? = nil) {
let pending = continuation
continuation = nil
timeout?.cancel()
timeout = nil
connection?.stateUpdateHandler = nil
connection?.cancel()
connection = nil
if let error {
pending?.resume(throwing: error)
} else {
pending?.resume(returning: result)
}
}
}
nonisolated struct LocalDiscoveryProbePolicy: Sendable {
let connectionProtocol: LocalDiscoveryProtocol
var port: Int { connectionProtocol.port }
var bonjourServices: [String] {
connectionProtocol == .ssh ? ["_ssh._tcp", "_sftp-ssh._tcp"] : []
}
func acceptsConnection(received data: Data) -> Bool {
// Telnet discovery is a port scan; unlike SSH, there is no mandatory server-first banner.
connectionProtocol == .telnet || SSHIdentification.isPresent(in: data)
}
}
nonisolated enum SSHIdentification {
static func isPresent(in data: Data) -> Bool {
let lines = data.split(separator: 10, omittingEmptySubsequences: false)
return lines.dropLast().contains { line in
let identification = line.last == 13 ? line.dropLast() : line[...]
guard line.count < 255, identification.allSatisfy({ (32...126).contains($0) }),
let text = String(data: Data(identification), encoding: .ascii)
else { return false }
return (text.hasPrefix("SSH-2.0-") || text.hasPrefix("SSH-1.99-"))
&& text.dropFirst(8).first.map { !$0.isWhitespace } == true
}
}
}