From 417138b3b13bca5d717374c068137a70ad4a0e81 Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Tue, 19 May 2026 14:13:33 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=BB=D1=83=D1=87=D1=88=D0=B8=20=D1=83?= =?UTF-8?q?=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B8=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=BA=D1=83=20=D1=81=D1=82=D0=B0=D1=82=D1=83?= =?UTF-8?q?=D1=81=D0=B0=20VPN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 16 ++- app/Sources/LemanaVPN/LemanaVPNApp.swift | 9 +- app/Sources/LemanaVPN/VPNManager.swift | 42 ++++-- bin/vpn-lemanapro.sh | 15 ++- tests/smoke.sh | 22 ++++ uninstall.sh | 160 +++++++++++++++++++---- 6 files changed, 224 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 8fe5ddd..4008da7 100644 --- a/README.md +++ b/README.md @@ -97,10 +97,16 @@ curl -fsSL https://example.org/dokril/lemana-vpn/raw/branch/main/install.sh \ ```sh vpn --status -Modules: core=ok, bitwarden=on, touchid=on, dns=on, app=on, autostart=on, patches=active, keychain=password:yes/totp_seed:yes +Modules: ✅ core=ok, ✅ bitwarden=on, ✅ touchid=on, ✅ dns=on, ✅ app=on, ✅ autostart=on, ✅ patches=active, ✅ keychain=password:yes/totp_seed:yes VPN disconnected ``` +Emoji в human-выводе помогают быстро отличать норму, отключённый опциональный модуль и проблему: + +- `✅` — модуль установлен или состояние готово; +- `⏭️` — модуль осознанно отключён; +- `⚠️` — модуль включён, но чего-то не хватает. + Значения: | Поле | Значение | @@ -157,20 +163,22 @@ Detected state: В неинтерактивной среде скрипт не задаёт вопросов и использует выбранные флаги/дефолты. Для CI или повторяемой установки лучше явно указывать `--non-interactive`. -## Логи установщика +## Логи установщика и удаления -Установщик печатает пошаговый лог с emoji, цветом в интерактивном терминале и коротким пояснением, зачем нужен каждый шаг. Например, перед сборкой Swift-приложения он отдельно пишет, что `swift build` может занять время и что строки компилятора вида `[2/5] Write swift-version...` являются нормальным выводом. +Установщик и uninstall script печатают пошаговый лог с emoji, цветом в интерактивном терминале и коротким пояснением, зачем нужен каждый шаг. Например, перед сборкой Swift-приложения установщик отдельно пишет, что `swift build` может занять время и что строки компилятора вида `[2/5] Write swift-version...` являются нормальным выводом. При удалении отдельно показывается откат runtime-патчей `openconnect-lite`, удаление sudoers/DNS wrapper, приложения, aliases и config. Отключить цвет: ```sh NO_COLOR=1 sh install.sh +NO_COLOR=1 sh uninstall.sh ``` Отключить emoji: ```sh LEMANA_VPN_NO_EMOJI=1 sh install.sh +LEMANA_VPN_NO_EMOJI=1 sh uninstall.sh ``` ## Модули @@ -254,6 +262,8 @@ sh install.sh --without-touchid Приложение живёт в macOS status bar, запускает `~/bin/vpn-lemanapro.sh --json`, показывает состояние VPN, IP, оставшееся время сессии, health-check тоннеля и строку состояния модулей. +Строка состояния модулей в меню приложения использует те же маркеры, что CLI: `✅` для готового модуля, `⏭️` для отключённого опционального модуля и `⚠️` для проблемы. Иконка строки тоже меняется: `checkmark.circle` для полностью готового набора и `exclamationmark.triangle` для неполной установки. + Если в меню видно `modules unavailable: update CLI`, значит запущенное приложение обращается к старому `~/bin/vpn-lemanapro.sh`, который ещё не умеет отдавать модульный статус. Повтори установку через `curl`; установщик обновит CLI и перезапустит уже запущенное `LemanaVPN.app`. Для сборки нужен Swift 5.9+ из Xcode Command Line Tools: diff --git a/app/Sources/LemanaVPN/LemanaVPNApp.swift b/app/Sources/LemanaVPN/LemanaVPNApp.swift index 7461e1b..73b2d9e 100644 --- a/app/Sources/LemanaVPN/LemanaVPNApp.swift +++ b/app/Sources/LemanaVPN/LemanaVPNApp.swift @@ -30,6 +30,12 @@ struct StatusBarLabel: View { private var iconName: String { switch vpnManager.state { case .disconnected: + if vpnManager.moduleStatusSystemImage == "exclamationmark.triangle" { + return "exclamationmark.shield" + } + if vpnManager.moduleStatusSystemImage == "questionmark.circle" { + return "questionmark.circle" + } return "shield.slash" case .unlocking, .connecting: return "shield.lefthalf.filled" @@ -63,7 +69,7 @@ struct VPNMenuView: View { } Divider() - Label(vpnManager.moduleSummary, systemImage: "puzzlepiece.extension") + Label(vpnManager.moduleSummary, systemImage: vpnManager.moduleStatusSystemImage) .disabled(true) Button("Обновить статус модулей") { vpnManager.refreshStatus() @@ -174,4 +180,3 @@ struct VPNMenuView: View { } } } - diff --git a/app/Sources/LemanaVPN/VPNManager.swift b/app/Sources/LemanaVPN/VPNManager.swift index 32a5a79..a6f648a 100644 --- a/app/Sources/LemanaVPN/VPNManager.swift +++ b/app/Sources/LemanaVPN/VPNManager.swift @@ -58,15 +58,35 @@ struct ModuleStatus: Decodable { var patches: Patches var app: AppModule? + var hasWarnings: Bool { + let coreReady = core.openconnect && core.openconnect_lite && core.openconnect_lite_config + let bitwardenReady = !bitwarden.enabled || bitwarden.installed + let touchReady = !touchid.enabled || touchid.installed + let keychainReady = keychain.password && keychain.totp_seed + let appReady = app?.installed ?? true + + return !coreReady + || !bitwardenReady + || !touchReady + || !keychainReady + || !dns_cleanup.installed + || !patches.active + || !appReady + } + + var systemImage: String { + hasWarnings ? "exclamationmark.triangle" : "checkmark.circle" + } + 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 appState = app.map { $0.installed ? "app on" : "app missing" } ?? "app unknown" - let autostartState = app.map { $0.autostart ? "autostart on" : "autostart off" } ?? "autostart unknown" - let patchState = patches.active ? "patches active" : "patches pending" - let keychainState = "kc \(keychain.password ? "pass" : "-")/\(keychain.totp_seed ? "totp" : "-")" + let coreState = core.openconnect && core.openconnect_lite && core.openconnect_lite_config ? "✅ core" : "⚠️ core" + let bwState = bitwarden.enabled ? (bitwarden.installed ? "✅ bw" : "⚠️ bw") : "⏭️ bw" + let touchState = touchid.enabled ? (touchid.installed ? "✅ touch" : "⚠️ touch") : "⏭️ touch" + let dnsState = dns_cleanup.installed ? "✅ dns" : "⚠️ dns" + let appState = app.map { $0.installed ? "✅ app" : "⚠️ app" } ?? "❔ app" + let autostartState = app.map { $0.autostart ? "✅ autostart" : "⏭️ autostart" } ?? "❔ autostart" + let patchState = patches.active ? "✅ patches" : "⚠️ patches" + let keychainState = "\(keychain.password && keychain.totp_seed ? "✅" : "⚠️") kc \(keychain.password ? "pass" : "-")/\(keychain.totp_seed ? "totp" : "-")" return [coreState, bwState, touchState, dnsState, appState, autostartState, patchState, keychainState].joined(separator: " | ") } } @@ -97,6 +117,7 @@ class VPNManager: ObservableObject { @Published var lastError: String? @Published var tunnelHealthy: Bool = true @Published var moduleSummary: String = "modules loading..." + @Published var moduleStatusSystemImage: String = "hourglass" private var process: Process? private var outputPipe: Pipe? @@ -166,6 +187,7 @@ class VPNManager: ObservableObject { Task { @MainActor in guard !lastLine.isEmpty, let jsonData = lastLine.data(using: .utf8) else { self.moduleSummary = "modules unavailable" + self.moduleStatusSystemImage = "questionmark.circle" self.log("[modules] status refresh returned no JSON output") return } @@ -175,6 +197,7 @@ class VPNManager: ObservableObject { response = try JSONDecoder().decode(VPNStatusResponse.self, from: jsonData) } catch { self.moduleSummary = "modules unavailable" + self.moduleStatusSystemImage = "exclamationmark.triangle" let compact = text.replacingOccurrences(of: "\n", with: "\\n") let preview = compact.count > 500 ? String(compact.prefix(500)) + "..." : compact self.log("[modules] status decode failed: \(error.localizedDescription); output=\(preview)") @@ -183,11 +206,13 @@ class VPNManager: ObservableObject { guard let modules = response.modules else { self.moduleSummary = "modules unavailable: update CLI" + self.moduleStatusSystemImage = "exclamationmark.triangle" self.log("[modules] status has no modules field; reinstall CLI with install.sh") return } self.moduleSummary = modules.summary + self.moduleStatusSystemImage = modules.systemImage } } @@ -307,6 +332,7 @@ class VPNManager: ObservableObject { if let modules = event.modules { moduleSummary = modules.summary + moduleStatusSystemImage = modules.systemImage } log("[event] \(event.event)" + { diff --git a/bin/vpn-lemanapro.sh b/bin/vpn-lemanapro.sh index 7cdc795..dd0a0df 100755 --- a/bin/vpn-lemanapro.sh +++ b/bin/vpn-lemanapro.sh @@ -146,11 +146,11 @@ _module_status_json() { _module_human_part() { local name="$1" enabled="$2" installed="$3" if [[ "$enabled" == "0" ]]; then - printf '%s=off' "$name" + printf '⏭️ %s=off' "$name" elif [[ "$installed" == "true" ]]; then - printf '%s=on' "$name" + printf '✅ %s=on' "$name" else - printf '%s=missing' "$name" + printf '⚠️ %s=missing' "$name" fi } @@ -172,15 +172,20 @@ _module_status_human() { app_installed="$(_module_bool test -x "$APP_DIR/Contents/MacOS/LemanaVPN")" app_autostart="$(_module_bool test -f "$LAUNCH_AGENT")" - printf 'Modules: %s, ' "$core" + printf 'Modules: %s %s, ' "$([[ "$core" == "core=ok" ]] && printf '✅' || printf '⚠️')" "$core" _module_human_part "bitwarden" "$USE_BITWARDEN" "$bitwarden_installed" printf ', ' _module_human_part "touchid" "$USE_TOUCHID" "$touchid_installed" - printf ', dns=%s, app=%s, autostart=%s, patches=%s, keychain=password:%s/totp_seed:%s\n' \ + printf ', %s dns=%s, %s app=%s, %s autostart=%s, %s patches=%s, %s keychain=password:%s/totp_seed:%s\n' \ + "$([[ "$dns_cleanup_installed" == "true" ]] && printf '✅' || printf '⚠️')" \ "$([[ "$dns_cleanup_installed" == "true" ]] && printf on || printf missing)" \ + "$([[ "$app_installed" == "true" ]] && printf '✅' || printf '⚠️')" \ "$([[ "$app_installed" == "true" ]] && printf on || printf missing)" \ + "$([[ "$app_autostart" == "true" ]] && printf '✅' || printf '⏭️')" \ "$([[ "$app_autostart" == "true" ]] && printf on || printf off)" \ + "$([[ "$patches_active" == "true" ]] && printf '✅' || printf '⚠️')" \ "$([[ "$patches_active" == "true" ]] && printf active || printf pending)" \ + "$([[ "$keychain_password" == "true" && "$keychain_totp_seed" == "true" ]] && printf '✅' || printf '⚠️')" \ "$([[ "$keychain_password" == "true" ]] && printf yes || printf no)" \ "$([[ "$keychain_totp_seed" == "true" ]] && printf yes || printf no)" } diff --git a/tests/smoke.sh b/tests/smoke.sh index 32febec..a70c299 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -34,9 +34,31 @@ printf '%s\n' "$status_json" | grep -q '"modules":' printf '%s\n' "$status_json" | grep -q '"app":' status_text="$(bash "$ROOT/bin/vpn-lemanapro.sh" --status)" +printf '%s\n' "$status_text" | grep -q 'Modules:' +printf '%s\n' "$status_text" | grep -q 'core=' printf '%s\n' "$status_text" | grep -q 'app=' printf '%s\n' "$status_text" | grep -q 'autostart=' +uninstall_home="$TMP_DIR/uninstall-home" +mkdir -p "$uninstall_home" +uninstall_output="$( + HOME="$uninstall_home" \ + LEMANA_VPN_BIN_DIR="$uninstall_home/bin" \ + LEMANA_VPN_CONFIG_DIR="$uninstall_home/.config/lemana-vpn" \ + OPENCONNECT_LITE_CONFIG_DIR="$uninstall_home/.config/openconnect-lite" \ + sh "$ROOT/uninstall.sh" --dry-run --remove-keychain --remove-touchid-helper --remove-openconnect-lite +)" + +printf '%s\n' "$uninstall_output" | grep -q 'Начинаю удаление Lemana VPN' +printf '%s\n' "$uninstall_output" | grep -q 'Проверяю runtime-патчи openconnect-lite' +printf '%s\n' "$uninstall_output" | grep -q 'Удаляю sudoers и DNS cleanup wrapper' +printf '%s\n' "$uninstall_output" | grep -q 'Удаляю VPN-записи из macOS Keychain' + +if printf '%s\n' "$uninstall_output" | grep -q "$esc"; then + echo "non-tty uninstall dry-run output contains ANSI color codes" >&2 + exit 1 +fi + fake_pwd="$TMP_DIR/fake-pwd" mkdir -p "$fake_pwd/bin" printf 'stale local cli\n' > "$fake_pwd/bin/vpn-lemanapro.sh" diff --git a/uninstall.sh b/uninstall.sh index e475eb5..c3aebad 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -16,6 +16,42 @@ REMOVE_KEYCHAIN=0 REMOVE_TOUCHID_HELPER=0 REMOVE_OPENCONNECT_LITE=0 +if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-}" != "dumb" ]; then + C_RESET="$(printf '\033[0m')" + C_BOLD="$(printf '\033[1m')" + C_DIM="$(printf '\033[2m')" + C_RED="$(printf '\033[31m')" + C_GREEN="$(printf '\033[32m')" + C_YELLOW="$(printf '\033[33m')" + C_BLUE="$(printf '\033[34m')" + C_CYAN="$(printf '\033[36m')" +else + C_RESET="" + C_BOLD="" + C_DIM="" + C_RED="" + C_GREEN="" + C_YELLOW="" + C_BLUE="" + C_CYAN="" +fi + +if [ "${LEMANA_VPN_NO_EMOJI:-0}" = "1" ]; then + E_STEP=">" + E_INFO="i" + E_OK="+" + E_WARN="!" + E_SKIP="-" + E_ERROR="x" +else + E_STEP="➡️" + E_INFO="ℹ️" + E_OK="✅" + E_WARN="⚠️" + E_SKIP="⏭️" + E_ERROR="❌" +fi + usage() { cat <<'USAGE' Usage: @@ -60,6 +96,41 @@ log() { printf '%s\n' "$*" } +color_line() { + color="$1" + shift + printf '%s%s%s\n' "$color" "$*" "$C_RESET" +} + +log_step() { + color_line "$C_BOLD$C_CYAN" "$E_STEP $*" +} + +log_info() { + color_line "$C_BLUE" "$E_INFO $*" +} + +log_detail() { + color_line "$C_DIM" " $*" +} + +log_ok() { + color_line "$C_GREEN" "$E_OK $*" +} + +log_warn() { + color_line "$C_YELLOW" "$E_WARN $*" +} + +log_skip() { + color_line "$C_DIM" "$E_SKIP $*" +} + +die() { + printf '%s%s ERROR: %s%s\n' "$C_RED" "$E_ERROR" "$*" "$C_RESET" >&2 + exit 1 +} + run() { if [ "$DRY_RUN" -eq 1 ]; then printf '+' @@ -84,23 +155,30 @@ restore_openconnect_lite_patch() { backup="$CONFIG_DIR/patch-backups/webengine_process.py.before-lemana-vpn" wep="$(find_webengine_process)" + log_step "Проверяю runtime-патчи openconnect-lite" + log_detail "Если Lemana VPN менял webengine_process.py, перед удалением возвращаем исходник из backup." + if [ ! -f "$backup" ]; then - log "No openconnect-lite patch backup found; patch rollback skipped." + log_skip "No openconnect-lite patch backup found; patch rollback skipped." return 0 fi if [ -z "$wep" ] || [ ! -f "$wep" ]; then - log "openconnect-lite source not found; patch rollback skipped." + log_warn "openconnect-lite source not found; patch rollback skipped." return 0 fi - log "Restoring openconnect-lite source from patch backup" + log_info "Restoring openconnect-lite source from patch backup" run cp "$backup" "$wep" + log_ok "openconnect-lite patch rollback completed" } remove_zshrc_block() { zshrc="$HOME/.zshrc" - [ -f "$zshrc" ] || return 0 + if [ ! -f "$zshrc" ]; then + log_skip "~/.zshrc not found; shell aliases skipped." + return 0 + fi tmp="$(mktemp)" if [ "$DRY_RUN" -eq 1 ]; then @@ -115,39 +193,61 @@ remove_zshrc_block() { skip != 1 { print } ' "$zshrc" > "$tmp" mv "$tmp" "$zshrc" + log_ok "Shell aliases removed from $zshrc" } remove_keychain_entries() { - [ "$REMOVE_KEYCHAIN" -eq 1 ] || return 0 + if [ "$REMOVE_KEYCHAIN" -ne 1 ]; then + log_skip "Keychain cleanup disabled; LDAP/TOTP and Bitwarden cached entries are kept." + return 0 + fi - log "Removing VPN-related Keychain entries" - run security delete-generic-password -s openconnect-lite -a "$USERNAME" >/dev/null 2>&1 || true - run security delete-generic-password -s openconnect-lite -a "totp/$USERNAME" >/dev/null 2>&1 || true - run security delete-generic-password -s vpn-lemanapro -a bw-session >/dev/null 2>&1 || true - run security delete-generic-password -s vpn-lemanapro -a bw-master >/dev/null 2>&1 || true + log_step "Удаляю VPN-записи из macOS Keychain" + log_detail "Удаляются только записи openconnect-lite для пользователя $USERNAME и кэш Bitwarden Lemana VPN." + if [ "$DRY_RUN" -eq 1 ]; then + run security delete-generic-password -s openconnect-lite -a "$USERNAME" + run security delete-generic-password -s openconnect-lite -a "totp/$USERNAME" + run security delete-generic-password -s vpn-lemanapro -a bw-session + run security delete-generic-password -s vpn-lemanapro -a bw-master + else + security delete-generic-password -s openconnect-lite -a "$USERNAME" >/dev/null 2>&1 || true + security delete-generic-password -s openconnect-lite -a "totp/$USERNAME" >/dev/null 2>&1 || true + security delete-generic-password -s vpn-lemanapro -a bw-session >/dev/null 2>&1 || true + security delete-generic-password -s vpn-lemanapro -a bw-master >/dev/null 2>&1 || true + fi + log_ok "Keychain cleanup completed" } main() { - [ "$(uname -s)" = "Darwin" ] || { - echo "This uninstaller supports macOS only" >&2 - exit 1 - } + [ "$(uname -s)" = "Darwin" ] || die "This uninstaller supports macOS only" + + log_step "Начинаю удаление Lemana VPN" + log_detail "По умолчанию удаляются скрипты, sudoers, DNS wrapper, config, aliases и приложение." + log_detail "Shared-зависимости Homebrew не удаляются; openconnect-lite удаляется только с --remove-openconnect-lite." restore_openconnect_lite_patch - log "Removing installed scripts" + log_step "Удаляю CLI-скрипты" + log_detail "Убираю vpn-lemanapro.sh и локальный uninstall helper из $INSTALL_BIN_DIR." run rm -f "$INSTALL_BIN_DIR/vpn-lemanapro.sh" run rm -f "$INSTALL_BIN_DIR/uninstall-lemana-vpn.sh" + log_ok "CLI scripts removed" if [ "$REMOVE_TOUCHID_HELPER" -eq 1 ]; then + log_info "Removing Touch ID helper: $INSTALL_BIN_DIR/keychain-fingerprint" run rm -f "$INSTALL_BIN_DIR/keychain-fingerprint" + else + log_skip "Touch ID helper kept; use --remove-touchid-helper to remove it." fi - log "Removing sudoers and DNS cleanup wrapper" + log_step "Удаляю sudoers и DNS cleanup wrapper" + log_detail "macOS может запросить sudo-пароль, потому что эти файлы принадлежат root." run sudo rm -f /etc/sudoers.d/lemana-vpn-openconnect /etc/sudoers.d/lemana-vpn-dns run sudo rm -f "$DNS_CLEANUP" + log_ok "sudoers and DNS cleanup wrapper removed" if [ "$KEEP_APP" -eq 0 ]; then - log "Removing Menu Bar app" + log_step "Удаляю Menu Bar app" + log_detail "Сначала отключаю LaunchAgent, затем удаляю $APP_DIR." if [ "$DRY_RUN" -eq 0 ]; then launchctl unload "$LAUNCH_AGENT" >/dev/null 2>&1 || true else @@ -155,29 +255,45 @@ main() { fi run rm -f "$LAUNCH_AGENT" run rm -rf "$APP_DIR" + log_ok "Menu Bar app removed" + else + log_skip "Menu Bar app kept because --keep-app is set." fi - log "Removing shell aliases" + log_step "Удаляю shell aliases" + log_detail "Из ~/.zshrc удаляется только блок между # >>> lemana-vpn и # <<< lemana-vpn." remove_zshrc_block - log "Removing openconnect-lite config" + log_step "Удаляю openconnect-lite config" + log_detail "Удаляется профиль SSO, который был создан установщиком Lemana VPN." run rm -f "$OC_CONFIG_DIR/config.toml" + log_ok "openconnect-lite config removed" if [ "$KEEP_CONFIG" -eq 0 ]; then - log "Removing Lemana VPN config" + log_step "Удаляю Lemana VPN config" + log_detail "Удаляется $CONFIG_DIR, включая backup runtime-патчей после их отката." run rm -rf "$CONFIG_DIR" + log_ok "Lemana VPN config removed" + else + log_skip "Lemana VPN config kept because --keep-config is set." fi remove_keychain_entries if [ "$REMOVE_OPENCONNECT_LITE" -eq 1 ]; then if command -v pipx >/dev/null 2>&1; then - log "Removing openconnect-lite from pipx" + log_step "Удаляю openconnect-lite из pipx" + log_detail "Это опционально: пакет может использоваться не только Lemana VPN." run pipx uninstall openconnect-lite + log_ok "openconnect-lite removed from pipx" + else + log_warn "pipx not found; openconnect-lite package removal skipped." fi + else + log_skip "openconnect-lite kept; use --remove-openconnect-lite to uninstall it from pipx." fi - log "Done." + log_ok "Done." } main "$@"