Files
lemana-vpn/app/Sources/LemanaVPN/VPNManager.swift
T

1131 lines
44 KiB
Swift

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[..<end], encoding: .utf8) {
lines.append(line)
}
pending.removeSubrange(...end)
}
return lines
}
}
@MainActor
class VPNManager: ObservableObject {
@Published var state: VPNState = .disconnected
@Published var lastError: String?
@Published var tunnelHealthy: Bool = true
@Published var moduleStatusSystemImage: String = "hourglass"
@Published var moduleStatus: ModuleStatus?
@Published private(set) var sessionTiming = SessionTiming()
@Published private(set) var isSavingAuth = false
@Published private(set) var isCheckingConnection = false
@Published private(set) var connectionCheckMessage: String?
@Published private(set) var connectionCheckFailed = false
@Published private(set) var isExternallyActive = false
private var connectionReady = false
private var connectionCheckEpoch = 0
private var statusGeneration = 0
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 outputBuffer = VPNLineBuffer()
private var currentIP: String = ""
private var userInitiatedDisconnect: Bool = false
private var sessionID: String?
private var sessionReady = false
private var stopInFlight = false
private var stopProcess: Process?
private var disconnectDeadline: Task<Void, Never>?
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
}
}
}
}