Добавь установку 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

3
.gitignore vendored
View File

@@ -1,4 +1,5 @@
.DS_Store
*.log
*.tmp
.build/
app/.build/

View File

@@ -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

14
app/Package.swift Normal file
View File

@@ -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"
)
]
)

View File

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

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

View File

@@ -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" '<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>LemanaVPN</string>
<key>CFBundleIdentifier</key>
<string>ru.dokops.LemanaVPN</string>
<key>CFBundleName</key>
<string>LemanaVPN</string>
<key>CFBundleDisplayName</key>
<string>LemanaVPN</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>LSUIElement</key>
<true/>
</dict>
</plist>'
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" "<?xml version=\"1.0\" encoding=\"UTF-8\"?>
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">
<plist version=\"1.0\">
<dict>
<key>Label</key>
<string>ru.dokops.LemanaVPN</string>
<key>ProgramArguments</key>
<array>
<string>$APP_DIR/Contents/MacOS/LemanaVPN</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<false/>
</dict>
</plist>"
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 "$@"

View File

@@ -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'

View File

@@ -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 "$@"