1289 lines
42 KiB
Bash
Executable File
1289 lines
42 KiB
Bash
Executable File
#!/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" '<?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"
|
||
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" "<?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
|
||
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" <<EOF
|
||
# >>> 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 "$@"
|