#!/bin/sh set -eu APP_NAME="lemana-vpn" DEFAULT_RAW_BASE_URL="https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main" RAW_BASE_URL="${LEMANA_VPN_RAW_BASE_URL:-$DEFAULT_RAW_BASE_URL}" INSTALL_BIN_DIR="${LEMANA_VPN_BIN_DIR:-$HOME/bin}" CONFIG_DIR="${LEMANA_VPN_CONFIG_DIR:-$HOME/.config/lemana-vpn}" OC_CONFIG_DIR="${OPENCONNECT_LITE_CONFIG_DIR:-$HOME/.config/openconnect-lite}" DNS_CLEANUP="/usr/local/sbin/lemana-vpn-dns-cleanup" # Read saved scalar settings without executing shell code. Explicit environment wins. read_saved_settings() { [ -f "$CONFIG_DIR/env" ] || [ -f "$OC_CONFIG_DIR/config.toml" ] || return 0 CONFIG_FILE="$CONFIG_DIR/env" OC_CONFIG_FILE="$OC_CONFIG_DIR/config.toml" python3 - <<'PYCONFIG' import json import os import re import shlex from pathlib import Path keys = ("MODE", "USERNAME", "BW_ITEM", "USE_TOUCHID", "AUTH_PROFILE", "CREDENTIAL_SOURCE", "USE_BITWARDEN", "LOCAL_OTP", "AUTO_SOURCE") pattern = re.compile(r"(?m)^(?:export[ \t]+)?(LEMANA_VPN_(?:" + "|".join(keys) + r"))=[ \t]*(\"(?:\\[\s\S]|[^\"\\])*\"|'[^']*'|[^\r\n]*)[ \t]*(?:#.*)?$") values = {} path = Path(os.environ["CONFIG_FILE"]) if path.exists(): for match in pattern.finditer(path.read_text()): key, raw = match.groups() if raw.startswith('"'): value = re.sub(r'\\([$`"\\\n])', lambda m: "" if m[1] == "\n" else m[1], raw[1:-1]) else: parts = shlex.split(raw, comments=True) if len(parts) > 1: continue value = parts[0] if parts else "" values[key] = value if not values.get("LEMANA_VPN_USERNAME") and "LEMANA_VPN_USERNAME" not in os.environ: path = Path(os.environ["OC_CONFIG_FILE"]) if path.exists(): section = re.search(r"""(?ms)^[ \t]*\[[ \t]*(?:credentials|"credentials"|'credentials')[ \t]*\][ \t]*(?:#[^\n]*)?(?:\n|\Z)(.*?)(?=^[ \t]*\[|\Z)""", path.read_text()) match = re.search(r"""(?m)^[ \t]*(?:username|"username"|'username')[ \t]*=[ \t]*("(?:\\.|[^"\\])*"|'[^']*')""", section[1] if section else "") if match: raw = match[1] values["LEMANA_VPN_USERNAME"] = json.loads(raw) if raw.startswith('"') else raw[1:-1] # Select auth behavior from one priority layer; saved legacy fields cannot override it. if "LEMANA_VPN_MODE" not in os.environ and any("LEMANA_VPN_" + key in os.environ for key in ("AUTH_PROFILE", "CREDENTIAL_SOURCE", "USE_BITWARDEN")): for key in ("MODE", "AUTH_PROFILE", "CREDENTIAL_SOURCE", "USE_BITWARDEN", "LOCAL_OTP"): values.pop("LEMANA_VPN_" + key, None) if "LEMANA_VPN_MODE" in os.environ: values.pop("LEMANA_VPN_LOCAL_OTP", None) for key, value in values.items(): if key not in os.environ: print(key + "=" + shlex.quote(value)) if key == "LEMANA_VPN_USERNAME": print("SAVED_USERNAME=" + shlex.quote(value)) PYCONFIG } saved_settings="$(read_saved_settings)" eval "$saved_settings" USERNAME="${LEMANA_VPN_USERNAME:-}" BW_ITEM="${LEMANA_VPN_BW_ITEM:-LM LDAP}" MODE="${LEMANA_VPN_MODE:-}" LOCAL_OTP="${LEMANA_VPN_LOCAL_OTP:-0}" AUTO_SOURCE="${LEMANA_VPN_AUTO_SOURCE:-keychain}" MANUAL_FORCED=0 CREDENTIAL_SOURCE="${LEMANA_VPN_CREDENTIAL_SOURCE:-}" AUTH_PROFILE="${LEMANA_VPN_AUTH_PROFILE:-manual}" USE_BITWARDEN="${LEMANA_VPN_USE_BITWARDEN:-0}" USE_TOUCHID="${LEMANA_VPN_USE_TOUCHID:-0}" INSTALL_SUDOERS=1 INSTALL_ALIASES=1 INSTALL_APP=1 INSTALL_AUTOSTART=1 CONFIGURE_LOGIN=0 CONFIGURE_KEYCHAIN=0 IMPORT_YANDEX_KEY=0 DRY_RUN=0 FORCE=0 INTERACTIVE=auto BITWARDEN_FORCED=0 MODE_FORCED=0 CREDENTIAL_SOURCE_FORCED=0 AUTH_PROFILE_FORCED=0 TOUCHID_FORCED=0 [ "${LEMANA_VPN_USE_TOUCHID+x}" != x ] || TOUCHID_FORCED=1 SUDOERS_FORCED=0 SHELL_FORCED=0 APP_FORCED=0 AUTOSTART_FORCED=0 CONFIGURE_KEYCHAIN_FORCED=0 CONFIGURE_LOGIN_FORCED=0 APP_DIR="${LEMANA_VPN_APP_DIR:-$HOME/Applications/LemanaVPN.app}" LAUNCH_AGENT="$HOME/Library/LaunchAgents/ru.dokops.LemanaVPN.plist" 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: sh install.sh [options] Options: --with-bitwarden Install/use Bitwarden CLI module as an optional credential source --without-bitwarden Do not install/use Bitwarden CLI (default) --mode VALUE Profile: manual-keychain, keychain-auto or bitwarden-auto --credential-source VALUE Legacy credential source: none, keychain, or bitwarden --auth-profile VALUE Legacy compatibility profile --with-touchid Install/use keychain-fingerprint Touch ID helper with Bitwarden --without-touchid Do not install/use Touch ID helper --configure-login Prompt for LDAP username/password after install --configure-keychain Prompt for LDAP password and permanent 2FA setup code after install --import-yandex-key Import a local Yandex Key backup after login setup (Terminal required) --username VALUE Corporate LDAP username (otherwise asked on first Terminal connection) --bw-item VALUE Bitwarden item name (default: LM LDAP) --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 Manual-first install without optional credential helpers --dry-run Print actions without changing files --force Reinstall files even when present -h, --help Show this help Examples: sh install.sh sh install.sh --mode manual-keychain --configure-login sh install.sh --mode bitwarden-auto sh install.sh --configure-login sh install.sh --without-touchid USAGE } apply_mode_defaults() { case "$MODE" in manual-keychain) LOCAL_OTP=0 AUTH_PROFILE="saved-login" CREDENTIAL_SOURCE="keychain" USE_BITWARDEN=0 USE_TOUCHID=0 ;; keychain-auto) AUTH_PROFILE="auto" CREDENTIAL_SOURCE="keychain" USE_BITWARDEN=0 USE_TOUCHID=0 LOCAL_OTP=1 AUTO_SOURCE=keychain ;; bitwarden-auto) LOCAL_OTP=0 AUTO_SOURCE=bitwarden AUTH_PROFILE="auto" CREDENTIAL_SOURCE="bitwarden" USE_BITWARDEN=1 if [ "$TOUCHID_FORCED" -eq 0 ]; then USE_TOUCHID=1 fi ;; *) echo "--mode requires manual-keychain, keychain-auto or bitwarden-auto" >&2 exit 1 ;; esac } normalize_mode_from_legacy() { if [ -z "$MODE" ]; then [ -n "$CREDENTIAL_SOURCE" ] || { [ "$USE_BITWARDEN" -ne 1 ] || CREDENTIAL_SOURCE=bitwarden; } case "$CREDENTIAL_SOURCE:$AUTH_PROFILE" in bitwarden:*|*:auto) MODE="bitwarden-auto" ;; *) MODE="manual-keychain" ;; esac fi if [ "$MODE" = manual-keychain ] && [ "$LOCAL_OTP" = 1 ] && [ "$MANUAL_FORCED" -eq 0 ]; then MODE=keychain-auto fi apply_mode_defaults } while [ "$#" -gt 0 ]; do case "$1" in --with-bitwarden) MODE="bitwarden-auto" MODE_FORCED=1 apply_mode_defaults BITWARDEN_FORCED=1 CREDENTIAL_SOURCE_FORCED=1 ;; --without-bitwarden) MANUAL_FORCED=1 MODE="manual-keychain" MODE_FORCED=1 apply_mode_defaults USE_BITWARDEN=0 USE_TOUCHID=0 BITWARDEN_FORCED=1 ;; --mode) shift [ "$#" -gt 0 ] || { echo "--mode requires manual-keychain, keychain-auto or bitwarden-auto" >&2; exit 1; } MODE="$1" [ "$MODE" != manual-keychain ] || MANUAL_FORCED=1 MODE_FORCED=1 apply_mode_defaults ;; --credential-source) shift [ "$#" -gt 0 ] || { echo "--credential-source requires none, keychain, or bitwarden" >&2; exit 1; } CREDENTIAL_SOURCE="$1" CREDENTIAL_SOURCE_FORCED=1 case "$CREDENTIAL_SOURCE" in none|keychain) if [ "$MODE_FORCED" -eq 0 ]; then MODE="manual-keychain" MANUAL_FORCED=1 fi ;; bitwarden) if [ "$MODE_FORCED" -eq 0 ]; then MODE="bitwarden-auto" fi ;; *) echo "--credential-source requires none, keychain, or bitwarden" >&2 exit 1 ;; esac ;; --auth-profile) shift [ "$#" -gt 0 ] || { echo "--auth-profile requires manual, saved-login, saved-totp, or auto" >&2; exit 1; } AUTH_PROFILE="$1" AUTH_PROFILE_FORCED=1 case "$AUTH_PROFILE" in manual|saved-login) if [ "$MODE_FORCED" -eq 0 ]; then MODE="manual-keychain" MANUAL_FORCED=1 fi ;; saved-totp|auto) if [ "$MODE_FORCED" -eq 0 ]; then MODE="bitwarden-auto" fi ;; *) echo "--auth-profile requires manual, saved-login, saved-totp, or auto" >&2 exit 1 ;; esac ;; --with-touchid) USE_TOUCHID=1 TOUCHID_FORCED=1 ;; --without-touchid) USE_TOUCHID=0 TOUCHID_FORCED=1 ;; --configure-login) MANUAL_FORCED=1 if [ "$MODE_FORCED" -eq 0 ]; then MODE="manual-keychain" fi CONFIGURE_LOGIN=1 CONFIGURE_LOGIN_FORCED=1 CONFIGURE_KEYCHAIN=0 ;; --import-yandex-key) IMPORT_YANDEX_KEY=1 if [ "$MODE_FORCED" -eq 0 ]; then MODE="keychain-auto" fi MODE_FORCED=1 ;; --configure-keychain) if [ "$MODE_FORCED" -eq 0 ]; then MODE="bitwarden-auto" fi CONFIGURE_KEYCHAIN=1 CONFIGURE_LOGIN=0 CONFIGURE_KEYCHAIN_FORCED=1 ;; --username) shift [ "$#" -gt 0 ] || { echo "--username requires a value" >&2; exit 1; } USERNAME="$1" ;; --bw-item) shift [ "$#" -gt 0 ] || { echo "--bw-item requires a value" >&2; exit 1; } BW_ITEM="$1" ;; --raw-base-url) shift [ "$#" -gt 0 ] || { echo "--raw-base-url requires a value" >&2; exit 1; } RAW_BASE_URL="${1%/}" ;; --no-sudoers) INSTALL_SUDOERS=0 SUDOERS_FORCED=1 ;; --no-shell) 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) MANUAL_FORCED=1 MODE="manual-keychain" MODE_FORCED=1 apply_mode_defaults BITWARDEN_FORCED=1 CREDENTIAL_SOURCE_FORCED=1 AUTH_PROFILE_FORCED=1 TOUCHID_FORCED=1 ;; --dry-run) DRY_RUN=1 ;; --force) FORCE=1 ;; -h|--help) usage exit 0 ;; *) echo "Unknown option: $1" >&2 usage >&2 exit 1 ;; esac shift done [ -z "$MODE" ] || MODE_FORCED=1 normalize_mode_from_legacy 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 '+' for arg in "$@"; do printf ' %s' "$arg" done printf '\n' return 0 fi "$@" } need_cmd() { command -v "$1" >/dev/null 2>&1 || die "Command not found: $1" } tty_available() { { [ -r /dev/tty ] && [ -w /dev/tty ]; } || return 1 { : <> /dev/tty; } 2>/dev/null } has_tty() { tty_available || [ -t 0 ] } interactive_enabled() { case "$INTERACTIVE" in 1) has_tty ;; 0) return 1 ;; auto) has_tty ;; *) return 1 ;; esac } prompt_printf() { if tty_available; then printf "$@" > /dev/tty else printf "$@" fi } prompt_read_answer() { if tty_available; then IFS= read -r answer < /dev/tty || answer="" else IFS= read -r answer || answer="" fi } preserve_existing_username() { if [ -n "${SAVED_USERNAME:-}" ] && [ "$USERNAME" = "$SAVED_USERNAME" ]; then log_info "Preserving existing LDAP username from previous config" fi } yes_no() { prompt="$1" default_answer="$2" [ "$default_answer" = "y" ] || [ "$default_answer" = "n" ] || die "Invalid yes_no default: $default_answer" if ! interactive_enabled; then [ "$default_answer" = "y" ] return $? fi if [ "$default_answer" = "y" ]; then suffix="[Y/n]" else suffix="[y/N]" fi while :; do prompt_printf '%s%s %s%s ' "$C_BOLD" "$prompt" "$suffix" "$C_RESET" prompt_read_answer case "$answer" in "") [ "$default_answer" = "y" ]; return $? ;; y|Y|yes|YES|Yes|д|Д|да|Да|ДА) return 0 ;; n|N|no|NO|No|н|Н|нет|Нет|НЕТ) return 1 ;; *) prompt_printf 'Введите y или n.\n' ;; esac done } print_install_intro() { log_step "Lemana VPN установит приложение для подключения к корпоративному VPN" log_detail "Есть два режима: ручной ввод кода и автоматический вход. Для автоматического входа выберите ключ на Mac или Bitwarden." log_detail "Ключ из Яндекс Ключа можно импортировать отдельно, без Bitwarden." } choose_auth_profile_interactive() { log_step "Выбираю режим подключения" log_detail "Если не уверены, просто нажмите Enter: будет ручной режим с сохранением логина и пароля." while :; do prompt_printf '\n' prompt_printf '%sКакой режим использовать?%s\n' "$C_BOLD" "$C_RESET" prompt_printf ' 1) Ручной вход: запомнить логин и пароль на этом Mac (рекомендуется)\n' prompt_printf ' Логин и пароль подставятся из macOS Keychain. Одноразовый код вводится руками каждый раз.\n\n' prompt_printf ' 2) Автоматический вход\n' prompt_printf ' Пароль и код подставляются автоматически, форма отправляется без ручного ввода.\n' prompt_printf '%sВыбор [1/2, Enter=1]:%s ' "$C_BOLD" "$C_RESET" prompt_read_answer case "$answer" in ""|1) MANUAL_FORCED=1 MODE=manual-keychain apply_mode_defaults CONFIGURE_LOGIN=1 CONFIGURE_KEYCHAIN=0 ;; 2) prompt_printf 'Источник данных: 1) Ключ на этом Mac, 2) Bitwarden [1/2, Enter=1]: ' prompt_read_answer case "$answer" in ""|1) MODE=keychain-auto; CONFIGURE_LOGIN=1 ;; 2) MODE=bitwarden-auto; CONFIGURE_LOGIN=0 ;; *) prompt_printf 'Введите 1 или 2.\n'; continue ;; esac apply_mode_defaults CONFIGURE_KEYCHAIN=0 ;; *) prompt_printf 'Введите 1 или 2. Если не уверены, нажмите Enter.\n' continue ;; esac break done case "$MODE" in manual-keychain) log_info "Режим подключения: логин и пароль в macOS Keychain, одноразовый код вручную" ;; keychain-auto) log_info "Режим подключения: автоматический, ключ на этом Mac" ;; bitwarden-auto) log_info "Режим подключения: автоматический, Bitwarden" ;; esac } bool_word() { if "$@" >/dev/null 2>&1; then printf 'yes' else printf 'no' fi } keychain_has() { security find-generic-password -s "$1" -a "$2" >/dev/null 2>&1 } zsh_aliases_installed() { [ -f "$HOME/.zshrc" ] && grep -q '^# >>> lemana-vpn$' "$HOME/.zshrc" } print_detected_state() { log_step "Проверяю установленное окружение" log_detail "Так установщик понимает, какие модули уже есть, а по каким нужно спросить." log "Detected state:" log " openconnect: $(bool_word command -v openconnect)" log " pipx: $(bool_word command -v pipx)" log " openconnect-lite: $(bool_word test -x "$HOME/.local/bin/openconnect-lite")" log " Bitwarden CLI: $(bool_word command -v bw)" log " Touch ID helper: $(bool_word test -x "$INSTALL_BIN_DIR/keychain-fingerprint")" 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 2FA setup: $(bool_word keychain_has openconnect-lite "totp/$USERNAME")" } choose_modules() { if interactive_enabled; then print_install_intro fi print_detected_state case "$AUTH_PROFILE" in manual|saved-login|saved-totp|auto) ;; *) die "Unknown auth profile: $AUTH_PROFILE" ;; esac if [ "$CREDENTIAL_SOURCE_FORCED" -eq 0 ] && [ "$CREDENTIAL_SOURCE" = "none" ] && [ "$AUTH_PROFILE" != "manual" ]; then CREDENTIAL_SOURCE=keychain fi case "$CREDENTIAL_SOURCE" in none) USE_BITWARDEN=0 USE_TOUCHID=0 ;; bitwarden) USE_BITWARDEN=1 ;; keychain) USE_BITWARDEN=0 USE_TOUCHID=0 ;; *) die "Unknown credential source: $CREDENTIAL_SOURCE" ;; esac if ! interactive_enabled; then log_skip "Interactive prompts: off" return 0 fi log_info "Interactive prompts: on" log_detail "Установщик проведёт по понятному выбору режима входа; флаги командной строки имеют приоритет." if [ "$MODE_FORCED" -eq 0 ] && [ "$AUTH_PROFILE_FORCED" -eq 0 ] && [ "$CREDENTIAL_SOURCE_FORCED" -eq 0 ]; then choose_auth_profile_interactive fi if [ "$CREDENTIAL_SOURCE" = "bitwarden" ] && [ "$BITWARDEN_FORCED" -eq 0 ] && ! command -v bw >/dev/null 2>&1; then if yes_no "Bitwarden не найден на этом Mac. Установить его сейчас?" n; then MODE=bitwarden-auto apply_mode_defaults else MODE=manual-keychain apply_mode_defaults CONFIGURE_LOGIN=1 fi fi if [ "$TOUCHID_FORCED" -eq 0 ]; then if [ "$USE_BITWARDEN" -eq 1 ]; then if ! [ -x "$INSTALL_BIN_DIR/keychain-fingerprint" ]; then if yes_no "Разрешить Touch ID для открытия Bitwarden?" y; then USE_TOUCHID=1 else USE_TOUCHID=0 fi fi else USE_TOUCHID=0 fi fi if [ "$SUDOERS_FORCED" -eq 0 ]; then if ! [ -f /etc/sudoers.d/lemana-vpn-openconnect ] || ! [ -f /etc/sudoers.d/lemana-vpn-dns ]; then if yes_no "Настроить sudoers для VPN/DNS без повторного sudo-пароля?" y; then INSTALL_SUDOERS=1 else INSTALL_SUDOERS=0 fi fi fi if [ "$SHELL_FORCED" -eq 0 ] && ! zsh_aliases_installed; then if yes_no "Добавить алиасы vpn/vpn-debug/vpn-fix-dns в ~/.zshrc?" y; then INSTALL_ALIASES=1 else INSTALL_ALIASES=0 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_LOGIN_FORCED" -eq 0 ] && [ "$MODE" != "bitwarden-auto" ] && [ "$CONFIGURE_LOGIN" -eq 0 ]; then if ! keychain_has openconnect-lite "$USERNAME"; then if yes_no "Запомнить логин и пароль в macOS Keychain после установки?" y; then CONFIGURE_LOGIN=1 fi fi fi if [ "$CONFIGURE_KEYCHAIN_FORCED" -eq 0 ] && [ "$MODE" = "bitwarden-auto" ] && [ "$CREDENTIAL_SOURCE" = "keychain" ] && [ "$CONFIGURE_KEYCHAIN" -eq 0 ]; then if ! keychain_has openconnect-lite "$USERNAME" || ! keychain_has openconnect-lite "totp/$USERNAME"; then if yes_no "Сохранить пароль и постоянный код настройки 2FA в macOS Keychain после установки?" y; then CONFIGURE_KEYCHAIN=1 fi fi fi if [ "$MODE" != "bitwarden-auto" ] && [ "$IMPORT_YANDEX_KEY" -eq 0 ] && ! keychain_has openconnect-lite "totp/$USERNAME"; then import_default=n [ "$MODE" != keychain-auto ] || import_default=y if yes_no "Импортировать код из Яндекс Ключа и включить автоматический вход?" "$import_default"; then IMPORT_YANDEX_KEY=1 fi fi } script_dir() { case "$0" in */*) cd "$(dirname "$0")" 2>/dev/null && pwd ;; *) if [ -f "$PWD/$0" ]; then pwd else return 1 fi ;; esac } download_file() { src="$1" dst="$2" local_dir="$(script_dir 2>/dev/null || true)" if [ -n "$local_dir" ] && [ -f "$local_dir/$src" ]; then run cp "$local_dir/$src" "$dst" return 0 fi if [ "${LEMANA_VPN_USE_LOCAL_FILES:-0}" = "1" ] && [ -f "$PWD/$src" ]; then run cp "$PWD/$src" "$dst" return 0 fi need_cmd curl run curl -fsSL "$RAW_BASE_URL/$src" -o "$dst" } write_file() { dst="$1" content="$2" if [ "$DRY_RUN" -eq 1 ]; then printf '+ write %s\n' "$dst" return 0 fi printf '%s\n' "$content" > "$dst" } render_template_file() { src="$1" dst="$2" if [ "$DRY_RUN" -eq 1 ]; then printf '+ render %s -> %s\n' "$src" "$dst" return 0 fi USERNAME="$USERNAME" BW_ITEM="$BW_ITEM" DNS_CLEANUP="$DNS_CLEANUP" USE_TOUCHID="$USE_TOUCHID" AUTO_SOURCE="$AUTO_SOURCE" \ CONFIG_FILE="$CONFIG_DIR/env" python3 - "$src" "$dst" <<'PYCONFIG' import os import re import sys from pathlib import Path def shell_value(value): return value.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$").replace("`", "\\`") rendered = Path(sys.argv[1]).read_text() rendered = re.sub(r"\{\{(USERNAME|BW_ITEM|DNS_CLEANUP|USE_TOUCHID|AUTO_SOURCE)\}\}", lambda m: shell_value(os.environ[m[1]]), rendered) keys = re.findall(r"(?m)^(LEMANA_VPN_\w+)=", rendered) existing = Path(os.environ["CONFIG_FILE"]) kept = "" if existing.exists(): pattern = r"(?m)^(?:export[ \t]+)?(?:" + "|".join(keys) + r")=[ \t]*(?:\"(?:\\[\s\S]|[^\"\\])*\"|'[^']*'|[^\r\n]*)[ \t]*\n?" kept = re.sub(pattern, "", existing.read_text()).rstrip() Path(sys.argv[2]).write_text((kept + "\n" if kept else "") + rendered) PYCONFIG } install_homebrew_packages() { need_cmd brew log_step "Проверяю Homebrew-зависимости" log_detail "openconnect нужен как VPN-клиент, pipx — для изолированной установки openconnect-lite." for pkg in openconnect pipx; do if brew list "$pkg" >/dev/null 2>&1; then log_ok "Homebrew package already installed: $pkg" else log_info "Installing Homebrew package: $pkg" run brew install "$pkg" fi done if [ "$USE_BITWARDEN" -eq 1 ]; then if brew list bitwarden-cli >/dev/null 2>&1; then log_ok "Homebrew package already installed: bitwarden-cli" else log_info "Installing Homebrew package: bitwarden-cli" log_detail "Bitwarden CLI нужен, чтобы брать LDAP-пароль и постоянный код настройки 2FA из item '$BW_ITEM'." run brew install bitwarden-cli fi else log_skip "Bitwarden module disabled; пропускаю bitwarden-cli." fi } install_openconnect_lite() { need_cmd pipx log_step "Проверяю openconnect-lite" log_detail "openconnect-lite открывает SAML/Keycloak SSO и передаёт полученную сессию в openconnect." if [ -x "$HOME/.local/bin/openconnect-lite" ] && [ "$FORCE" -eq 0 ]; then log_ok "openconnect-lite already installed" else log_info "Installing openconnect-lite via pipx" run pipx install openconnect-lite fi if pipx --help 2>/dev/null | grep -q ' pin '; then log_detail "Закрепляю pipx package, чтобы случайное обновление не сломало SSO-патчи." run pipx pin openconnect-lite >/dev/null 2>&1 || true fi } install_cli() { tmp="$1" log_step "Обновляю CLI и uninstall helper" log_detail "CLI управляет подключением, статусом модулей, Bitwarden/Keychain sync и runtime-патчами." run mkdir -p "$INSTALL_BIN_DIR" download_file "bin/vpn-lemanapro.sh" "$tmp/vpn-lemanapro.sh" run install -m 755 "$tmp/vpn-lemanapro.sh" "$INSTALL_BIN_DIR/vpn-lemanapro.sh" log_ok "CLI installed: $INSTALL_BIN_DIR/vpn-lemanapro.sh" download_file "bin/lemana-vpn-import-yandex.py" "$tmp/lemana-vpn-import-yandex.py" run install -m 644 "$tmp/lemana-vpn-import-yandex.py" "$INSTALL_BIN_DIR/lemana-vpn-import-yandex.py" download_file "uninstall.sh" "$tmp/uninstall.sh" run install -m 755 "$tmp/uninstall.sh" "$INSTALL_BIN_DIR/uninstall-lemana-vpn.sh" log_ok "Uninstall helper installed: $INSTALL_BIN_DIR/uninstall-lemana-vpn.sh" } install_config() { tmp="$1" mode_template="$tmp/$MODE.env" log_step "Записываю конфигурацию" log_detail "Здесь сохраняются выбранный режим входа, LDAP username если он задан, и профиль openconnect-lite для Keycloak SSO." run mkdir -p "$CONFIG_DIR" "$OC_CONFIG_DIR" download_file "templates/openconnect-lite-config.toml" "$tmp/openconnect-lite-config.toml" if [ "$DRY_RUN" -eq 1 ]; then printf '+ render %s/config.toml\n' "$OC_CONFIG_DIR" else USERNAME="$USERNAME" python3 - "$OC_CONFIG_DIR/config.toml" "$tmp/openconnect-lite-config.toml" "$tmp/config.toml" <<'PYCONFIG' import json import os import re import sys from pathlib import Path existing, template, destination = map(Path, sys.argv[1:]) text = (existing if existing.exists() else template).read_text() username = "username = " + json.dumps(os.environ["USERNAME"], ensure_ascii=False) section = re.search(r"""(?ms)^[ \t]*\[[ \t]*(?:credentials|"credentials"|'credentials')[ \t]*\][ \t]*(?:#[^\n]*)?(?:\n|\Z)(.*?)(?=^[ \t]*\[|\Z)""", text) if section: body = section[1] username_key = r"""(?m)^[ \t]*(?:username|"username"|'username')[ \t]*=""" if re.search(username_key, body): body = re.sub(username_key + r".*$", lambda _: username, body) else: body = username + "\n" + body prefix = text[:section.start(1)] text = prefix + ("" if prefix.endswith("\n") else "\n") + body + text[section.end(1):] else: text = text.rstrip() + "\n\n[credentials]\n" + username + "\n" destination.write_text(text) PYCONFIG fi download_file "templates/$MODE.env" "$mode_template" render_template_file "$mode_template" "$tmp/env" run install -m 600 "$tmp/config.toml" "$OC_CONFIG_DIR/config.toml" run install -m 600 "$tmp/env" "$CONFIG_DIR/env" log_ok "Config installed: $CONFIG_DIR/env" } install_dns_cleanup() { tmp="$1" dns_cleanup_dir="$(dirname "$DNS_CLEANUP")" log_step "Устанавливаю DNS cleanup wrapper" log_detail "Wrapper сохраняет прежние DNS и восстанавливает изменения VPN после отключения или перезагрузки." log_detail "macOS может запросить sudo-пароль для записи в $dns_cleanup_dir." download_file "libexec/lemana-vpn-dns-cleanup" "$tmp/lemana-vpn-dns-cleanup" run sudo install -d -m 755 -o root -g wheel "$dns_cleanup_dir" log_info "Installing DNS cleanup wrapper: $DNS_CLEANUP" run sudo install -m 755 -o root -g wheel "$tmp/lemana-vpn-dns-cleanup" "$DNS_CLEANUP" log_ok "DNS cleanup wrapper installed" } install_sudoers() { if [ "$INSTALL_SUDOERS" -ne 1 ]; then log_skip "Sudoers module disabled; openconnect/DNS cleanup могут спрашивать пароль sudo." return 0 fi if [ -f /etc/sudoers.d/lemana-vpn-openconnect ] && [ -f /etc/sudoers.d/lemana-vpn-dns ] && [ "$FORCE" -eq 0 ]; then log_ok "Sudoers rules already installed" return 0 fi log_step "Настраиваю sudoers" log_detail "Это убирает повторный sudo-пароль при подключении, но разрешает только openconnect и DNS cleanup wrapper." openconnect_bin="$(brew --prefix)/bin/openconnect" [ -x "$openconnect_bin" ] || openconnect_bin="$(command -v openconnect || true)" [ -n "$openconnect_bin" ] || die "openconnect binary not found" tmp="$1" current_user="$(id -un)" run sudo install -d -m 755 -o root -g wheel /etc/sudoers.d write_file "$tmp/sudoers-openconnect" "$current_user ALL=(ALL) NOPASSWD: $openconnect_bin" run sudo install -m 440 -o root -g wheel "$tmp/sudoers-openconnect" /etc/sudoers.d/lemana-vpn-openconnect run sudo visudo -c -f /etc/sudoers.d/lemana-vpn-openconnect log_ok "sudoers openconnect rule is valid" write_file "$tmp/sudoers-dns" "$current_user ALL=(ALL) NOPASSWD: $DNS_CLEANUP" run sudo install -m 440 -o root -g wheel "$tmp/sudoers-dns" /etc/sudoers.d/lemana-vpn-dns run sudo visudo -c -f /etc/sudoers.d/lemana-vpn-dns log_ok "sudoers DNS cleanup rule is valid" } install_touchid_helper() { if [ "$USE_TOUCHID" -ne 1 ]; then log_skip "Touch ID module disabled; мастер-пароль Bitwarden будет вводиться вручную при необходимости." return 0 fi log_step "Проверяю Touch ID helper" log_detail "Helper добавляет локальный Touch ID prompt перед чтением мастер-пароля Bitwarden из Keychain." if [ -x "$INSTALL_BIN_DIR/keychain-fingerprint" ] && [ "$FORCE" -eq 0 ]; then log_ok "Touch ID helper already installed: $INSTALL_BIN_DIR/keychain-fingerprint" return 0 fi need_cmd git need_cmd swiftc tmp="$1" log_info "Building keychain-fingerprint helper" run git clone --depth 1 https://github.com/dss99911/keychain-fingerprint.git "$tmp/keychain-fingerprint" run swiftc -o "$tmp/keychain-fingerprint-bin" "$tmp/keychain-fingerprint/main.swift" -framework LocalAuthentication -framework Security run install -m 700 "$tmp/keychain-fingerprint-bin" "$INSTALL_BIN_DIR/keychain-fingerprint" log_ok "Touch ID helper installed" } install_menu_bar_app() { if [ "$INSTALL_APP" -ne 1 ]; then log_skip "Menu Bar app disabled; CLI и алиасы останутся основным интерфейсом." return 0 fi need_cmd swift tmp="$1" app_src="$tmp/app" log_step "Собираю Swift Menu Bar app" log_detail "Приложение живёт в status bar и вызывает $INSTALL_BIN_DIR/vpn-lemanapro.sh для подключения и статуса модулей." log_detail "Swift build может занять минуту; строки вида '[2/5] Write swift-version...' — нормальный вывод компилятора." 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" download_file "app/Sources/LemanaVPN/BoundedLog.swift" "$app_src/Sources/LemanaVPN/BoundedLog.swift" download_file "app/Sources/LemanaVPN/SessionTiming.swift" "$app_src/Sources/LemanaVPN/SessionTiming.swift" download_file "app/Sources/LemanaVPN/VPNIcons.swift" "$app_src/Sources/LemanaVPN/VPNIcons.swift" log_info "Building LemanaVPN.app" run swift build -c release --package-path "$app_src" app_bin="$app_src/.build/release/LemanaVPN" info_plist="$tmp/Info.plist" if [ "$DRY_RUN" -eq 0 ] && [ ! -x "$app_bin" ]; then die "Swift build did not produce $app_bin" fi run mkdir -p "$APP_DIR/Contents/MacOS" "$APP_DIR/Contents/Resources" run install -m 755 "$app_bin" "$APP_DIR/Contents/MacOS/LemanaVPN" write_file "$info_plist" ' CFBundleExecutable LemanaVPN CFBundleIdentifier ru.dokops.LemanaVPN CFBundleName LemanaVPN CFBundleDisplayName LemanaVPN CFBundlePackageType APPL CFBundleShortVersionString 1.0 CFBundleVersion 1 LSMinimumSystemVersion 13.0 LSUIElement ' run install -m 644 "$info_plist" "$APP_DIR/Contents/Info.plist" log_ok "Menu Bar app installed: $APP_DIR" } install_launch_agent() { if [ "$INSTALL_AUTOSTART" -ne 1 ]; then log_skip "Autostart disabled; LemanaVPN.app можно запускать вручную." return 0 fi if [ "$INSTALL_APP" -ne 1 ]; then log_skip "Autostart skipped because Menu Bar app is disabled." return 0 fi tmp="$1" plist="$tmp/ru.dokops.LemanaVPN.plist" log_step "Настраиваю автозапуск Menu Bar app" log_detail "LaunchAgent запускает LemanaVPN.app при логине, чтобы VPN был доступен из status bar." run mkdir -p "$HOME/Library/LaunchAgents" write_file "$plist" " Label ru.dokops.LemanaVPN ProgramArguments $APP_DIR/Contents/MacOS/LemanaVPN RunAtLoad KeepAlive " run install -m 644 "$plist" "$LAUNCH_AGENT" if [ "$DRY_RUN" -eq 0 ]; then launchctl unload "$LAUNCH_AGENT" >/dev/null 2>&1 || true launchctl load "$LAUNCH_AGENT" >/dev/null 2>&1 || true else printf '+ launchctl load %s\n' "$LAUNCH_AGENT" fi log_ok "LaunchAgent installed: $LAUNCH_AGENT" } restart_running_menu_bar_app() { [ "$INSTALL_APP" -eq 1 ] || return 0 if [ "$DRY_RUN" -eq 1 ]; then printf '+ restart LemanaVPN.app if running\n' return 0 fi if pgrep -x LemanaVPN >/dev/null 2>&1; then log_step "Перезапускаю уже запущенное LemanaVPN.app" log_detail "Это нужно, чтобы status bar приложение сразу увидело свежий CLI и новый код." killall LemanaVPN >/dev/null 2>&1 || true sleep 1 open "$APP_DIR" >/dev/null 2>&1 || true log_ok "LemanaVPN.app restarted" else log_skip "LemanaVPN.app сейчас не запущен; перезапуск не нужен." fi } install_shell_aliases() { if [ "$INSTALL_ALIASES" -ne 1 ]; then log_skip "Shell aliases disabled; команды можно вызывать напрямую из $INSTALL_BIN_DIR." return 0 fi zshrc="$HOME/.zshrc" tmp="$1" log_step "Обновляю shell aliases" log_detail "Основные алиасы vpn, vpn-auto, vpn-debug и vpn-fix-dns добавляются идемпотентным блоком в ~/.zshrc." log_detail "vpn-manual и vpn-manual-full остаются только как совместимые fallback-команды." [ -f "$zshrc" ] || run touch "$zshrc" block="$tmp/zshrc-block" if [ "$DRY_RUN" -eq 1 ]; then printf '+ update %s aliases\n' "$zshrc" return 0 fi cat > "$block" <>> lemana-vpn vpn() { "$INSTALL_BIN_DIR/vpn-lemanapro.sh" "\$@"; } vpn-auto() { "$INSTALL_BIN_DIR/vpn-lemanapro.sh" --auto "\$@"; } vpn-manual() { "$INSTALL_BIN_DIR/vpn-lemanapro.sh" --manual "\$@"; } vpn-manual-full() { "$INSTALL_BIN_DIR/vpn-lemanapro.sh" --manual-full "\$@"; } vpn-debug() { "$INSTALL_BIN_DIR/vpn-lemanapro.sh" --debug "\$@"; } vpn-fix-dns() { "$INSTALL_BIN_DIR/vpn-lemanapro.sh" --repair-dns; } # <<< lemana-vpn EOF awk ' /^# >>> lemana-vpn$/ { skip=1; next } /^# <<< lemana-vpn$/ { skip=0; next } skip != 1 { print } ' "$zshrc" > "$tmp/zshrc" { cat "$tmp/zshrc" printf '\n' cat "$block" } > "$tmp/zshrc.new" mv "$tmp/zshrc.new" "$zshrc" log_ok "Shell aliases updated: $zshrc" } maybe_login_bitwarden() { if [ "$CREDENTIAL_SOURCE" != "bitwarden" ]; then log_skip "Bitwarden не выбран; пропускаю Bitwarden login." return 0 fi if [ "$DRY_RUN" -eq 1 ]; then printf '+ bw status # check Bitwarden login state\n' return 0 fi if ! command -v bw >/dev/null 2>&1; then log_warn "Bitwarden CLI enabled but bw not found." return 0 fi log_step "Проверяю Bitwarden CLI session" log_detail "На первом запуске vpn CLI сможет понять, нужно ли делать bw login/unlock." status="$(bw status 2>/dev/null || true)" if printf '%s\n' "$status" | grep -q '"status":"unauthenticated"'; then log_warn "Bitwarden CLI is not logged in. Run later: bw login" elif printf '%s\n' "$status" | grep -q '"status":"locked"'; then log_info "Bitwarden CLI is logged in but locked. First vpn run will ask for master password." else log_ok "Bitwarden CLI is available." fi } auth_profile_label() { case "$MODE" in manual-keychain) printf 'ручной вход: логин и пароль в Keychain, одноразовый код вручную' ;; keychain-auto) printf 'автоматический вход: ключ на этом Mac' ;; bitwarden-auto) printf 'автоматический вход: Bitwarden' ;; *) printf '%s' "$MODE" ;; esac } print_final_next_steps() { log "" log_ok "Готово." log_info "Open a new shell or run: exec zsh" if [ "$IMPORT_YANDEX_KEY" -eq 1 ] || [ "$MODE" != manual-keychain ]; then log_info "Как подключиться: автоматический вход командой vpn или кнопкой \"Подключить\"." else log_info "Как подключиться: Lemana VPN подставит логин и пароль, одноразовый код введите вручную." fi log_detail "Способ входа можно изменить в меню приложения." log_info "Status: vpn --status" if [ "$INSTALL_APP" -eq 1 ]; then log_info "App: open '$APP_DIR'" fi } main() { [ "$(uname -s)" = "Darwin" ] || die "This installer supports macOS only" tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT INT TERM preserve_existing_username choose_modules if [ "$IMPORT_YANDEX_KEY" -eq 1 ]; then [ "$MODE" != "bitwarden-auto" ] || die "Импорт Яндекс Ключа используется с локальным ключом, без Bitwarden." if [ -z "$USERNAME" ] || ! keychain_has openconnect-lite "$USERNAME"; then CONFIGURE_LOGIN=1 fi fi log_step "Начинаю установку Lemana VPN" log_detail "Повторный запуск безопасен: файлы обновляются идемпотентно, существующие credentials не перезаписываются." if interactive_enabled; then log_info "Выбран режим: $(auth_profile_label)" log_detail "Bitwarden и Touch ID будут установлены только если они выбраны явно." else log_info "Modules: mode=$MODE bitwarden=$USE_BITWARDEN touchid=$USE_TOUCHID sudoers=$INSTALL_SUDOERS shell=$INSTALL_ALIASES app=$INSTALL_APP autostart=$INSTALL_AUTOSTART" fi install_homebrew_packages install_openconnect_lite install_cli "$tmp" install_config "$tmp" install_dns_cleanup "$tmp" install_sudoers "$tmp" install_touchid_helper "$tmp" install_menu_bar_app "$tmp" install_launch_agent "$tmp" restart_running_menu_bar_app install_shell_aliases "$tmp" maybe_login_bitwarden if [ "$CONFIGURE_LOGIN" -eq 1 ]; then log_step "Записываю логин и пароль в macOS Keychain" log_detail "Будет запрошен корпоративный LDAP-пароль; настройка одноразового кода выполняется отдельно." run "$INSTALL_BIN_DIR/vpn-lemanapro.sh" --configure-login elif [ "$CONFIGURE_KEYCHAIN" -eq 1 ]; then log_step "Записываю credentials в macOS Keychain" log_detail "Будут запрошены LDAP-пароль и постоянный код настройки 2FA, не текущий 30-секундный код." run "$INSTALL_BIN_DIR/vpn-lemanapro.sh" --configure-keychain fi if [ "$IMPORT_YANDEX_KEY" -eq 1 ]; then log_step "Импортирую код из Яндекс Ключа" log_detail "Выберите локальный файл и запись VPN, затем подтвердите её текущим кодом с телефона." run "$INSTALL_BIN_DIR/vpn-lemanapro.sh" --import-yandex-key fi print_final_next_steps } main "$@"