Добавь установку Swift-приложения VPN
This commit is contained in:
594
app/Sources/LemanaVPN/VPNManager.swift
Normal file
594
app/Sources/LemanaVPN/VPNManager.swift
Normal file
@@ -0,0 +1,594 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user