1131 lines
44 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|
|
}
|