Добавь установку Swift-приложения VPN

This commit is contained in:
2026-05-19 12:46:32 +03:00
parent d999be49ee
commit 89e899dfa1
8 changed files with 992 additions and 6 deletions

View 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()
}
}
}