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

841 lines
30 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Combine
import Foundation
struct VPNEvent: Decodable {
let event: String
var ip: String?
var expires: String?
var remaining_sec: Int?
var attempt: Int?
var delay: Int?
var reason: String?
var message: String?
var source: String?
var profile: String?
var auth_profile: String?
var tier: Int?
var modules: ModuleStatus?
}
struct VPNStatusResponse: Decodable {
var modules: ModuleStatus?
}
struct ModuleStatus: Decodable {
struct Core: Decodable {
var openconnect: Bool
var openconnect_lite: Bool
var config: Bool
var openconnect_lite_config: Bool
}
struct ToggleModule: Decodable {
var enabled: Bool
var installed: Bool
}
struct 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
}
struct Patches: Decodable {
var active: Bool
var backup: Bool
}
struct AppModule: Decodable {
var installed: Bool
var autostart: Bool
}
var core: Core
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 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 profile = credentials?.auth_profile ?? "manual"
let keychainReady: Bool
switch profile {
case "manual":
keychainReady = true
case "saved-login":
keychainReady = keychain.password
default:
keychainReady = keychain.password && keychain.totp_seed
}
let appReady = app?.installed ?? true
return !coreReady
|| !bitwardenReady
|| !touchReady
|| !keychainReady
|| !dns_cleanup.installed
|| !patches.active
|| !appReady
}
var systemImage: String {
hasWarnings ? "exclamationmark.triangle" : "checkmark.circle"
}
var summary: String {
let coreState = core.openconnect && core.openconnect_lite && core.openconnect_lite_config ? "✅ core" : "⚠️ core"
let credentialState = credentials.map { "🔐 \($0.auth_profile ?? "manual")/\($0.source)" } ?? "🔐 legacy"
let bwState = bitwarden.enabled ? (bitwarden.installed ? "✅ bw" : "⚠️ bw") : "⏭️ bw"
let touchState = touchid.enabled ? (touchid.installed ? "✅ touch" : "⚠️ touch") : "⏭️ touch"
let dnsState = dns_cleanup.installed ? "✅ dns" : "⚠️ dns"
let appState = app.map { $0.installed ? "✅ app" : "⚠️ app" } ?? "❔ app"
let autostartState = app.map { $0.autostart ? "✅ autostart" : "⏭️ autostart" } ?? "❔ autostart"
let patchState = patches.active ? "✅ patches" : "⚠️ patches"
let keychainReady: Bool
switch credentials?.auth_profile ?? "manual" {
case "manual":
keychainReady = true
case "saved-login":
keychainReady = keychain.password
default:
keychainReady = keychain.password && keychain.totp_seed
}
let keychainState = "\(keychainReady ? "" : "⚠️") kc \(keychain.password ? "pass" : "-")/\(keychain.totp_seed ? "totp" : "-")"
return [coreState, credentialState, bwState, touchState, dnsState, appState, autostartState, patchState, keychainState].joined(separator: " | ")
}
}
enum VPNState: Equatable {
case disconnected
case unlocking(tier: String)
case connecting
case connected(ip: String, expiresAt: Date?, remainingSec: Int)
case reconnecting(attempt: Int)
case error(message: String)
var label: String {
switch self {
case .disconnected: return "disconnected"
case .unlocking(let t): return "unlocking(\(t))"
case .connecting: return "connecting"
case .connected(let ip, _, let r): return "connected(ip=\(ip), remaining=\(r)s)"
case .reconnecting(let a): return "reconnecting(#\(a))"
case .error(let m): return "error(\(m))"
}
}
}
enum VPNLaunchMode: String, Identifiable {
case configured = "configured"
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 .auto: return "--auto"
case .savedLogin: return "--saved-login"
case .manual: return "--manual"
case .manualFull: return "--manual-full"
}
}
var menuTitle: String {
switch self {
case .configured: return "Подключить"
case .auto: return "Подключить автоматически"
case .savedLogin: return "Подставить только логин и пароль"
case .manual: return "Подключить с сохранёнными данными"
case .manualFull: return "Подключить полностью вручную"
}
}
}
@MainActor
class VPNManager: ObservableObject {
@Published var state: VPNState = .disconnected
@Published var lastError: String?
@Published var tunnelHealthy: Bool = true
@Published var moduleSummary: String = "modules loading..."
@Published var moduleStatusSystemImage: String = "hourglass"
@Published var moduleStatus: ModuleStatus?
private var process: Process?
private var outputPipe: Pipe?
private var stderrPipe: Pipe?
private var timer: Timer?
private var healthTimer: Timer?
private var healthCheckTarget: String?
private var sessionExpiresAt: Date?
private var currentIP: String = ""
private var userInitiatedDisconnect: Bool = false
private var autoReconnectAttempts: Int = 0
private var reconnectTimer: Timer?
private var consecutiveHealthFailures: Int = 0
private var currentLaunchMode: VPNLaunchMode = .configured
private let healthCheckInterval: TimeInterval = 10
private let maxAutoReconnectAttempts: Int = 3
private let maxHealthFailuresBeforeKill: Int = 3
private let autoReconnectDelay: TimeInterval = 5
private let scriptPath: String = {
let home = FileManager.default.homeDirectoryForCurrentUser.path
return "\(home)/bin/vpn-lemanapro.sh"
}()
init() {
refreshStatus()
}
var isRunning: Bool {
process?.isRunning ?? false
}
var remainingTime: String {
guard let expires = sessionExpiresAt else { return "" }
let remaining = Int(expires.timeIntervalSinceNow)
guard remaining > 0 else { return "expired" }
let h = remaining / 3600
let m = (remaining % 3600) / 60
return String(format: "%d:%02d", h, m)
}
var statusBarTitle: String {
switch state {
case .connected:
return remainingTime
default:
return ""
}
}
var installModeLabel: String {
moduleStatus?.app_menu?.install_label ?? "Режим: проверяю установку"
}
var installSetupHint: String? {
if let hint = moduleStatus?.app_menu?.setup_hint {
return hint
}
if moduleStatus != nil, moduleStatus?.app_menu == nil {
return "Обновите CLI: sh install.sh"
}
return nil
}
var availableLaunchModes: [VPNLaunchMode] {
guard let menu = moduleStatus?.app_menu else {
return [.configured]
}
let modes = menu.available_modes.compactMap(VPNLaunchMode.init(rawValue:))
return modes.isEmpty ? [.configured] : modes
}
func refreshStatus() {
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/bin/bash")
proc.arguments = ["-l", scriptPath, "--status", "--json"]
proc.environment = processEnvironment()
let pipe = Pipe()
proc.standardOutput = pipe
proc.standardError = FileHandle.nullDevice
proc.terminationHandler = { [weak self] _ in
let data = pipe.fileHandleForReading.readDataToEndOfFile()
guard let self = self else { return }
let text = String(data: data, encoding: .utf8) ?? ""
let lastLine = text.split(separator: "\n").map(String.init).last ?? ""
Task { @MainActor in
guard !lastLine.isEmpty, let jsonData = lastLine.data(using: .utf8) else {
self.moduleSummary = "modules unavailable"
self.moduleStatusSystemImage = "questionmark.circle"
self.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.moduleSummary = "modules unavailable"
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.moduleSummary = "modules unavailable: update CLI"
self.moduleStatusSystemImage = "exclamationmark.triangle"
self.moduleStatus = nil
self.log("[modules] status has no modules field; reinstall CLI with install.sh")
return
}
self.moduleSummary = modules.summary
self.moduleStatusSystemImage = modules.systemImage
self.moduleStatus = modules
}
}
do {
try proc.run()
} catch {
log("[modules] status refresh failed: \(error.localizedDescription)")
}
}
func connect(mode: VPNLaunchMode = .configured) {
guard !isRunning else {
log("connect() called but process already running")
return
}
currentLaunchMode = mode
log("-- VPN connect requested (\(mode.rawValue)) --")
refreshStatus()
state = .connecting
lastError = nil
sessionExpiresAt = nil
currentIP = ""
userInitiatedDisconnect = false
reconnectTimer?.invalidate()
reconnectTimer = nil
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/bin/bash")
var arguments = ["-l", scriptPath, "--json"]
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] handle in
let data = handle.availableData
guard !data.isEmpty else {
handle.readabilityHandler = nil
return
}
if let text = String(data: data, encoding: .utf8) {
let lines = text.split(separator: "\n").map(String.init)
Task { @MainActor in
for jsonLine in lines {
self?.parseEvent(jsonLine)
}
}
}
}
errPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
let data = handle.availableData
guard !data.isEmpty else {
handle.readabilityHandler = nil
return
}
if let text = String(data: data, encoding: .utf8) {
let lines = text.split(separator: "\n", omittingEmptySubsequences: true).map(String.init)
Task { @MainActor in
for line in lines {
self?.log("[script] \(line)")
}
}
}
}
proc.terminationHandler = { [weak self] proc in
Task { @MainActor in
self?.handleTermination(exitCode: proc.terminationStatus)
}
}
do {
try proc.run()
log("Process started (pid=\(proc.processIdentifier))")
} catch {
log("Process launch failed: \(error.localizedDescription)")
state = .error(message: error.localizedDescription)
}
}
func disconnect() {
guard let proc = process, proc.isRunning else {
log("disconnect() called but no running process")
reconnectTimer?.invalidate()
reconnectTimer = nil
if case .reconnecting = state { state = .disconnected }
return
}
userInitiatedDisconnect = true
log("-- VPN disconnect requested (SIGINT -> pid \(proc.processIdentifier)) --")
proc.interrupt()
}
func configureLogin() {
openTerminalCommand(name: "configure-login", command: "\(shellQuote(scriptPath)) --configure-login")
}
func quit() {
log("-- App quit --")
userInitiatedDisconnect = true
reconnectTimer?.invalidate()
reconnectTimer = nil
disconnect()
stopTimer()
stopHealthCheck()
}
private func parseEvent(_ jsonString: String) {
guard let data = jsonString.data(using: .utf8),
let event = try? JSONDecoder().decode(VPNEvent.self, from: data) else {
log("[stdout] (non-JSON) \(jsonString)")
return
}
if let modules = event.modules {
moduleSummary = modules.summary
moduleStatusSystemImage = modules.systemImage
moduleStatus = modules
}
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.auth_profile ?? event.profile { extras.append("profile=\(p)") }
return extras.isEmpty ? "" : " (\(extras.joined(separator: ", ")))"
}())
Task { @MainActor in
let prevState = state.label
switch event.event {
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 "auth_profile":
if let message = event.message {
log(" \(message)")
} else if let profile = event.profile {
log(" Auth profile: \(profile)")
}
return
case "manual_sso", "auto_sso":
let profile = event.auth_profile ?? event.profile ?? "configured"
log(" SSO mode: \(profile)")
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":
state = .connecting
case "connected":
currentIP = event.ip ?? ""
if let isoStr = event.expires {
sessionExpiresAt = ISO8601DateFormatter().date(from: isoStr)
}
let remaining = event.remaining_sec ?? Int(sessionExpiresAt?.timeIntervalSinceNow ?? 0)
state = .connected(ip: currentIP, expiresAt: sessionExpiresAt, remainingSec: remaining)
autoReconnectAttempts = 0
startTimer()
detectVPNDNS()
case "session_info":
currentIP = event.ip ?? currentIP
if let isoStr = event.expires {
sessionExpiresAt = ISO8601DateFormatter().date(from: isoStr)
}
let remaining = event.remaining_sec ?? Int(sessionExpiresAt?.timeIntervalSinceNow ?? 0)
state = .connected(ip: currentIP, expiresAt: sessionExpiresAt, remainingSec: remaining)
case "disconnected":
state = .disconnected
stopTimer()
stopHealthCheck()
case "reconnecting":
state = .reconnecting(attempt: event.attempt ?? 1)
case "dns_cleanup":
log(" DNS cleanup in progress")
return
case "error":
lastError = event.message
state = .error(message: event.message ?? "Unknown error")
default:
log(" Unknown event type, ignoring")
return
}
if prevState != state.label {
log("[state] \(prevState) -> \(state.label)")
}
}
}
private func openTerminalCommand(name: String, command: String) {
let stateDir = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".local/state/vpn-lemanapro")
let commandURL = stateDir.appendingPathComponent("\(name).command")
let script = """
#!/bin/zsh
\(command)
status=$?
printf '\\n'
if [ "$status" -eq 0 ]; then
printf 'Логин и пароль обновлены.\\n'
else
printf 'Команда завершилась с ошибкой: %s\\n' "$status"
fi
printf 'Нажмите Enter, чтобы закрыть окно...'
read -r _
exit "$status"
"""
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
state = .error(message: error.localizedDescription)
}
}
private func shellQuote(_ value: String) -> String {
"'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
private func handleTermination(exitCode: Int32) {
log("-- Process terminated (exit=\(exitCode), userInitiated=\(userInitiatedDisconnect)) --")
stopTimer()
stopHealthCheck()
process = nil
outputPipe = nil
stderrPipe = nil
refreshStatus()
let prevState = state.label
if userInitiatedDisconnect {
state = .disconnected
userInitiatedDisconnect = false
autoReconnectAttempts = 0
} else if currentLaunchMode != .auto {
log("Manual connection ended; auto-reconnect is disabled for manual mode")
state = .disconnected
autoReconnectAttempts = 0
} else if [0, 2, 130, 143].contains(exitCode) {
scheduleAutoReconnect(reason: "session ended (exit \(exitCode))")
} else {
scheduleAutoReconnect(reason: "process crashed (exit \(exitCode))")
}
if prevState != state.label {
log("[state] \(prevState) -> \(state.label)")
}
}
private func scheduleAutoReconnect(reason: String) {
autoReconnectAttempts += 1
if autoReconnectAttempts > maxAutoReconnectAttempts {
log("Auto-reconnect limit reached (\(maxAutoReconnectAttempts) attempts). Giving up.")
state = .error(message: "Не удалось переподключиться (\(autoReconnectAttempts - 1) попыток)")
autoReconnectAttempts = 0
return
}
log("Auto-reconnect #\(autoReconnectAttempts)/\(maxAutoReconnectAttempts) in \(Int(autoReconnectDelay))s (reason: \(reason))")
state = .reconnecting(attempt: autoReconnectAttempts)
reconnectTimer?.invalidate()
reconnectTimer = Timer.scheduledTimer(withTimeInterval: autoReconnectDelay, repeats: false) { [weak self] _ in
Task { @MainActor in
guard let self = self else { return }
self.reconnectTimer = nil
guard case .reconnecting = self.state else {
self.log("Auto-reconnect cancelled (state changed)")
return
}
self.connect(mode: self.currentLaunchMode)
}
}
}
private func startTimer() {
stopTimer()
timer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in
Task { @MainActor in
guard let self = self else { return }
if case .connected = self.state {
let remaining = Int(self.sessionExpiresAt?.timeIntervalSinceNow ?? 0)
self.state = .connected(
ip: self.currentIP,
expiresAt: self.sessionExpiresAt,
remainingSec: remaining
)
}
}
}
}
private func stopTimer() {
timer?.invalidate()
timer = nil
}
private func detectVPNDNS() {
log("[health] Detecting VPN DNS via scutil --dns...")
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/usr/sbin/scutil")
proc.arguments = ["--dns"]
let pipe = Pipe()
proc.standardOutput = pipe
proc.standardError = FileHandle.nullDevice
proc.terminationHandler = { [weak self] _ in
let data = pipe.fileHandleForReading.readDataToEndOfFile()
guard let output = String(data: data, encoding: .utf8) else { return }
var dnsServer: String?
let resolvers = output.components(separatedBy: "resolver #")
for resolver in resolvers {
guard resolver.contains("Supplemental") else { continue }
for line in resolver.components(separatedBy: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("nameserver[0]"),
let ip = trimmed.components(separatedBy: " : ").last?
.trimmingCharacters(in: .whitespaces),
ip.hasPrefix("10.") {
dnsServer = ip
break
}
}
if dnsServer != nil { break }
}
Task { @MainActor in
guard let self = self else { return }
self.healthCheckTarget = dnsServer
if let dns = dnsServer {
self.log("[health] VPN DNS discovered: \(dns)")
self.startHealthCheck()
} else {
self.log("[health] No VPN DNS found (no Supplemental 10.x resolver)")
}
}
}
do {
try proc.run()
} catch {
log("[health] scutil failed: \(error.localizedDescription)")
}
}
private func startHealthCheck() {
healthTimer?.invalidate()
healthTimer = nil
tunnelHealthy = true
consecutiveHealthFailures = 0
guard let target = healthCheckTarget else { return }
log("[health] Started: ping \(target) every \(Int(healthCheckInterval))s")
healthTimer = Timer.scheduledTimer(withTimeInterval: healthCheckInterval, repeats: true) { [weak self] _ in
Task { @MainActor in
self?.checkTunnelHealth()
}
}
}
private func stopHealthCheck() {
if healthTimer != nil {
log("[health] Stopped")
}
healthTimer?.invalidate()
healthTimer = nil
healthCheckTarget = nil
tunnelHealthy = true
consecutiveHealthFailures = 0
}
private func checkTunnelHealth() {
guard case .connected = state, let target = healthCheckTarget else { return }
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/sbin/ping")
proc.arguments = ["-c", "1", "-W", "2000", target]
proc.standardOutput = FileHandle.nullDevice
proc.standardError = FileHandle.nullDevice
let wasHealthy = tunnelHealthy
proc.terminationHandler = { [weak self] p in
Task { @MainActor in
guard let self = self, case .connected = self.state else { return }
let nowHealthy = p.terminationStatus == 0
if nowHealthy {
self.consecutiveHealthFailures = 0
if !wasHealthy {
self.log("[health] Recovered - ping \(target)")
}
} else {
self.consecutiveHealthFailures += 1
if wasHealthy {
self.log("[health] Unreachable - ping \(target) (fail #\(self.consecutiveHealthFailures))")
} else {
self.log("[health] Still unreachable (fail #\(self.consecutiveHealthFailures)/\(self.maxHealthFailuresBeforeKill))")
}
if self.consecutiveHealthFailures >= self.maxHealthFailuresBeforeKill {
self.log("[health] Tunnel dead after \(self.consecutiveHealthFailures) failures - killing process for reconnect")
self.stopHealthCheck()
if let proc = self.process, proc.isRunning {
proc.terminate()
}
return
}
}
self.tunnelHealthy = nowHealthy
}
}
do {
try proc.run()
} catch {
consecutiveHealthFailures += 1
if tunnelHealthy {
log("[health] ping failed to launch: \(error.localizedDescription)")
}
tunnelHealthy = false
}
}
private func processEnvironment() -> [String: String] {
var env = ProcessInfo.processInfo.environment
let home = FileManager.default.homeDirectoryForCurrentUser.path
let extraPaths = [
"\(home)/bin",
"\(home)/.local/bin",
"/opt/homebrew/bin",
"/opt/homebrew/sbin",
"/usr/local/bin",
"/usr/local/sbin"
]
let existingPath = env["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin"
env["PATH"] = (extraPaths + [existingPath]).joined(separator: ":")
env["HOME"] = home
return env
}
private static let logURL: URL = {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Logs/LemanaVPN.log")
}()
private static let tsFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd HH:mm:ss"
f.locale = Locale(identifier: "en_US_POSIX")
return f
}()
func log(_ message: String) {
let ts = Self.tsFormatter.string(from: Date())
let line = "[\(ts)] \(message)\n"
let path = Self.logURL.path
if !FileManager.default.fileExists(atPath: path) {
FileManager.default.createFile(atPath: path, contents: nil)
}
if let handle = FileHandle(forWritingAtPath: path) {
handle.seekToEndOfFile()
handle.write(line.data(using: .utf8) ?? Data())
handle.closeFile()
}
}
}