diff --git a/.gitignore b/.gitignore index 725a02f..9cf8b85 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .DS_Store *.log *.tmp - +.build/ +app/.build/ diff --git a/README.md b/README.md index 38af1c2..1f79e45 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ CLI-установка корпоративного VPN `vpn.lemanapro.ru` для macOS. -**Модули по умолчанию:** Core: включён; Bitwarden: включён; Touch ID: включён; DNS cleanup: включён; runtime-патчи: применяются автоматически перед подключением. +**Модули по умолчанию:** Core: включён; Bitwarden: включён; Touch ID: включён; DNS cleanup: включён; Swift Menu Bar app: включён; автозапуск приложения: включён; runtime-патчи: применяются автоматически перед подключением. Репозиторий собирает в один воспроизводимый пакет то, что раньше было ручной локальной настройкой: @@ -10,6 +10,7 @@ CLI-установка корпоративного VPN `vpn.lemanapro.ru` дл - `openconnect-lite` для SAML SSO через Keycloak; - опциональный Bitwarden CLI для LDAP-пароля и TOTP seed; - опциональный Touch ID helper для мастер-пароля Bitwarden; +- Swift Menu Bar app `LemanaVPN.app`; - безопасный DNS cleanup через root-owned wrapper; - алиасы `vpn`, `vpn-debug`, `vpn-fix-dns`. @@ -80,6 +81,8 @@ curl -fsSL https://example.org/dokril/lemana-vpn/raw/branch/main/install.sh \ | `~/bin/vpn-lemanapro.sh` | Основной CLI для подключения, статуса и sync секретов | | `~/bin/uninstall-lemana-vpn.sh` | Локальный uninstall helper | | `~/bin/keychain-fingerprint` | Опциональный Touch ID helper для мастер-пароля Bitwarden | +| `~/Applications/LemanaVPN.app` | Swift Menu Bar app для подключения из status bar | +| `~/Library/LaunchAgents/ru.dokops.LemanaVPN.plist` | Автозапуск Menu Bar app при логине | | `~/.config/lemana-vpn/env` | Локальная конфигурация модулей | | `~/.config/lemana-vpn/patch-backups/` | Backup исходника `openconnect-lite` перед runtime-патчами | | `~/.config/openconnect-lite/config.toml` | Профиль SSO и auto-fill правила Keycloak | @@ -128,6 +131,9 @@ Detected state: openconnect-lite: yes Bitwarden CLI: no Touch ID helper: no + Swift: yes + Menu Bar app: no + LaunchAgent: no DNS cleanup: no sudoers: no/no shell aliases: no @@ -139,6 +145,8 @@ Detected state: - поставить ли Bitwarden CLI, если `bw` не найден; - собрать ли Touch ID helper, если его нет и Bitwarden включён; +- собрать ли Swift Menu Bar app, если `~/Applications/LemanaVPN.app` не найден; +- включить ли автозапуск Menu Bar app при логине; - настроить ли sudoers для `openconnect` и DNS cleanup; - добавить ли алиасы в `~/.zshrc`; - записать ли LDAP-пароль и TOTP seed в Keychain, если Bitwarden отключён. @@ -218,6 +226,40 @@ curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh \ sh install.sh --without-touchid ``` +### Swift Menu Bar app + +Включён по умолчанию. Установщик собирает Swift-приложение из исходников в репозитории и кладёт bundle в: + +```sh +~/Applications/LemanaVPN.app +``` + +Приложение живёт в macOS status bar, запускает `~/bin/vpn-lemanapro.sh --json`, показывает состояние VPN, IP, оставшееся время сессии, health-check тоннеля и строку состояния модулей. + +Для сборки нужен Swift 5.9+ из Xcode Command Line Tools: + +```sh +xcode-select --install +``` + +Отключить установку приложения: + +```sh +curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh | sh -s -- --without-app +``` + +Оставить приложение, но отключить автозапуск: + +```sh +curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh | sh -s -- --without-autostart +``` + +Ручной запуск: + +```sh +open ~/Applications/LemanaVPN.app +``` + ## Использование ```sh @@ -226,6 +268,7 @@ vpn --status # статус без нового подключения vpn --status --json # статус в JSON vpn-debug # видимый браузер и debug-логи vpn-fix-dns # сбросить корпоративные DNS после аварийного завершения +open ~/Applications/LemanaVPN.app # открыть Swift-приложение в menu bar ``` Первый запуск с Bitwarden: @@ -343,6 +386,7 @@ uninstall-lemana-vpn.sh - удаляет sudoers rules и DNS cleanup wrapper; - удаляет блок `lemana-vpn` из `~/.zshrc`; - удаляет `~/.config/openconnect-lite/config.toml`; +- удаляет `~/Applications/LemanaVPN.app` и LaunchAgent автозапуска; - удаляет `~/.config/lemana-vpn`, если не передан `--keep-config`. Опциональные режимы: @@ -350,6 +394,7 @@ uninstall-lemana-vpn.sh ```sh uninstall-lemana-vpn.sh --dry-run uninstall-lemana-vpn.sh --keep-config +uninstall-lemana-vpn.sh --keep-app uninstall-lemana-vpn.sh --remove-keychain uninstall-lemana-vpn.sh --remove-touchid-helper uninstall-lemana-vpn.sh --remove-openconnect-lite diff --git a/app/Package.swift b/app/Package.swift new file mode 100644 index 0000000..514d1fe --- /dev/null +++ b/app/Package.swift @@ -0,0 +1,14 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "LemanaVPN", + platforms: [.macOS(.v13)], + targets: [ + .executableTarget( + name: "LemanaVPN", + path: "Sources/LemanaVPN" + ) + ] +) + diff --git a/app/Sources/LemanaVPN/LemanaVPNApp.swift b/app/Sources/LemanaVPN/LemanaVPNApp.swift new file mode 100644 index 0000000..7461e1b --- /dev/null +++ b/app/Sources/LemanaVPN/LemanaVPNApp.swift @@ -0,0 +1,177 @@ +import AppKit +import SwiftUI + +@main +struct LemanaVPNApp: App { + @StateObject private var vpnManager = VPNManager() + + var body: some Scene { + MenuBarExtra { + VPNMenuView(vpnManager: vpnManager) + } label: { + StatusBarLabel(vpnManager: vpnManager) + } + } +} + +struct StatusBarLabel: View { + @ObservedObject var vpnManager: VPNManager + + var body: some View { + HStack(spacing: 4) { + Image(systemName: iconName) + if !vpnManager.statusBarTitle.isEmpty { + Text(vpnManager.statusBarTitle) + .monospacedDigit() + } + } + } + + private var iconName: String { + switch vpnManager.state { + case .disconnected: + return "shield.slash" + case .unlocking, .connecting: + return "shield.lefthalf.filled" + case .connected: + return vpnManager.tunnelHealthy ? "checkmark.shield" : "exclamationmark.shield" + case .reconnecting: + return "arrow.trianglehead.2.clockwise" + case .error: + return "exclamationmark.shield" + } + } +} + +struct VPNMenuView: View { + @ObservedObject var vpnManager: VPNManager + + var body: some View { + switch vpnManager.state { + case .disconnected: + disconnectedMenu + case .unlocking(let tier): + unlockingMenu(tier: tier) + case .connecting: + connectingMenu + case .connected(let ip, _, let remainingSec): + connectedMenu(ip: ip, remainingSec: remainingSec) + case .reconnecting(let attempt): + reconnectingMenu(attempt: attempt) + case .error(let message): + errorMenu(message: message) + } + + Divider() + Label(vpnManager.moduleSummary, systemImage: "puzzlepiece.extension") + .disabled(true) + Button("Обновить статус модулей") { + vpnManager.refreshStatus() + } + Divider() + Button("Открыть логи") { + let logPath = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Logs/LemanaVPN.log") + NSWorkspace.shared.open(logPath) + } + Button("Выход") { + vpnManager.quit() + NSApplication.shared.terminate(nil) + } + .keyboardShortcut("q") + } + + private var disconnectedMenu: some View { + Group { + Label("VPN отключён", systemImage: "circle") + .disabled(true) + Divider() + Button("Подключить") { + vpnManager.connect() + } + .keyboardShortcut("c") + } + } + + private func unlockingMenu(tier: String) -> some View { + Group { + Label("Bitwarden: \(tier)...", systemImage: "key") + .disabled(true) + Divider() + Button("Отменить") { + vpnManager.disconnect() + } + } + } + + private var connectingMenu: some View { + Group { + Label("Подключение...", systemImage: "circle.dotted") + .disabled(true) + Divider() + Button("Отменить") { + vpnManager.disconnect() + } + } + } + + private func connectedMenu(ip: String, remainingSec: Int) -> some View { + let hours = remainingSec / 3600 + let mins = (remainingSec % 3600) / 60 + + return Group { + Label("VPN подключён", systemImage: "circle.fill") + .disabled(true) + if !vpnManager.tunnelHealthy { + Label("Проблемы с подключением", systemImage: "exclamationmark.triangle") + .disabled(true) + } + if !ip.isEmpty { + Text("IP: \(ip)") + .foregroundColor(.secondary) + } + if remainingSec > 0 { + Text("Сессия: \(hours)ч \(mins)м") + .foregroundColor(.secondary) + } + Divider() + Button("Копировать IP") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(ip, forType: .string) + } + .keyboardShortcut("c", modifiers: [.command]) + .disabled(ip.isEmpty) + + Button("Отключить") { + vpnManager.disconnect() + } + .keyboardShortcut("d") + } + } + + private func reconnectingMenu(attempt: Int) -> some View { + Group { + Label("Переподключение (\(attempt)/3)...", systemImage: "arrow.clockwise") + .disabled(true) + Divider() + Button("Отменить") { + vpnManager.disconnect() + } + } + } + + private func errorMenu(message: String) -> some View { + Group { + Label("Ошибка", systemImage: "exclamationmark.triangle") + .disabled(true) + Text(message) + .foregroundColor(.secondary) + .lineLimit(3) + Divider() + Button("Подключить заново") { + vpnManager.connect() + } + } + } +} + diff --git a/app/Sources/LemanaVPN/VPNManager.swift b/app/Sources/LemanaVPN/VPNManager.swift new file mode 100644 index 0000000..9a1a1b6 --- /dev/null +++ b/app/Sources/LemanaVPN/VPNManager.swift @@ -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() + } + } +} diff --git a/install.sh b/install.sh index 456b85c..7af8521 100755 --- a/install.sh +++ b/install.sh @@ -15,6 +15,8 @@ USE_BITWARDEN=1 USE_TOUCHID=1 INSTALL_SUDOERS=1 INSTALL_ALIASES=1 +INSTALL_APP=1 +INSTALL_AUTOSTART=1 CONFIGURE_KEYCHAIN=0 DRY_RUN=0 FORCE=0 @@ -23,7 +25,11 @@ BITWARDEN_FORCED=0 TOUCHID_FORCED=0 SUDOERS_FORCED=0 SHELL_FORCED=0 +APP_FORCED=0 +AUTOSTART_FORCED=0 CONFIGURE_KEYCHAIN_FORCED=0 +APP_DIR="${LEMANA_VPN_APP_DIR:-$HOME/Applications/LemanaVPN.app}" +LAUNCH_AGENT="$HOME/Library/LaunchAgents/ru.dokops.LemanaVPN.plist" usage() { cat <<'USAGE' @@ -41,6 +47,10 @@ Options: --raw-base-url URL Raw file base URL for curl installs --no-sudoers Do not install sudoers rules --no-shell Do not update ~/.zshrc aliases + --with-app Build/install macOS menu bar app (default) + --without-app Do not build/install macOS menu bar app + --with-autostart Install LaunchAgent for menu bar app (default) + --without-autostart Do not install LaunchAgent --interactive Ask before installing optional missing modules --non-interactive Use selected/default modules without prompts --minimal Same as --without-bitwarden --without-touchid @@ -100,6 +110,24 @@ while [ "$#" -gt 0 ]; do INSTALL_ALIASES=0 SHELL_FORCED=1 ;; + --with-app) + INSTALL_APP=1 + APP_FORCED=1 + ;; + --without-app) + INSTALL_APP=0 + INSTALL_AUTOSTART=0 + APP_FORCED=1 + AUTOSTART_FORCED=1 + ;; + --with-autostart) + INSTALL_AUTOSTART=1 + AUTOSTART_FORCED=1 + ;; + --without-autostart) + INSTALL_AUTOSTART=0 + AUTOSTART_FORCED=1 + ;; --interactive) INTERACTIVE=1 ;; --non-interactive) INTERACTIVE=0 ;; --minimal) @@ -215,6 +243,9 @@ print_detected_state() { log " DNS cleanup: $(bool_word test -x "$DNS_CLEANUP")" log " sudoers: $(bool_word test -f /etc/sudoers.d/lemana-vpn-openconnect)/$(bool_word test -f /etc/sudoers.d/lemana-vpn-dns)" log " shell aliases: $(bool_word zsh_aliases_installed)" + log " Swift: $(bool_word command -v swift)" + log " Menu Bar app: $(bool_word test -x "$APP_DIR/Contents/MacOS/LemanaVPN")" + log " LaunchAgent: $(bool_word test -f "$LAUNCH_AGENT")" log " Keychain password: $(bool_word keychain_has openconnect-lite "$USERNAME")" log " Keychain TOTP seed: $(bool_word keychain_has openconnect-lite "totp/$USERNAME")" } @@ -269,6 +300,23 @@ choose_modules() { fi fi + if [ "$APP_FORCED" -eq 0 ] && ! [ -x "$APP_DIR/Contents/MacOS/LemanaVPN" ]; then + if yes_no "Swift Menu Bar app не найден. Собрать и установить LemanaVPN.app?" y; then + INSTALL_APP=1 + else + INSTALL_APP=0 + INSTALL_AUTOSTART=0 + fi + fi + + if [ "$AUTOSTART_FORCED" -eq 0 ] && [ "$INSTALL_APP" -eq 1 ] && ! [ -f "$LAUNCH_AGENT" ]; then + if yes_no "Включить автозапуск LemanaVPN.app при логине?" y; then + INSTALL_AUTOSTART=1 + else + INSTALL_AUTOSTART=0 + fi + fi + if [ "$CONFIGURE_KEYCHAIN_FORCED" -eq 0 ] && [ "$USE_BITWARDEN" -eq 0 ]; then if ! keychain_has openconnect-lite "$USERNAME" || ! keychain_has openconnect-lite "totp/$USERNAME"; then if yes_no "Bitwarden отключён, а Keychain credentials неполные. Записать LDAP-пароль и TOTP seed после установки?" y; then @@ -432,6 +480,92 @@ install_touchid_helper() { run install -m 700 "$tmp/keychain-fingerprint-bin" "$INSTALL_BIN_DIR/keychain-fingerprint" } +install_menu_bar_app() { + [ "$INSTALL_APP" -eq 1 ] || return 0 + + need_cmd swift + + tmp="$1" + app_src="$tmp/app" + run mkdir -p "$app_src/Sources/LemanaVPN" + + download_file "app/Package.swift" "$app_src/Package.swift" + download_file "app/Sources/LemanaVPN/LemanaVPNApp.swift" "$app_src/Sources/LemanaVPN/LemanaVPNApp.swift" + download_file "app/Sources/LemanaVPN/VPNManager.swift" "$app_src/Sources/LemanaVPN/VPNManager.swift" + + log "Building LemanaVPN.app" + run swift build -c release --package-path "$app_src" + + app_bin="$app_src/.build/release/LemanaVPN" + info_plist="$tmp/Info.plist" + + if [ "$DRY_RUN" -eq 0 ] && [ ! -x "$app_bin" ]; then + die "Swift build did not produce $app_bin" + fi + + run mkdir -p "$APP_DIR/Contents/MacOS" "$APP_DIR/Contents/Resources" + run install -m 755 "$app_bin" "$APP_DIR/Contents/MacOS/LemanaVPN" + + write_file "$info_plist" ' + + + + CFBundleExecutable + LemanaVPN + CFBundleIdentifier + ru.dokops.LemanaVPN + CFBundleName + LemanaVPN + CFBundleDisplayName + LemanaVPN + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 13.0 + LSUIElement + + +' + run install -m 644 "$info_plist" "$APP_DIR/Contents/Info.plist" +} + +install_launch_agent() { + [ "$INSTALL_AUTOSTART" -eq 1 ] || return 0 + [ "$INSTALL_APP" -eq 1 ] || return 0 + + tmp="$1" + plist="$tmp/ru.dokops.LemanaVPN.plist" + + run mkdir -p "$HOME/Library/LaunchAgents" + write_file "$plist" " + + + + Label + ru.dokops.LemanaVPN + ProgramArguments + + $APP_DIR/Contents/MacOS/LemanaVPN + + RunAtLoad + + KeepAlive + + +" + run install -m 644 "$plist" "$LAUNCH_AGENT" + if [ "$DRY_RUN" -eq 0 ]; then + launchctl unload "$LAUNCH_AGENT" >/dev/null 2>&1 || true + launchctl load "$LAUNCH_AGENT" >/dev/null 2>&1 || true + else + printf '+ launchctl load %s\n' "$LAUNCH_AGENT" + fi +} + install_shell_aliases() { [ "$INSTALL_ALIASES" -eq 1 ] || return 0 @@ -491,7 +625,7 @@ main() { choose_modules log "Installing Lemana VPN" - log "Modules: bitwarden=$USE_BITWARDEN touchid=$USE_TOUCHID sudoers=$INSTALL_SUDOERS shell=$INSTALL_ALIASES" + log "Modules: bitwarden=$USE_BITWARDEN touchid=$USE_TOUCHID sudoers=$INSTALL_SUDOERS shell=$INSTALL_ALIASES app=$INSTALL_APP autostart=$INSTALL_AUTOSTART" install_homebrew_packages install_openconnect_lite @@ -500,6 +634,8 @@ main() { install_dns_cleanup "$tmp" install_sudoers "$tmp" install_touchid_helper "$tmp" + install_menu_bar_app "$tmp" + install_launch_agent "$tmp" install_shell_aliases "$tmp" maybe_login_bitwarden @@ -512,6 +648,9 @@ main() { log "Open a new shell or run: exec zsh" log "Connect: vpn" log "Status: vpn --status" + if [ "$INSTALL_APP" -eq 1 ]; then + log "App: open '$APP_DIR'" + fi } main "$@" diff --git a/tests/smoke.sh b/tests/smoke.sh index 14b45b1..e9674b9 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -15,8 +15,9 @@ output="$(cd "$ROOT" && sh install.sh --dry-run --non-interactive --minimal)" printf '%s\n' "$output" | grep -q 'Detected state:' printf '%s\n' "$output" | grep -q 'Interactive prompts: off' -printf '%s\n' "$output" | grep -q 'Modules: bitwarden=0 touchid=0 sudoers=1 shell=1' +printf '%s\n' "$output" | grep -q 'Modules: bitwarden=0 touchid=0 sudoers=1 shell=1 app=1 autostart=1' printf '%s\n' "$output" | grep -q 'sudo install -d -m 755 -o root -g wheel /usr/local/sbin' +printf '%s\n' "$output" | grep -q 'swift build -c release --package-path' +printf '%s\n' "$output" | grep -q 'launchctl load' printf 'smoke ok\n' - diff --git a/uninstall.sh b/uninstall.sh index 5fbb18f..e475eb5 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -7,8 +7,11 @@ OC_CONFIG_DIR="${OPENCONNECT_LITE_CONFIG_DIR:-$HOME/.config/openconnect-lite}" OC_VENV="${LEMANA_VPN_OC_VENV:-$HOME/.local/pipx/venvs/openconnect-lite}" DNS_CLEANUP="${LEMANA_VPN_DNS_CLEANUP:-/usr/local/sbin/lemana-vpn-dns-cleanup}" USERNAME="${LEMANA_VPN_USERNAME:-60103293}" +APP_DIR="${LEMANA_VPN_APP_DIR:-$HOME/Applications/LemanaVPN.app}" +LAUNCH_AGENT="$HOME/Library/LaunchAgents/ru.dokops.LemanaVPN.plist" DRY_RUN=0 KEEP_CONFIG=0 +KEEP_APP=0 REMOVE_KEYCHAIN=0 REMOVE_TOUCHID_HELPER=0 REMOVE_OPENCONNECT_LITE=0 @@ -20,6 +23,7 @@ Usage: Options: --keep-config Keep ~/.config/lemana-vpn + --keep-app Keep ~/Applications/LemanaVPN.app and LaunchAgent --remove-keychain Remove VPN-related Keychain entries --remove-touchid-helper Remove ~/bin/keychain-fingerprint --remove-openconnect-lite Remove pipx openconnect-lite after patch rollback @@ -34,6 +38,7 @@ USAGE while [ "$#" -gt 0 ]; do case "$1" in --keep-config) KEEP_CONFIG=1 ;; + --keep-app) KEEP_APP=1 ;; --remove-keychain) REMOVE_KEYCHAIN=1 ;; --remove-touchid-helper) REMOVE_TOUCHID_HELPER=1 ;; --remove-openconnect-lite) REMOVE_OPENCONNECT_LITE=1 ;; @@ -141,6 +146,17 @@ main() { run sudo rm -f /etc/sudoers.d/lemana-vpn-openconnect /etc/sudoers.d/lemana-vpn-dns run sudo rm -f "$DNS_CLEANUP" + if [ "$KEEP_APP" -eq 0 ]; then + log "Removing Menu Bar app" + if [ "$DRY_RUN" -eq 0 ]; then + launchctl unload "$LAUNCH_AGENT" >/dev/null 2>&1 || true + else + printf '+ launchctl unload %s\n' "$LAUNCH_AGENT" + fi + run rm -f "$LAUNCH_AGENT" + run rm -rf "$APP_DIR" + fi + log "Removing shell aliases" remove_zshrc_block @@ -165,4 +181,3 @@ main() { } main "$@" -