import Foundation /// Native port of the `ovpn connect|disconnect|status|reroute` shell /// wrapper. Everything the script did is here; the script is no longer invoked. /// /// Two rules shaped the design: /// /// 1. **The password never enters this process.** The auth file is written by /// `security` redirecting straight to disk, so the secret goes Keychain → /// file without passing through app memory. The app only ever knows the path. /// 2. **Root work goes through one system prompt.** Route pinning and launching /// openvpn are batched into a single `do shell script … with administrator /// privileges`, so macOS shows its own auth dialog once and handles the /// password itself. enum VPNService { // MARK: - Dependencies struct Requirement: Identifiable, Sendable { let id: String let title: String let detail: String let satisfied: Bool /// Shell command that would fix it, if there is an obvious one. let fix: String? } static func requirements(_ vpn: VPNConfig) async -> [Requirement] { var result: [Requirement] = [] let openvpnOK = FileManager.default.isExecutableFile(atPath: vpn.openvpnPath) result.append(Requirement( id: "openvpn", title: "OpenVPN", detail: openvpnOK ? vpn.openvpnPath : "не найден: \(vpn.openvpnPath)", satisfied: openvpnOK, fix: openvpnOK ? nil : "brew install openvpn")) let configOK = FileManager.default.isReadableFile(atPath: vpn.configPath) result.append(Requirement( id: "config", title: "Профиль .ovpn", detail: configOK ? vpn.configPath : "не найден: \(vpn.configPath)", satisfied: configOK, fix: nil)) // Either source of credentials will do; the file wins if present. let authFileOK = FileManager.default.isReadableFile(atPath: vpn.authPath) let keychainOK = authFileOK ? true : await keychainEntryExists(vpn) result.append(Requirement( id: "auth", title: "Учётные данные", detail: authFileOK ? "файл \(vpn.authPath)" : (keychainOK ? "Keychain: \(vpn.keychainService)" : "нет ни файла, ни записи в Keychain"), satisfied: authFileOK || keychainOK, fix: keychainOK || authFileOK ? nil : "security add-generic-password -s \(vpn.keychainService) " + "-a \(vpn.keychainAccount) -w")) return result } /// Existence check only — deliberately without `-w`, so no secret is read. private static func keychainEntryExists(_ vpn: VPNConfig) async -> Bool { await Probe.commandSucceeds( "/usr/bin/security", args: ["find-generic-password", "-s", vpn.keychainService, "-a", vpn.keychainAccount], timeout: 5) } // MARK: - State static func pid(_ vpn: VPNConfig) -> pid_t? { guard let text = try? String(contentsOfFile: vpn.pidPath, encoding: .utf8), let value = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) else { return nil } return value } /// Signal 0 probes for existence without delivering anything. EPERM still /// means the process is there — openvpn runs as root, we do not. static func processAlive(_ vpn: VPNConfig) -> Bool { guard let pid = pid(vpn) else { return false } if kill(pid, 0) == 0 { return true } return errno == EPERM } /// Seconds since openvpn started, from the pidfile mtime — it is written /// once at startup. macOS `ps` has no etimes field worth parsing. static func processAge(_ vpn: VPNConfig) -> Int? { guard let attrs = try? FileManager.default.attributesOfItem(atPath: vpn.pidPath), let modified = attrs[.modificationDate] as? Date else { return nil } return Int(Date().timeIntervalSince(modified)) } /// A live pid proves nothing: on an auth or TLS failure openvpn soft-restarts /// and retries forever, so the process stays up while the tunnel never comes /// back. Check the pulled address and the pushed route instead. static func tunnelUp(_ vpn: VPNConfig) async -> Bool { // Both profiles land on the same 10.8.0.0/24, so the tun address alone // cannot tell them apart. Without a known pushed LAN there is no honest // test, so fall back to the pid check rather than one that lies. guard !vpn.lanNetwork.isEmpty else { return processAlive(vpn) } guard hasTunnelAddress(vpn.tunNetwork) else { return false } return await hasLanRoute(vpn.lanNetwork) } /// Reads interface addresses directly instead of scraping `ifconfig`. private static func hasTunnelAddress(_ network: String) -> Bool { var head: UnsafeMutablePointer? guard getifaddrs(&head) == 0, let head else { return false } defer { freeifaddrs(head) } var node: UnsafeMutablePointer? = head while let current = node { defer { node = current.pointee.ifa_next } guard let addr = current.pointee.ifa_addr, addr.pointee.sa_family == sa_family_t(AF_INET) else { continue } var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) guard getnameinfo(addr, socklen_t(addr.pointee.sa_len), &buffer, socklen_t(buffer.count), nil, 0, NI_NUMERICHOST) == 0 else { continue } if String(cString: buffer).hasPrefix(network + ".") { return true } } return false } private static func hasLanRoute(_ network: String) async -> Bool { guard let output = await Probe.commandOutput( "/usr/sbin/netstat", args: ["-rn", "-f", "inet"], timeout: 5) else { return false } return output.split(separator: "\n").contains { line in let first = line.split(separator: " ").first.map(String.init) ?? "" return first == network || first.hasPrefix(network + "/") } } static func isUp(_ vpn: VPNConfig) async -> Bool { guard processAlive(vpn) else { return false } return await tunnelUp(vpn) } // MARK: - Gateway /// The LAN router, found the same way the script did: walk the configured /// network services and take the first with an IPv4 Router. Deliberately not /// the system default route — once the tunnel is up that points into it. static func findGateway() async -> String? { guard let services = await Probe.commandOutput( "/usr/sbin/networksetup", args: ["-listallnetworkservices"], timeout: 8) else { return nil } for line in services.split(separator: "\n").dropFirst() { let service = line.trimmingCharacters(in: .whitespaces) // A leading asterisk marks a disabled service. guard !service.isEmpty, !service.hasPrefix("*") else { continue } guard let info = await Probe.commandOutput( "/usr/sbin/networksetup", args: ["-getinfo", service], timeout: 8) else { continue } for infoLine in info.split(separator: "\n") where infoLine.hasPrefix("Router: ") { let value = infoLine.dropFirst("Router: ".count) .trimmingCharacters(in: .whitespaces) if isIPv4(value) { return value } } } return nil } private static func isIPv4(_ text: String) -> Bool { var addr = in_addr() return inet_pton(AF_INET, text, &addr) == 1 } }