Добавь установку Swift-приложения VPN
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.log
|
||||||
*.tmp
|
*.tmp
|
||||||
|
.build/
|
||||||
|
app/.build/
|
||||||
|
|||||||
47
README.md
47
README.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
CLI-установка корпоративного VPN `vpn.lemanapro.ru` для macOS.
|
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;
|
- `openconnect-lite` для SAML SSO через Keycloak;
|
||||||
- опциональный Bitwarden CLI для LDAP-пароля и TOTP seed;
|
- опциональный Bitwarden CLI для LDAP-пароля и TOTP seed;
|
||||||
- опциональный Touch ID helper для мастер-пароля Bitwarden;
|
- опциональный Touch ID helper для мастер-пароля Bitwarden;
|
||||||
|
- Swift Menu Bar app `LemanaVPN.app`;
|
||||||
- безопасный DNS cleanup через root-owned wrapper;
|
- безопасный DNS cleanup через root-owned wrapper;
|
||||||
- алиасы `vpn`, `vpn-debug`, `vpn-fix-dns`.
|
- алиасы `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/vpn-lemanapro.sh` | Основной CLI для подключения, статуса и sync секретов |
|
||||||
| `~/bin/uninstall-lemana-vpn.sh` | Локальный uninstall helper |
|
| `~/bin/uninstall-lemana-vpn.sh` | Локальный uninstall helper |
|
||||||
| `~/bin/keychain-fingerprint` | Опциональный Touch ID helper для мастер-пароля Bitwarden |
|
| `~/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/env` | Локальная конфигурация модулей |
|
||||||
| `~/.config/lemana-vpn/patch-backups/` | Backup исходника `openconnect-lite` перед runtime-патчами |
|
| `~/.config/lemana-vpn/patch-backups/` | Backup исходника `openconnect-lite` перед runtime-патчами |
|
||||||
| `~/.config/openconnect-lite/config.toml` | Профиль SSO и auto-fill правила Keycloak |
|
| `~/.config/openconnect-lite/config.toml` | Профиль SSO и auto-fill правила Keycloak |
|
||||||
@@ -128,6 +131,9 @@ Detected state:
|
|||||||
openconnect-lite: yes
|
openconnect-lite: yes
|
||||||
Bitwarden CLI: no
|
Bitwarden CLI: no
|
||||||
Touch ID helper: no
|
Touch ID helper: no
|
||||||
|
Swift: yes
|
||||||
|
Menu Bar app: no
|
||||||
|
LaunchAgent: no
|
||||||
DNS cleanup: no
|
DNS cleanup: no
|
||||||
sudoers: no/no
|
sudoers: no/no
|
||||||
shell aliases: no
|
shell aliases: no
|
||||||
@@ -139,6 +145,8 @@ Detected state:
|
|||||||
|
|
||||||
- поставить ли Bitwarden CLI, если `bw` не найден;
|
- поставить ли Bitwarden CLI, если `bw` не найден;
|
||||||
- собрать ли Touch ID helper, если его нет и Bitwarden включён;
|
- собрать ли Touch ID helper, если его нет и Bitwarden включён;
|
||||||
|
- собрать ли Swift Menu Bar app, если `~/Applications/LemanaVPN.app` не найден;
|
||||||
|
- включить ли автозапуск Menu Bar app при логине;
|
||||||
- настроить ли sudoers для `openconnect` и DNS cleanup;
|
- настроить ли sudoers для `openconnect` и DNS cleanup;
|
||||||
- добавить ли алиасы в `~/.zshrc`;
|
- добавить ли алиасы в `~/.zshrc`;
|
||||||
- записать ли LDAP-пароль и TOTP seed в Keychain, если Bitwarden отключён.
|
- записать ли 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
|
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
|
```sh
|
||||||
@@ -226,6 +268,7 @@ vpn --status # статус без нового подключения
|
|||||||
vpn --status --json # статус в JSON
|
vpn --status --json # статус в JSON
|
||||||
vpn-debug # видимый браузер и debug-логи
|
vpn-debug # видимый браузер и debug-логи
|
||||||
vpn-fix-dns # сбросить корпоративные DNS после аварийного завершения
|
vpn-fix-dns # сбросить корпоративные DNS после аварийного завершения
|
||||||
|
open ~/Applications/LemanaVPN.app # открыть Swift-приложение в menu bar
|
||||||
```
|
```
|
||||||
|
|
||||||
Первый запуск с Bitwarden:
|
Первый запуск с Bitwarden:
|
||||||
@@ -343,6 +386,7 @@ uninstall-lemana-vpn.sh
|
|||||||
- удаляет sudoers rules и DNS cleanup wrapper;
|
- удаляет sudoers rules и DNS cleanup wrapper;
|
||||||
- удаляет блок `lemana-vpn` из `~/.zshrc`;
|
- удаляет блок `lemana-vpn` из `~/.zshrc`;
|
||||||
- удаляет `~/.config/openconnect-lite/config.toml`;
|
- удаляет `~/.config/openconnect-lite/config.toml`;
|
||||||
|
- удаляет `~/Applications/LemanaVPN.app` и LaunchAgent автозапуска;
|
||||||
- удаляет `~/.config/lemana-vpn`, если не передан `--keep-config`.
|
- удаляет `~/.config/lemana-vpn`, если не передан `--keep-config`.
|
||||||
|
|
||||||
Опциональные режимы:
|
Опциональные режимы:
|
||||||
@@ -350,6 +394,7 @@ uninstall-lemana-vpn.sh
|
|||||||
```sh
|
```sh
|
||||||
uninstall-lemana-vpn.sh --dry-run
|
uninstall-lemana-vpn.sh --dry-run
|
||||||
uninstall-lemana-vpn.sh --keep-config
|
uninstall-lemana-vpn.sh --keep-config
|
||||||
|
uninstall-lemana-vpn.sh --keep-app
|
||||||
uninstall-lemana-vpn.sh --remove-keychain
|
uninstall-lemana-vpn.sh --remove-keychain
|
||||||
uninstall-lemana-vpn.sh --remove-touchid-helper
|
uninstall-lemana-vpn.sh --remove-touchid-helper
|
||||||
uninstall-lemana-vpn.sh --remove-openconnect-lite
|
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
|
USE_TOUCHID=1
|
||||||
INSTALL_SUDOERS=1
|
INSTALL_SUDOERS=1
|
||||||
INSTALL_ALIASES=1
|
INSTALL_ALIASES=1
|
||||||
|
INSTALL_APP=1
|
||||||
|
INSTALL_AUTOSTART=1
|
||||||
CONFIGURE_KEYCHAIN=0
|
CONFIGURE_KEYCHAIN=0
|
||||||
DRY_RUN=0
|
DRY_RUN=0
|
||||||
FORCE=0
|
FORCE=0
|
||||||
@@ -23,7 +25,11 @@ BITWARDEN_FORCED=0
|
|||||||
TOUCHID_FORCED=0
|
TOUCHID_FORCED=0
|
||||||
SUDOERS_FORCED=0
|
SUDOERS_FORCED=0
|
||||||
SHELL_FORCED=0
|
SHELL_FORCED=0
|
||||||
|
APP_FORCED=0
|
||||||
|
AUTOSTART_FORCED=0
|
||||||
CONFIGURE_KEYCHAIN_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() {
|
usage() {
|
||||||
cat <<'USAGE'
|
cat <<'USAGE'
|
||||||
@@ -41,6 +47,10 @@ Options:
|
|||||||
--raw-base-url URL Raw file base URL for curl installs
|
--raw-base-url URL Raw file base URL for curl installs
|
||||||
--no-sudoers Do not install sudoers rules
|
--no-sudoers Do not install sudoers rules
|
||||||
--no-shell Do not update ~/.zshrc aliases
|
--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
|
--interactive Ask before installing optional missing modules
|
||||||
--non-interactive Use selected/default modules without prompts
|
--non-interactive Use selected/default modules without prompts
|
||||||
--minimal Same as --without-bitwarden --without-touchid
|
--minimal Same as --without-bitwarden --without-touchid
|
||||||
@@ -100,6 +110,24 @@ while [ "$#" -gt 0 ]; do
|
|||||||
INSTALL_ALIASES=0
|
INSTALL_ALIASES=0
|
||||||
SHELL_FORCED=1
|
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 ;;
|
--interactive) INTERACTIVE=1 ;;
|
||||||
--non-interactive) INTERACTIVE=0 ;;
|
--non-interactive) INTERACTIVE=0 ;;
|
||||||
--minimal)
|
--minimal)
|
||||||
@@ -215,6 +243,9 @@ print_detected_state() {
|
|||||||
log " DNS cleanup: $(bool_word test -x "$DNS_CLEANUP")"
|
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 " 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 " 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 password: $(bool_word keychain_has openconnect-lite "$USERNAME")"
|
||||||
log " Keychain TOTP seed: $(bool_word keychain_has openconnect-lite "totp/$USERNAME")"
|
log " Keychain TOTP seed: $(bool_word keychain_has openconnect-lite "totp/$USERNAME")"
|
||||||
}
|
}
|
||||||
@@ -269,6 +300,23 @@ choose_modules() {
|
|||||||
fi
|
fi
|
||||||
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 [ "$CONFIGURE_KEYCHAIN_FORCED" -eq 0 ] && [ "$USE_BITWARDEN" -eq 0 ]; then
|
||||||
if ! keychain_has openconnect-lite "$USERNAME" || ! keychain_has openconnect-lite "totp/$USERNAME"; 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
|
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"
|
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_shell_aliases() {
|
||||||
[ "$INSTALL_ALIASES" -eq 1 ] || return 0
|
[ "$INSTALL_ALIASES" -eq 1 ] || return 0
|
||||||
|
|
||||||
@@ -491,7 +625,7 @@ main() {
|
|||||||
choose_modules
|
choose_modules
|
||||||
|
|
||||||
log "Installing Lemana VPN"
|
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_homebrew_packages
|
||||||
install_openconnect_lite
|
install_openconnect_lite
|
||||||
@@ -500,6 +634,8 @@ main() {
|
|||||||
install_dns_cleanup "$tmp"
|
install_dns_cleanup "$tmp"
|
||||||
install_sudoers "$tmp"
|
install_sudoers "$tmp"
|
||||||
install_touchid_helper "$tmp"
|
install_touchid_helper "$tmp"
|
||||||
|
install_menu_bar_app "$tmp"
|
||||||
|
install_launch_agent "$tmp"
|
||||||
install_shell_aliases "$tmp"
|
install_shell_aliases "$tmp"
|
||||||
maybe_login_bitwarden
|
maybe_login_bitwarden
|
||||||
|
|
||||||
@@ -512,6 +648,9 @@ main() {
|
|||||||
log "Open a new shell or run: exec zsh"
|
log "Open a new shell or run: exec zsh"
|
||||||
log "Connect: vpn"
|
log "Connect: vpn"
|
||||||
log "Status: vpn --status"
|
log "Status: vpn --status"
|
||||||
|
if [ "$INSTALL_APP" -eq 1 ]; then
|
||||||
|
log "App: open '$APP_DIR'"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
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 'Detected state:'
|
||||||
printf '%s\n' "$output" | grep -q 'Interactive prompts: off'
|
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 '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'
|
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}"
|
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}"
|
DNS_CLEANUP="${LEMANA_VPN_DNS_CLEANUP:-/usr/local/sbin/lemana-vpn-dns-cleanup}"
|
||||||
USERNAME="${LEMANA_VPN_USERNAME:-60103293}"
|
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
|
DRY_RUN=0
|
||||||
KEEP_CONFIG=0
|
KEEP_CONFIG=0
|
||||||
|
KEEP_APP=0
|
||||||
REMOVE_KEYCHAIN=0
|
REMOVE_KEYCHAIN=0
|
||||||
REMOVE_TOUCHID_HELPER=0
|
REMOVE_TOUCHID_HELPER=0
|
||||||
REMOVE_OPENCONNECT_LITE=0
|
REMOVE_OPENCONNECT_LITE=0
|
||||||
@@ -20,6 +23,7 @@ Usage:
|
|||||||
|
|
||||||
Options:
|
Options:
|
||||||
--keep-config Keep ~/.config/lemana-vpn
|
--keep-config Keep ~/.config/lemana-vpn
|
||||||
|
--keep-app Keep ~/Applications/LemanaVPN.app and LaunchAgent
|
||||||
--remove-keychain Remove VPN-related Keychain entries
|
--remove-keychain Remove VPN-related Keychain entries
|
||||||
--remove-touchid-helper Remove ~/bin/keychain-fingerprint
|
--remove-touchid-helper Remove ~/bin/keychain-fingerprint
|
||||||
--remove-openconnect-lite Remove pipx openconnect-lite after patch rollback
|
--remove-openconnect-lite Remove pipx openconnect-lite after patch rollback
|
||||||
@@ -34,6 +38,7 @@ USAGE
|
|||||||
while [ "$#" -gt 0 ]; do
|
while [ "$#" -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--keep-config) KEEP_CONFIG=1 ;;
|
--keep-config) KEEP_CONFIG=1 ;;
|
||||||
|
--keep-app) KEEP_APP=1 ;;
|
||||||
--remove-keychain) REMOVE_KEYCHAIN=1 ;;
|
--remove-keychain) REMOVE_KEYCHAIN=1 ;;
|
||||||
--remove-touchid-helper) REMOVE_TOUCHID_HELPER=1 ;;
|
--remove-touchid-helper) REMOVE_TOUCHID_HELPER=1 ;;
|
||||||
--remove-openconnect-lite) REMOVE_OPENCONNECT_LITE=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 /etc/sudoers.d/lemana-vpn-openconnect /etc/sudoers.d/lemana-vpn-dns
|
||||||
run sudo rm -f "$DNS_CLEANUP"
|
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"
|
log "Removing shell aliases"
|
||||||
remove_zshrc_block
|
remove_zshrc_block
|
||||||
|
|
||||||
@@ -165,4 +181,3 @@ main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user