Добавь установку Swift-приложения VPN
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
.DS_Store
|
||||
*.log
|
||||
*.tmp
|
||||
|
||||
.build/
|
||||
app/.build/
|
||||
|
||||
47
README.md
47
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
|
||||
|
||||
14
app/Package.swift
Normal file
14
app/Package.swift
Normal 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"
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
177
app/Sources/LemanaVPN/LemanaVPNApp.swift
Normal file
177
app/Sources/LemanaVPN/LemanaVPNApp.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
141
install.sh
141
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" '<?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 "$@"
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
17
uninstall.sh
17
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 "$@"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user