import Combine import Foundation struct VPNEvent: Decodable { let event: String var ip: String? var expires: String? var remaining_sec: Int? var attempt: Int? var delay: Int? var reason: String? var message: String? var tier: Int? var modules: ModuleStatus? } struct VPNStatusResponse: Decodable { var modules: ModuleStatus? } struct ModuleStatus: Decodable { struct Core: Decodable { var openconnect: Bool var openconnect_lite: Bool var config: Bool var openconnect_lite_config: Bool } struct ToggleModule: Decodable { var enabled: Bool var installed: Bool } struct Keychain: Decodable { var password: Bool var totp_seed: Bool } struct DnsCleanup: Decodable { var installed: Bool } struct Patches: Decodable { var active: Bool var backup: Bool } struct AppModule: Decodable { var installed: Bool var autostart: Bool } var core: Core var bitwarden: ToggleModule var touchid: ToggleModule var keychain: Keychain var dns_cleanup: DnsCleanup var patches: Patches var app: AppModule? var hasWarnings: Bool { let coreReady = core.openconnect && core.openconnect_lite && core.openconnect_lite_config let bitwardenReady = !bitwarden.enabled || bitwarden.installed let touchReady = !touchid.enabled || touchid.installed let keychainReady = keychain.password && keychain.totp_seed let appReady = app?.installed ?? true return !coreReady || !bitwardenReady || !touchReady || !keychainReady || !dns_cleanup.installed || !patches.active || !appReady } var systemImage: String { hasWarnings ? "exclamationmark.triangle" : "checkmark.circle" } var summary: String { let coreState = core.openconnect && core.openconnect_lite && core.openconnect_lite_config ? "✅ core" : "⚠️ core" let bwState = bitwarden.enabled ? (bitwarden.installed ? "✅ bw" : "⚠️ bw") : "⏭️ bw" let touchState = touchid.enabled ? (touchid.installed ? "✅ touch" : "⚠️ touch") : "⏭️ touch" let dnsState = dns_cleanup.installed ? "✅ dns" : "⚠️ dns" let appState = app.map { $0.installed ? "✅ app" : "⚠️ app" } ?? "❔ app" let autostartState = app.map { $0.autostart ? "✅ autostart" : "⏭️ autostart" } ?? "❔ autostart" let patchState = patches.active ? "✅ patches" : "⚠️ patches" let keychainState = "\(keychain.password && keychain.totp_seed ? "✅" : "⚠️") kc \(keychain.password ? "pass" : "-")/\(keychain.totp_seed ? "totp" : "-")" return [coreState, bwState, touchState, dnsState, appState, autostartState, patchState, keychainState].joined(separator: " | ") } } enum VPNState: Equatable { case disconnected case unlocking(tier: String) case connecting case connected(ip: String, expiresAt: Date?, remainingSec: Int) case reconnecting(attempt: Int) case error(message: String) var label: String { switch self { case .disconnected: return "disconnected" case .unlocking(let t): return "unlocking(\(t))" case .connecting: return "connecting" case .connected(let ip, _, let r): return "connected(ip=\(ip), remaining=\(r)s)" case .reconnecting(let a): return "reconnecting(#\(a))" case .error(let m): return "error(\(m))" } } } enum VPNLaunchMode: String { case auto case manual var cliArgument: String { switch self { case .auto: return "--auto" case .manual: return "--manual" } } } @MainActor class VPNManager: ObservableObject { @Published var state: VPNState = .disconnected @Published var lastError: String? @Published var tunnelHealthy: Bool = true @Published var moduleSummary: String = "modules loading..." @Published var moduleStatusSystemImage: String = "hourglass" private var process: Process? private var outputPipe: Pipe? private var stderrPipe: Pipe? private var timer: Timer? private var healthTimer: Timer? private var healthCheckTarget: String? private var sessionExpiresAt: Date? private var currentIP: String = "" private var userInitiatedDisconnect: Bool = false private var autoReconnectAttempts: Int = 0 private var reconnectTimer: Timer? private var consecutiveHealthFailures: Int = 0 private var currentLaunchMode: VPNLaunchMode = .auto private let healthCheckInterval: TimeInterval = 10 private let maxAutoReconnectAttempts: Int = 3 private let maxHealthFailuresBeforeKill: Int = 3 private let autoReconnectDelay: TimeInterval = 5 private let scriptPath: String = { let home = FileManager.default.homeDirectoryForCurrentUser.path return "\(home)/bin/vpn-lemanapro.sh" }() init() { refreshStatus() } var isRunning: Bool { process?.isRunning ?? false } var remainingTime: String { guard let expires = sessionExpiresAt else { return "" } let remaining = Int(expires.timeIntervalSinceNow) guard remaining > 0 else { return "expired" } let h = remaining / 3600 let m = (remaining % 3600) / 60 return String(format: "%d:%02d", h, m) } var statusBarTitle: String { switch state { case .connected: return remainingTime default: return "" } } func refreshStatus() { let proc = Process() proc.executableURL = URL(fileURLWithPath: "/bin/bash") proc.arguments = ["-l", scriptPath, "--status", "--json"] proc.environment = processEnvironment() let pipe = Pipe() proc.standardOutput = pipe proc.standardError = FileHandle.nullDevice proc.terminationHandler = { [weak self] _ in let data = pipe.fileHandleForReading.readDataToEndOfFile() guard let self = self else { return } let text = String(data: data, encoding: .utf8) ?? "" let lastLine = text.split(separator: "\n").map(String.init).last ?? "" Task { @MainActor in guard !lastLine.isEmpty, let jsonData = lastLine.data(using: .utf8) else { self.moduleSummary = "modules unavailable" self.moduleStatusSystemImage = "questionmark.circle" self.log("[modules] status refresh returned no JSON output") return } let response: VPNStatusResponse do { response = try JSONDecoder().decode(VPNStatusResponse.self, from: jsonData) } catch { self.moduleSummary = "modules unavailable" self.moduleStatusSystemImage = "exclamationmark.triangle" let compact = text.replacingOccurrences(of: "\n", with: "\\n") let preview = compact.count > 500 ? String(compact.prefix(500)) + "..." : compact self.log("[modules] status decode failed: \(error.localizedDescription); output=\(preview)") return } guard let modules = response.modules else { self.moduleSummary = "modules unavailable: update CLI" self.moduleStatusSystemImage = "exclamationmark.triangle" self.log("[modules] status has no modules field; reinstall CLI with install.sh") return } self.moduleSummary = modules.summary self.moduleStatusSystemImage = modules.systemImage } } do { try proc.run() } catch { log("[modules] status refresh failed: \(error.localizedDescription)") } } func connect(mode: VPNLaunchMode = .auto) { guard !isRunning else { log("connect() called but process already running") return } currentLaunchMode = mode log("-- VPN connect requested (\(mode.rawValue)) --") refreshStatus() state = .connecting lastError = nil sessionExpiresAt = nil currentIP = "" userInitiatedDisconnect = false reconnectTimer?.invalidate() reconnectTimer = nil let proc = Process() proc.executableURL = URL(fileURLWithPath: "/bin/bash") proc.arguments = ["-l", scriptPath, "--json", mode.cliArgument] proc.environment = processEnvironment() let stdoutPipe = Pipe() proc.standardOutput = stdoutPipe let errPipe = Pipe() proc.standardError = errPipe self.process = proc self.outputPipe = stdoutPipe self.stderrPipe = errPipe stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in let data = handle.availableData guard !data.isEmpty else { handle.readabilityHandler = nil return } if let text = String(data: data, encoding: .utf8) { let lines = text.split(separator: "\n").map(String.init) Task { @MainActor in for jsonLine in lines { self?.parseEvent(jsonLine) } } } } errPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in let data = handle.availableData guard !data.isEmpty else { handle.readabilityHandler = nil return } if let text = String(data: data, encoding: .utf8) { let lines = text.split(separator: "\n", omittingEmptySubsequences: true).map(String.init) Task { @MainActor in for line in lines { self?.log("[script] \(line)") } } } } proc.terminationHandler = { [weak self] proc in Task { @MainActor in self?.handleTermination(exitCode: proc.terminationStatus) } } do { try proc.run() log("Process started (pid=\(proc.processIdentifier))") } catch { log("Process launch failed: \(error.localizedDescription)") state = .error(message: error.localizedDescription) } } func disconnect() { guard let proc = process, proc.isRunning else { log("disconnect() called but no running process") reconnectTimer?.invalidate() reconnectTimer = nil if case .reconnecting = state { state = .disconnected } return } userInitiatedDisconnect = true log("-- VPN disconnect requested (SIGINT -> pid \(proc.processIdentifier)) --") proc.interrupt() } func quit() { log("-- App quit --") userInitiatedDisconnect = true reconnectTimer?.invalidate() reconnectTimer = nil disconnect() stopTimer() stopHealthCheck() } private func parseEvent(_ jsonString: String) { guard let data = jsonString.data(using: .utf8), let event = try? JSONDecoder().decode(VPNEvent.self, from: data) else { log("[stdout] (non-JSON) \(jsonString)") return } if let modules = event.modules { moduleSummary = modules.summary moduleStatusSystemImage = modules.systemImage } log("[event] \(event.event)" + { var extras: [String] = [] if let ip = event.ip { extras.append("ip=\(ip)") } if let exp = event.expires { extras.append("expires=\(exp)") } if let r = event.remaining_sec { extras.append("remaining=\(r)s") } if let a = event.attempt { extras.append("attempt=\(a)") } if let m = event.message { extras.append("msg=\(m)") } if let r = event.reason { extras.append("reason=\(r)") } return extras.isEmpty ? "" : " (\(extras.joined(separator: ", ")))" }()) Task { @MainActor in let prevState = state.label switch event.event { case "modules": return case "bw_cached": state = .unlocking(tier: "cached") case "bw_touchid": state = .unlocking(tier: "Touch ID") case "bw_manual": state = .unlocking(tier: "manual") case "bw_synced": log(" Credentials synced to Keychain") return case "keychain_ready": log(" LDAP credentials are ready in macOS Keychain") return case "keychain_required": log(" LDAP credentials are missing or incomplete") return case "log": if let message = event.message { log(" \(message)") } return case "waiting": if let message = event.message { log(" \(message)") } return case "connecting": state = .connecting case "connected": currentIP = event.ip ?? "" if let isoStr = event.expires { sessionExpiresAt = ISO8601DateFormatter().date(from: isoStr) } let remaining = event.remaining_sec ?? Int(sessionExpiresAt?.timeIntervalSinceNow ?? 0) state = .connected(ip: currentIP, expiresAt: sessionExpiresAt, remainingSec: remaining) autoReconnectAttempts = 0 startTimer() detectVPNDNS() case "session_info": currentIP = event.ip ?? currentIP if let isoStr = event.expires { sessionExpiresAt = ISO8601DateFormatter().date(from: isoStr) } let remaining = event.remaining_sec ?? Int(sessionExpiresAt?.timeIntervalSinceNow ?? 0) state = .connected(ip: currentIP, expiresAt: sessionExpiresAt, remainingSec: remaining) case "disconnected": state = .disconnected stopTimer() stopHealthCheck() case "reconnecting": state = .reconnecting(attempt: event.attempt ?? 1) case "dns_cleanup": log(" DNS cleanup in progress") return case "error": lastError = event.message state = .error(message: event.message ?? "Unknown error") default: log(" Unknown event type, ignoring") return } if prevState != state.label { log("[state] \(prevState) -> \(state.label)") } } } private func handleTermination(exitCode: Int32) { log("-- Process terminated (exit=\(exitCode), userInitiated=\(userInitiatedDisconnect)) --") stopTimer() stopHealthCheck() process = nil outputPipe = nil stderrPipe = nil refreshStatus() let prevState = state.label if userInitiatedDisconnect { state = .disconnected userInitiatedDisconnect = false autoReconnectAttempts = 0 } else if currentLaunchMode == .manual { log("Manual connection ended; auto-reconnect is disabled for manual mode") state = .disconnected autoReconnectAttempts = 0 } else if [0, 2, 130, 143].contains(exitCode) { scheduleAutoReconnect(reason: "session ended (exit \(exitCode))") } else { scheduleAutoReconnect(reason: "process crashed (exit \(exitCode))") } if prevState != state.label { log("[state] \(prevState) -> \(state.label)") } } private func scheduleAutoReconnect(reason: String) { autoReconnectAttempts += 1 if autoReconnectAttempts > maxAutoReconnectAttempts { log("Auto-reconnect limit reached (\(maxAutoReconnectAttempts) attempts). Giving up.") state = .error(message: "Не удалось переподключиться (\(autoReconnectAttempts - 1) попыток)") autoReconnectAttempts = 0 return } log("Auto-reconnect #\(autoReconnectAttempts)/\(maxAutoReconnectAttempts) in \(Int(autoReconnectDelay))s (reason: \(reason))") state = .reconnecting(attempt: autoReconnectAttempts) reconnectTimer?.invalidate() reconnectTimer = Timer.scheduledTimer(withTimeInterval: autoReconnectDelay, repeats: false) { [weak self] _ in Task { @MainActor in guard let self = self else { return } self.reconnectTimer = nil guard case .reconnecting = self.state else { self.log("Auto-reconnect cancelled (state changed)") return } self.connect(mode: self.currentLaunchMode) } } } private func startTimer() { stopTimer() timer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in Task { @MainActor in guard let self = self else { return } if case .connected = self.state { let remaining = Int(self.sessionExpiresAt?.timeIntervalSinceNow ?? 0) self.state = .connected( ip: self.currentIP, expiresAt: self.sessionExpiresAt, remainingSec: remaining ) } } } } private func stopTimer() { timer?.invalidate() timer = nil } private func detectVPNDNS() { log("[health] Detecting VPN DNS via scutil --dns...") let proc = Process() proc.executableURL = URL(fileURLWithPath: "/usr/sbin/scutil") proc.arguments = ["--dns"] let pipe = Pipe() proc.standardOutput = pipe proc.standardError = FileHandle.nullDevice proc.terminationHandler = { [weak self] _ in let data = pipe.fileHandleForReading.readDataToEndOfFile() guard let output = String(data: data, encoding: .utf8) else { return } var dnsServer: String? let resolvers = output.components(separatedBy: "resolver #") for resolver in resolvers { guard resolver.contains("Supplemental") else { continue } for line in resolver.components(separatedBy: "\n") { let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.hasPrefix("nameserver[0]"), let ip = trimmed.components(separatedBy: " : ").last? .trimmingCharacters(in: .whitespaces), ip.hasPrefix("10.") { dnsServer = ip break } } if dnsServer != nil { break } } Task { @MainActor in guard let self = self else { return } self.healthCheckTarget = dnsServer if let dns = dnsServer { self.log("[health] VPN DNS discovered: \(dns)") self.startHealthCheck() } else { self.log("[health] No VPN DNS found (no Supplemental 10.x resolver)") } } } do { try proc.run() } catch { log("[health] scutil failed: \(error.localizedDescription)") } } private func startHealthCheck() { healthTimer?.invalidate() healthTimer = nil tunnelHealthy = true consecutiveHealthFailures = 0 guard let target = healthCheckTarget else { return } log("[health] Started: ping \(target) every \(Int(healthCheckInterval))s") healthTimer = Timer.scheduledTimer(withTimeInterval: healthCheckInterval, repeats: true) { [weak self] _ in Task { @MainActor in self?.checkTunnelHealth() } } } private func stopHealthCheck() { if healthTimer != nil { log("[health] Stopped") } healthTimer?.invalidate() healthTimer = nil healthCheckTarget = nil tunnelHealthy = true consecutiveHealthFailures = 0 } private func checkTunnelHealth() { guard case .connected = state, let target = healthCheckTarget else { return } let proc = Process() proc.executableURL = URL(fileURLWithPath: "/sbin/ping") proc.arguments = ["-c", "1", "-W", "2000", target] proc.standardOutput = FileHandle.nullDevice proc.standardError = FileHandle.nullDevice let wasHealthy = tunnelHealthy proc.terminationHandler = { [weak self] p in Task { @MainActor in guard let self = self, case .connected = self.state else { return } let nowHealthy = p.terminationStatus == 0 if nowHealthy { self.consecutiveHealthFailures = 0 if !wasHealthy { self.log("[health] Recovered - ping \(target)") } } else { self.consecutiveHealthFailures += 1 if wasHealthy { self.log("[health] Unreachable - ping \(target) (fail #\(self.consecutiveHealthFailures))") } else { self.log("[health] Still unreachable (fail #\(self.consecutiveHealthFailures)/\(self.maxHealthFailuresBeforeKill))") } if self.consecutiveHealthFailures >= self.maxHealthFailuresBeforeKill { self.log("[health] Tunnel dead after \(self.consecutiveHealthFailures) failures - killing process for reconnect") self.stopHealthCheck() if let proc = self.process, proc.isRunning { proc.terminate() } return } } self.tunnelHealthy = nowHealthy } } do { try proc.run() } catch { consecutiveHealthFailures += 1 if tunnelHealthy { log("[health] ping failed to launch: \(error.localizedDescription)") } tunnelHealthy = false } } private func processEnvironment() -> [String: String] { var env = ProcessInfo.processInfo.environment let home = FileManager.default.homeDirectoryForCurrentUser.path let extraPaths = [ "\(home)/bin", "\(home)/.local/bin", "/opt/homebrew/bin", "/opt/homebrew/sbin", "/usr/local/bin", "/usr/local/sbin" ] let existingPath = env["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin" env["PATH"] = (extraPaths + [existingPath]).joined(separator: ":") env["HOME"] = home return env } private static let logURL: URL = { FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Library/Logs/LemanaVPN.log") }() private static let tsFormatter: DateFormatter = { let f = DateFormatter() f.dateFormat = "yyyy-MM-dd HH:mm:ss" f.locale = Locale(identifier: "en_US_POSIX") return f }() func log(_ message: String) { let ts = Self.tsFormatter.string(from: Date()) let line = "[\(ts)] \(message)\n" let path = Self.logURL.path if !FileManager.default.fileExists(atPath: path) { FileManager.default.createFile(atPath: path, contents: nil) } if let handle = FileHandle(forWritingAtPath: path) { handle.seekToEndOfFile() handle.write(line.data(using: .utf8) ?? Data()) handle.closeFile() } } }