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

595 lines
20 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 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 Keychain: Decodable {
var password: Bool
var totp_seed: Bool
}
struct DnsCleanup: Decodable {
var installed: Bool
}
struct Patches: Decodable {
var active: Bool
var backup: Bool
}
var core: Core
var bitwarden: ToggleModule
var touchid: ToggleModule
var keychain: Keychain
var dns_cleanup: DnsCleanup
var patches: Patches
var summary: String {
let coreState = core.openconnect && core.openconnect_lite && core.openconnect_lite_config ? "core ok" : "core missing"
let bwState = bitwarden.enabled ? (bitwarden.installed ? "bw on" : "bw missing") : "bw off"
let touchState = touchid.enabled ? (touchid.installed ? "touch on" : "touch missing") : "touch off"
let dnsState = dns_cleanup.installed ? "dns on" : "dns missing"
let patchState = patches.active ? "patches active" : "patches pending"
let keychainState = "kc \(keychain.password ? "pass" : "-")/\(keychain.totp_seed ? "totp" : "-")"
return [coreState, bwState, touchState, dnsState, 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))"
}
}
}
@MainActor
class VPNManager: ObservableObject {
@Published var state: VPNState = .disconnected
@Published var lastError: String?
@Published var tunnelHealthy: Bool = true
@Published var moduleSummary: String = "modules unknown"
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 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, let text = String(data: data, encoding: .utf8) else { return }
let lastLine = text.split(separator: "\n").map(String.init).last ?? ""
guard let jsonData = lastLine.data(using: .utf8),
let response = try? JSONDecoder().decode(VPNStatusResponse.self, from: jsonData),
let modules = response.modules else { return }
Task { @MainActor in
self.moduleSummary = modules.summary
}
}
do {
try proc.run()
} catch {
log("[modules] status refresh failed: \(error.localizedDescription)")
}
}
func connect() {
guard !isRunning else {
log("connect() called but process already running")
return
}
log("-- VPN connect requested --")
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"]
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
}
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 "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 "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 [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()
}
}
}
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()
}
}
}