import Combine import Foundation struct VPNEvent: Decodable { let event: String var ip: String? var expires: String? var started_at: String? var attempt_id: String? var remaining_sec: Int? var attempt: Int? var delay: Int? var reason: String? var message: String? var source: String? var mode: String? var profile: String? var auth_profile: String? var tier: Int? var modules: ModuleStatus? var result: String? } struct VPNStatusResponse: Decodable { var modules: ModuleStatus? } struct ModuleStatus: Decodable { struct Auth: Decodable { var mode: String var source: String var auto_source: String var label: String var detail: String var ready: Bool var setup_hint: String? } struct Logging: Decodable { var max_bytes: Int } struct Mode: Decodable { var id: String var label: String var ready: Bool var setup_hint: String? } 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 Credentials: Decodable { var source: String var auth_profile: String? var login_ready: Bool? var keychain_ready: Bool } struct AppMenu: Decodable { var install_label: String var configured_mode: String var available_modes: [String] var setup_hint: String? } struct Keychain: Decodable { var password: Bool var totp_seed: Bool } struct DnsCleanup: Decodable { var installed: Bool var recovery_supported: Bool? var stop_supported: Bool? } struct Patches: Decodable { var active: Bool var backup: Bool } struct AppModule: Decodable { var installed: Bool var autostart: Bool } var core: Core var mode: Mode? var credentials: Credentials? var app_menu: AppMenu? var bitwarden: ToggleModule var touchid: ToggleModule var keychain: Keychain var dns_cleanup: DnsCleanup var patches: Patches var app: AppModule? var logging: Logging? var auth: Auth? 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 modeId = mode?.id ?? credentials?.auth_profile ?? "manual-keychain" let keychainReady: Bool if let auth = auth { keychainReady = auth.ready } else { switch modeId { case "manual-keychain", "saved-login": keychainReady = mode?.ready ?? keychain.password case "keychain-auto", "bitwarden-auto", "auto", "saved-totp": keychainReady = keychain.password && keychain.totp_seed default: keychainReady = false } } let appReady = app?.installed ?? true return !coreReady || !bitwardenReady || !touchReady || !keychainReady || !dns_cleanup.installed || !patches.active || !appReady } var systemImage: String { hasWarnings ? "exclamationmark.triangle" : "checkmark.circle" } } enum VPNState: Equatable { case disconnected case disconnecting 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 .disconnecting: return "disconnecting" 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, Identifiable { case configured = "configured" case manualKeychain = "manual-keychain" case bitwardenAuto = "bitwarden-auto" // Hidden compatibility fallback for older CLI status responses. case auto = "auto" case savedLogin = "saved-login" case manual = "saved-totp" case manualFull = "manual-full" var id: String { rawValue } var cliArgument: String? { switch self { case .configured: return nil case .manualKeychain: return "manual-keychain" case .bitwardenAuto: return "bitwarden-auto" case .auto: return "--auto" case .savedLogin: return "--saved-login" case .manual: return "--manual" case .manualFull: return "--manual-full" } } } struct VPNLineBuffer { private var pending = Data() mutating func append(_ data: Data) -> [String] { pending.append(data) var lines: [String] = [] while let end = pending.firstIndex(of: 10) { if let line = String(data: pending[..? private var quitCompletion: ((Bool) -> Void)? private let disconnectTimeout: TimeInterval private var terminalError: String? private var currentAttemptID: String? private var healthCheckGeneration = 0 private var consecutiveHealthFailures: Int = 0 private var fileLog: BoundedLog private var logWriteFailed = false private let healthCheckInterval: TimeInterval = 10 private let maxHealthFailuresBeforeRestart: Int = 3 private let scriptPath: String init(scriptPath: String? = nil, logURL: URL? = nil, disconnectTimeout: TimeInterval = 45) { let home = FileManager.default.homeDirectoryForCurrentUser self.scriptPath = scriptPath ?? home.appendingPathComponent("bin/vpn-lemanapro.sh").path self.fileLog = BoundedLog(url: logURL ?? home.appendingPathComponent("Library/Logs/LemanaVPN.log")) self.disconnectTimeout = disconnectTimeout refreshConnectionStatus() } var isRunning: Bool { process?.isRunning ?? false } var canSetAuth: Bool { guard canConfigureCredentials, moduleStatus?.auth != nil else { return false } switch state { case .connecting, .unlocking, .reconnecting, .disconnecting: return false default: return true } } var canConnect: Bool { canConfigureCredentials && !isRunning && !userInitiatedDisconnect && moduleStatus != nil && moduleStatus?.auth?.ready != false } var needsDisconnectBeforeQuit: Bool { if case .reconnecting = state { return true } return isRunning || sessionReady || userInitiatedDisconnect || state == .disconnecting } var canRetryDisconnect: Bool { userInitiatedDisconnect && disconnectDeadline == nil && !stopInFlight } var canConfigureCredentials: Bool { connectionReady && !isCheckingConnection && !isExternallyActive && !isSavingAuth && state != .disconnecting } func refreshConnectionStatus() { guard !isCheckingConnection, !isSavingAuth, !stopInFlight else { return } if isRunning { refreshStatus() return } if case .reconnecting = state { refreshStatus() return } isCheckingConnection = true let epoch = connectionCheckEpoch connectionReady = false connectionCheckFailed = false isExternallyActive = false connectionCheckMessage = "Проверяю подключение и DNS…" refreshStatus { [weak self] succeeded in guard let self = self else { return } guard epoch == self.connectionCheckEpoch else { self.isCheckingConnection = false if !self.stopInFlight && !self.isRunning { self.refreshConnectionStatus() } return } guard succeeded else { self.finishConnectionCheck(error: "Не удалось проверить VPN. Обновите статус; если ошибка повторится, выполните sh install.sh.") return } guard self.moduleStatus?.dns_cleanup.recovery_supported == true else { self.finishConnectionCheck(error: "Для восстановления DNS обновите Lemana VPN: sh install.sh.") return } guard self.moduleStatus?.dns_cleanup.stop_supported == true else { self.finishConnectionCheck(error: "Для безопасного отключения обновите Lemana VPN: sh install.sh.") return } self.recoverDNS() } } private func finishConnectionCheck(error message: String) { if state == .disconnecting { state = .disconnected } isCheckingConnection = false connectionReady = false connectionCheckFailed = true connectionCheckMessage = message log("[dns] \(message)") if userInitiatedDisconnect || quitCompletion != nil { finishDisconnect(error: message) } } private func recoverDNS() { let epoch = connectionCheckEpoch let proc = Process() proc.executableURL = URL(fileURLWithPath: "/bin/bash") proc.arguments = [scriptPath, "--repair-dns", "--json"] proc.environment = processEnvironment() proc.standardInput = FileHandle.nullDevice proc.standardError = FileHandle.nullDevice let pipe = Pipe() proc.standardOutput = pipe proc.terminationHandler = { [weak self] process in let data = pipe.fileHandleForReading.readDataToEndOfFile() let response = try? JSONDecoder().decode(VPNEvent.self, from: data) Task { @MainActor in guard let self = self else { return } // A check started before Stop cannot complete the pending quit. if self.stopInFlight || epoch != self.connectionCheckEpoch { self.isCheckingConnection = false if !self.stopInFlight && !self.isRunning { self.refreshConnectionStatus() } return } guard response?.event == "dns_recovery", process.terminationStatus == 0, let result = response?.result, ["restored", "unchanged", "no_snapshot", "active"].contains(result) else { let detail = response?.event == "dns_recovery" && response?.result == "error" ? response?.message : nil self.finishConnectionCheck(error: detail?.isEmpty == false ? detail! : "Не удалось восстановить DNS. Обновите статус; если ошибка повторится, выполните sh install.sh.") return } self.isCheckingConnection = false if self.state == .disconnecting { self.state = .disconnected } self.isExternallyActive = result == "active" self.connectionReady = result != "active" self.connectionCheckMessage = result == "active" ? "Процессы VPN ещё работают. DNS пока не восстановлены. Дождитесь завершения и обновите статус." : (result == "restored" ? "Исходные DNS восстановлены." : nil) self.log("[dns] recovery result: \(result)") if self.userInitiatedDisconnect || self.quitCompletion != nil { self.finishDisconnect(error: result == "active" ? "Процессы VPN ещё работают. Повторите отключение после их завершения." : nil) } else if result != "active" { self.sessionReady = false self.sessionID = nil } } } do { try proc.run() } catch { finishConnectionCheck(error: "Не удалось запустить восстановление DNS. Обновите Lemana VPN: sh install.sh.") } } var remainingTime: String { sessionTiming.compactRemaining(at: Date()) ?? "" } var installModeLabel: String { moduleStatus?.app_menu?.install_label ?? "Режим: проверяю установку" } var installSetupHint: String? { if let auth = moduleStatus?.auth { return auth.setup_hint } if let hint = moduleStatus?.app_menu?.setup_hint { return hint } if moduleStatus != nil, moduleStatus?.app_menu == nil { return "Обновите CLI: sh install.sh" } return nil } func refreshStatus(completion: ((Bool) -> Void)? = nil) { guard (!isSavingAuth && !isCheckingConnection) || completion != nil else { return } statusGeneration += 1 let generation = statusGeneration let proc = Process() proc.executableURL = URL(fileURLWithPath: "/bin/bash") proc.arguments = [scriptPath, "--status", "--json"] proc.environment = processEnvironment() proc.standardInput = FileHandle.nullDevice let pipe = Pipe() proc.standardOutput = pipe proc.standardError = FileHandle.nullDevice proc.terminationHandler = { [weak self] process 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 var succeeded = false defer { completion?(succeeded) } guard generation == self.statusGeneration else { return } guard process.terminationStatus == 0 else { self.moduleStatusSystemImage = "exclamationmark.triangle" self.moduleStatus = nil self.log("[modules] status exited with code \(process.terminationStatus)") return } guard !lastLine.isEmpty, let jsonData = lastLine.data(using: .utf8) else { self.moduleStatusSystemImage = "questionmark.circle" self.moduleStatus = nil self.log("[modules] status refresh returned no JSON output") return } let response: VPNStatusResponse do { response = try JSONDecoder().decode(VPNStatusResponse.self, from: jsonData) } catch { self.moduleStatusSystemImage = "exclamationmark.triangle" self.moduleStatus = nil 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.moduleStatusSystemImage = "exclamationmark.triangle" self.moduleStatus = nil self.log("[modules] status has no modules field; reinstall CLI with install.sh") return } self.moduleStatusSystemImage = modules.systemImage self.moduleStatus = modules succeeded = true } } do { try proc.run() } catch { moduleStatus = nil moduleStatusSystemImage = "exclamationmark.triangle" log("[modules] status refresh failed: \(error.localizedDescription)") completion?(false) } } func setAuth(_ choice: String) { guard canSetAuth, ["manual", "auto", "keychain", "bitwarden"].contains(choice) else { return } isSavingAuth = true statusGeneration += 1 // Ignore refreshes started before the settings write. lastError = nil let proc = Process() proc.executableURL = URL(fileURLWithPath: "/bin/bash") proc.arguments = [scriptPath, "--set-auth=\(choice)"] proc.environment = processEnvironment() proc.standardOutput = FileHandle.nullDevice proc.standardError = FileHandle.nullDevice proc.terminationHandler = { [weak self] process in Task { @MainActor in guard let self = self else { return } if process.terminationStatus != 0 { self.isSavingAuth = false self.lastError = "Не удалось сохранить способ входа. Проверьте установку Lemana VPN." return } self.refreshStatus { succeeded in self.isSavingAuth = false if !succeeded { self.lastError = "Способ входа сохранён. Обновите статус, чтобы увидеть настройку." } } } } do { try proc.run() } catch { isSavingAuth = false lastError = "Не удалось запустить настройку способа входа. Обновите установку." } } func connect(mode: VPNLaunchMode = .configured) { guard canConnect else { log("connect() skipped until connection checks and settings are ready") return } log("-- VPN connect requested (\(mode.rawValue)) --") refreshStatus() state = .connecting terminalError = nil lastError = nil sessionTiming = SessionTiming() outputBuffer = VPNLineBuffer() currentIP = "" userInitiatedDisconnect = false sessionID = UUID().uuidString.lowercased() sessionReady = false let proc = Process() proc.executableURL = URL(fileURLWithPath: "/bin/bash") var arguments = [scriptPath, "--json", "--session-id=\(sessionID!)"] if let cliArgument = mode.cliArgument { arguments.append(cliArgument) } proc.arguments = arguments 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, weak proc] handle in let data = handle.availableData guard !data.isEmpty else { handle.readabilityHandler = nil return } Task { @MainActor in guard let self = self, let proc = proc, self.process === proc else { return } for jsonLine in self.outputBuffer.append(data) { 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 guard self?.process === proc else { return } 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 disconnectDeadline == nil, !stopInFlight else { return } userInitiatedDisconnect = true connectionCheckEpoch += 1 connectionReady = false lastError = nil state = .disconnecting stopTimer() stopHealthCheck() log("-- VPN session stop requested --") disconnectDeadline = Task { [weak self] in guard let self = self else { return } do { try await Task.sleep(nanoseconds: UInt64(self.disconnectTimeout * 1_000_000_000)) } catch { return } let pendingStop = self.stopProcess self.stopProcess = nil self.stopInFlight = false if pendingStop?.isRunning == true { pendingStop?.terminate() } self.finishDisconnect(error: "Отключение не завершилось. Приложение осталось открытым; повторите отключение.") } if sessionReady { stopSession() } else if !isRunning { refreshConnectionStatus() } // If launch is still registering, session_ready will perform the pending stop. } private func stopSession() { guard !stopInFlight, let sessionID = sessionID else { return } stopInFlight = true let proc = Process() stopProcess = proc proc.executableURL = URL(fileURLWithPath: "/bin/bash") proc.arguments = [scriptPath, "--stop-session=\(sessionID)", "--json"] proc.environment = processEnvironment() proc.standardInput = FileHandle.nullDevice proc.standardError = FileHandle.nullDevice let pipe = Pipe() proc.standardOutput = pipe proc.terminationHandler = { [weak self] process in let data = pipe.fileHandleForReading.readDataToEndOfFile() let event = try? JSONDecoder().decode(VPNEvent.self, from: data) Task { @MainActor in guard let self = self, self.sessionID == sessionID, self.stopProcess === process else { return } self.stopProcess = nil self.stopInFlight = false guard process.terminationStatus == 0, event?.event == "session_stop", event?.result == "stopping" else { self.finishDisconnect(error: "Не удалось остановить нашу VPN-сессию. Проверьте установку: sh install.sh; затем повторите отключение.") return } self.log("[disconnect] session stop acknowledged") if !self.isRunning { self.refreshConnectionStatus() } } } do { try proc.run() } catch { stopProcess = nil stopInFlight = false finishDisconnect(error: "Не удалось запустить остановку VPN. Обновите установку: sh install.sh.") } } private func finishDisconnect(error: String?) { disconnectDeadline?.cancel() disconnectDeadline = nil if let error = error { lastError = error state = .error(message: error) connectionReady = false log("[disconnect] \(error)") } else { userInitiatedDisconnect = false sessionReady = false sessionID = nil state = .disconnected lastError = nil } let completion = quitCompletion quitCompletion = nil completion?(error == nil) } func configureLogin() { guard canConfigureCredentials else { return } openTerminalCommand(name: "configure-login", argument: "--configure-login", successMessage: "Логин и пароль обновлены.") } func importYandexKey() { guard canConfigureCredentials else { return } openTerminalCommand(name: "import-yandex-key", argument: "--import-yandex-key", successMessage: "Код импортирован. Обновите статус в меню Lemana VPN.") } func quit(completion: @escaping (Bool) -> Void) { log("-- App quit requested --") guard needsDisconnectBeforeQuit else { completion(true); return } guard quitCompletion == nil else { return } quitCompletion = completion disconnect() } 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 } 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)") } if let p = event.mode ?? event.auth_profile ?? event.profile { extras.append("mode=\(p)") } return extras.isEmpty ? "" : " (\(extras.joined(separator: ", ")))" }()) let prevState = state.label if userInitiatedDisconnect || state == .disconnecting, ["bw_cached", "bw_touchid", "bw_manual", "connecting", "connected", "session_info", "reconnecting", "disconnected", "error", "auth_rejected"].contains(event.event) { return } if terminalError != nil, ["connecting", "connected", "session_info", "reconnecting", "disconnected"].contains(event.event) { return } switch event.event { case "session_ready": sessionReady = true if userInitiatedDisconnect { if disconnectDeadline != nil { stopSession() } else { disconnect() } } return case "modules": return case "credential_source": if let message = event.message { log(" \(message)") } else if let source = event.source { log(" Credential source: \(source)") } return case "mode", "auth_profile": if let message = event.message { log(" \(message)") } else if let mode = event.mode ?? event.profile { log(" Mode: \(mode)") } return case "manual_sso", "auto_sso": let mode = event.mode ?? event.auth_profile ?? event.profile ?? "configured" log(" SSO mode: \(mode)") return case "username_required", "username_saved": if let message = event.message { log(" \(message)") } else if event.event == "username_required" { log(" LDAP username is missing; configure it in Terminal") } else { log(" LDAP username saved") } 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": currentAttemptID = nil stopHealthCheck() sessionTiming = SessionTiming() stopTimer() state = .connecting case "connected": currentAttemptID = event.attempt_id currentIP = event.ip ?? "" sessionTiming.observe(startedAt: event.started_at, expires: event.expires) let remaining = Int(sessionTiming.remaining(at: Date()) ?? Double(event.remaining_sec ?? 0)) state = .connected(ip: currentIP, expiresAt: sessionTiming.expiresAt, remainingSec: remaining) startTimer() detectVPNDNS() case "session_info": guard case .connected = state else { return } currentIP = event.ip ?? currentIP sessionTiming.observe(startedAt: event.started_at, expires: event.expires) let remaining = Int(sessionTiming.remaining(at: Date()) ?? Double(event.remaining_sec ?? 0)) state = .connected(ip: currentIP, expiresAt: sessionTiming.expiresAt, remainingSec: remaining) if timer == nil { startTimer() } case "disconnected": sessionTiming = SessionTiming() state = .disconnected stopTimer() stopHealthCheck() case "reconnecting": stopHealthCheck() sessionTiming = SessionTiming() stopTimer() state = .reconnecting(attempt: event.attempt ?? 1) case "dns_cleanup": state = .disconnecting stopTimer() stopHealthCheck() log(" DNS cleanup in progress") return case "dns_recovery": log(" DNS recovery result: \(event.result ?? "unknown")") return case "error", "auth_rejected": stopHealthCheck() sessionTiming = SessionTiming() stopTimer() terminalError = event.message ?? "Не удалось подключиться к VPN." lastError = terminalError state = .error(message: terminalError!) default: log(" Unknown event type, ignoring") return } if prevState != state.label { log("[state] \(prevState) -> \(state.label)") } } static func terminalScript(scriptPath: String, argument: String, successMessage: String) -> String { func quote(_ value: String) -> String { "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" } return """ #!/bin/zsh \(quote(scriptPath)) \(quote(argument)) command_status=$? printf '\\n' if [ "$command_status" -eq 0 ]; then printf '%s\\n' \(quote(successMessage)) else printf 'Команда завершилась с ошибкой: %s\\n' "$command_status" fi printf 'Нажмите Enter, чтобы закрыть окно...' read -r _ exit "$command_status" """ } private func openTerminalCommand(name: String, argument: String, successMessage: String) { let stateDir = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".local/state/vpn-lemanapro") let commandURL = stateDir.appendingPathComponent("\(name).command") let script = Self.terminalScript(scriptPath: scriptPath, argument: argument, successMessage: successMessage) do { try FileManager.default.createDirectory(at: stateDir, withIntermediateDirectories: true) try script.write(to: commandURL, atomically: true, encoding: .utf8) try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: commandURL.path) let proc = Process() proc.executableURL = URL(fileURLWithPath: "/usr/bin/open") proc.arguments = ["-a", "Terminal", commandURL.path] try proc.run() log("Opened Terminal credential setup: \(commandURL.path)") } catch { log("Credential setup launch failed: \(error.localizedDescription)") lastError = error.localizedDescription if !isRunning { state = .error(message: error.localizedDescription) } } } private func handleTermination(exitCode: Int32) { log("-- Process terminated (exit=\(exitCode), userInitiated=\(userInitiatedDisconnect)) --") stopTimer() sessionTiming = SessionTiming() stopHealthCheck() process = nil outputPipe = nil stderrPipe = nil let prevState = state.label if userInitiatedDisconnect { state = .disconnecting } else { if terminalError == nil && ![0, 2, 130, 143].contains(exitCode) { terminalError = exitCode == 77 ? "Автоматический вход остановлен. Проверьте пароль и одноразовый код." : "Подключение завершилось с ошибкой. Проверьте сеть и настройки входа." } lastError = terminalError state = terminalError.map { .error(message: $0) } ?? .disconnected } if prevState != state.label { log("[state] \(prevState) -> \(state.label)") } refreshConnectionStatus() } private func startTimer() { stopTimer() timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in Task { @MainActor in guard let self = self else { return } if case .connected = self.state { let remaining = Int(self.sessionTiming.remaining(at: Date()) ?? 0) self.state = .connected( ip: self.currentIP, expiresAt: self.sessionTiming.expiresAt, remainingSec: remaining ) } } } timer?.tolerance = 0.2 } private func stopTimer() { timer?.invalidate() timer = nil } private func detectVPNDNS() { let generation = healthCheckGeneration 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, self.healthCheckGeneration == generation, case .connected = self.state 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() { healthCheckGeneration += 1 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 let generation = healthCheckGeneration proc.terminationHandler = { [weak self] p in Task { @MainActor in guard let self = self, self.healthCheckGeneration == generation, 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.maxHealthFailuresBeforeRestart))") } if self.consecutiveHealthFailures >= self.maxHealthFailuresBeforeRestart { self.log("[health] Tunnel unreachable; requesting an owned-engine restart") self.restartSession() 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 restartSession() { guard !userInitiatedDisconnect, sessionReady, let sessionID = sessionID, let attemptID = currentAttemptID, !attemptID.isEmpty, let connection = process, connection.isRunning else { return } stopHealthCheck() tunnelHealthy = false let generation = healthCheckGeneration let request = Process() request.executableURL = URL(fileURLWithPath: "/bin/bash") request.arguments = [scriptPath, "--restart-session=\(sessionID)", "--restart-attempt=\(attemptID)"] request.environment = processEnvironment() request.standardInput = FileHandle.nullDevice request.standardOutput = FileHandle.nullDevice request.standardError = FileHandle.nullDevice request.terminationHandler = { [weak self] result in Task { @MainActor in guard let self = self, self.process === connection, self.sessionID == sessionID, self.healthCheckGeneration == generation, !self.userInitiatedDisconnect else { return } if result.terminationStatus != 0 { self.lastError = "Не удалось безопасно перезапустить туннель. Отключите VPN и подключитесь заново." self.log("[health] Owned-engine restart failed") } } } do { try request.run() } catch { lastError = "Не удалось запустить переподключение. Отключите VPN и подключитесь заново." } } private func processEnvironment() -> [String: String] { var env = ProcessInfo.processInfo.environment env.removeValue(forKey: "BASH_ENV") // App actions always follow the saved configuration, not a parent shell's mode. for key in ["LEMANA_VPN_MODE", "LEMANA_VPN_LOCAL_OTP", "LEMANA_VPN_AUTO_SOURCE", "LEMANA_VPN_AUTH_PROFILE", "LEMANA_VPN_CREDENTIAL_SOURCE", "LEMANA_VPN_USE_BITWARDEN"] { env.removeValue(forKey: key) } 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 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) { if let limit = moduleStatus?.logging?.max_bytes, (1...999_999_999).contains(limit) { fileLog.maxBytes = limit } let ts = Self.tsFormatter.string(from: Date()) let line = "[\(ts)] \(message)\n" do { try fileLog.append(line) } catch { if !logWriteFailed { fputs("Cannot write Lemana VPN log: \(error.localizedDescription)\n", stderr) logWriteFailed = true } } } }