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

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