#!/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"
USERNAME="${LEMANA_VPN_USERNAME:-60103293}"
BW_ITEM="${LEMANA_VPN_BW_ITEM:-LM LDAP}"
CREDENTIAL_SOURCE="${LEMANA_VPN_CREDENTIAL_SOURCE:-bitwarden}"
USE_BITWARDEN=1
USE_TOUCHID=1
INSTALL_SUDOERS=1
INSTALL_ALIASES=1
INSTALL_APP=1
INSTALL_AUTOSTART=1
CONFIGURE_KEYCHAIN=0
DRY_RUN=0
FORCE=0
INTERACTIVE=auto
BITWARDEN_FORCED=0
CREDENTIAL_SOURCE_FORCED=0
TOUCHID_FORCED=0
SUDOERS_FORCED=0
SHELL_FORCED=0
APP_FORCED=0
AUTOSTART_FORCED=0
CONFIGURE_KEYCHAIN_FORCED=0
APP_DIR="${LEMANA_VPN_APP_DIR:-$HOME/Applications/LemanaVPN.app}"
LAUNCH_AGENT="$HOME/Library/LaunchAgents/ru.dokops.LemanaVPN.plist"
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 (default)
--without-bitwarden Do not install/use Bitwarden CLI; use Keychain credentials
--credential-source VALUE Credential source: bitwarden or keychain
--with-touchid Install/use keychain-fingerprint Touch ID helper (default)
--without-touchid Do not install/use Touch ID helper
--configure-keychain Prompt for LDAP password and TOTP secret after install
--username VALUE Corporate LDAP username (default: 60103293)
--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 Same as --without-bitwarden --without-touchid
--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 --minimal --configure-keychain
sh install.sh --credential-source keychain --configure-keychain
sh install.sh --without-touchid
USAGE
}
while [ "$#" -gt 0 ]; do
case "$1" in
--with-bitwarden)
CREDENTIAL_SOURCE="bitwarden"
USE_BITWARDEN=1
BITWARDEN_FORCED=1
CREDENTIAL_SOURCE_FORCED=1
;;
--without-bitwarden)
CREDENTIAL_SOURCE="keychain"
USE_BITWARDEN=0
USE_TOUCHID=0
BITWARDEN_FORCED=1
CREDENTIAL_SOURCE_FORCED=1
;;
--credential-source)
shift
[ "$#" -gt 0 ] || { echo "--credential-source requires bitwarden or keychain" >&2; exit 1; }
CREDENTIAL_SOURCE="$1"
CREDENTIAL_SOURCE_FORCED=1
case "$CREDENTIAL_SOURCE" in
bitwarden)
USE_BITWARDEN=1
;;
keychain)
USE_BITWARDEN=0
USE_TOUCHID=0
;;
*)
echo "--credential-source requires bitwarden or keychain" >&2
exit 1
;;
esac
;;
--with-touchid)
USE_TOUCHID=1
TOUCHID_FORCED=1
;;
--without-touchid)
USE_TOUCHID=0
TOUCHID_FORCED=1
;;
--configure-keychain)
CONFIGURE_KEYCHAIN=1
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)
CREDENTIAL_SOURCE="keychain"
USE_BITWARDEN=0
USE_TOUCHID=0
BITWARDEN_FORCED=1
CREDENTIAL_SOURCE_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
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"
}
has_tty() {
{ [ -r /dev/tty ] && [ -w /dev/tty ]; } || [ -t 0 ]
}
interactive_enabled() {
case "$INTERACTIVE" in
1) has_tty ;;
0) return 1 ;;
auto) has_tty ;;
*) return 1 ;;
esac
}
prompt_printf() {
if [ -w /dev/tty ]; then
printf "$@" > /dev/tty
else
printf "$@"
fi
}
prompt_read_answer() {
if [ -r /dev/tty ]; then
IFS= read -r answer < /dev/tty || answer=""
else
IFS= read -r answer || answer=""
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
}
choose_credential_source_interactive() {
log_step "Настраиваю способ хранения credentials"
log_detail "Можно выбрать Bitwarden или бесплатный macOS Keychain flow с ручным вводом LDAP password и TOTP seed."
while :; do
prompt_printf '\n'
prompt_printf '%sКак хранить VPN credentials?%s\n' "$C_BOLD" "$C_RESET"
prompt_printf ' 1) Bitwarden -> macOS Keychain\n'
prompt_printf ' 2) macOS Keychain: ввести LDAP password и TOTP seed сейчас\n'
prompt_printf ' 3) macOS Keychain: настрою вручную позже\n'
prompt_printf '%sВыбор [1/2/3, Enter=1]:%s ' "$C_BOLD" "$C_RESET"
prompt_read_answer
case "$answer" in
""|1)
CREDENTIAL_SOURCE=bitwarden
USE_BITWARDEN=1
;;
2)
CREDENTIAL_SOURCE=keychain
USE_BITWARDEN=0
USE_TOUCHID=0
CONFIGURE_KEYCHAIN=1
;;
3)
CREDENTIAL_SOURCE=keychain
USE_BITWARDEN=0
USE_TOUCHID=0
CONFIGURE_KEYCHAIN=0
;;
*)
prompt_printf 'Введите 1, 2 или 3.\n'
continue
;;
esac
break
done
case "$CREDENTIAL_SOURCE" in
bitwarden)
log_info "Credential source: Bitwarden sync into macOS Keychain"
;;
keychain)
if [ "$CONFIGURE_KEYCHAIN" -eq 1 ]; then
log_info "Credential source: macOS Keychain, credentials будут запрошены после установки"
else
log_info "Credential source: macOS Keychain, credentials можно настроить позже через vpn --configure-keychain"
fi
;;
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 TOTP seed: $(bool_word keychain_has openconnect-lite "totp/$USERNAME")"
}
choose_modules() {
print_detected_state
case "$CREDENTIAL_SOURCE" in
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 "Установщик проведёт по выбору credential source и отсутствующих опциональных модулей; флаги командной строки имеют приоритет."
if [ "$CREDENTIAL_SOURCE_FORCED" -eq 0 ]; then
choose_credential_source_interactive
fi
if [ "$CREDENTIAL_SOURCE" = "bitwarden" ] && [ "$BITWARDEN_FORCED" -eq 0 ] && ! command -v bw >/dev/null 2>&1; then
if yes_no "Bitwarden CLI не найден. Поставить модуль Bitwarden?" y; then
CREDENTIAL_SOURCE=bitwarden
USE_BITWARDEN=1
else
CREDENTIAL_SOURCE=keychain
USE_BITWARDEN=0
USE_TOUCHID=0
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 helper не найден. Собрать и установить?" 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_KEYCHAIN_FORCED" -eq 0 ] && [ "$CREDENTIAL_SOURCE" = "keychain" ] && [ "$CONFIGURE_KEYCHAIN" -eq 0 ]; then
if ! keychain_has openconnect-lite "$USERNAME" || ! keychain_has openconnect-lite "totp/$USERNAME"; then
if yes_no "Bitwarden отключён, а Keychain credentials неполные. Записать LDAP-пароль и TOTP seed после установки?" y; then
CONFIGURE_KEYCHAIN=1
fi
fi
fi
}
script_dir() {
case "$0" in
*/*) cd "$(dirname "$0")" 2>/dev/null && pwd ;;
*) return 1 ;;
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"
}
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-пароль и TOTP seed из 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 "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"
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
sed "s/{{USERNAME}}/$USERNAME/g" "$tmp/openconnect-lite-config.toml" > "$OC_CONFIG_DIR/config.toml"
chmod 600 "$OC_CONFIG_DIR/config.toml"
fi
env_content="LEMANA_VPN_USERNAME=\"$USERNAME\"
LEMANA_VPN_CREDENTIAL_SOURCE=\"$CREDENTIAL_SOURCE\"
LEMANA_VPN_BW_ITEM=\"$BW_ITEM\"
LEMANA_VPN_USE_BITWARDEN=\"$USE_BITWARDEN\"
LEMANA_VPN_USE_TOUCHID=\"$USE_TOUCHID\"
LEMANA_VPN_DNS_CLEANUP=\"$DNS_CLEANUP\""
write_file "$tmp/env" "$env_content"
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 сбрасывает только похожие на корпоративные 10.x 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
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"
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-manual, vpn-manual-full, vpn-debug и vpn-fix-dns добавляются идемпотентным блоком в ~/.zshrc."
[ -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() { sudo "$DNS_CLEANUP"; }
# <<< 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 "Credential source is keychain; пропускаю Bitwarden login."
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
}
main() {
[ "$(uname -s)" = "Darwin" ] || die "This installer supports macOS only"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT INT TERM
choose_modules
log_step "Начинаю установку Lemana VPN"
log_detail "Повторный запуск безопасен: файлы обновляются идемпотентно, существующие credentials не перезаписываются."
log_info "Modules: credential_source=$CREDENTIAL_SOURCE bitwarden=$USE_BITWARDEN touchid=$USE_TOUCHID sudoers=$INSTALL_SUDOERS shell=$INSTALL_ALIASES app=$INSTALL_APP autostart=$INSTALL_AUTOSTART"
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_KEYCHAIN" -eq 1 ]; then
log_step "Записываю credentials в macOS Keychain"
log_detail "Будут запрошены LDAP-пароль и постоянный TOTP seed, не текущий 30-секундный код."
run "$INSTALL_BIN_DIR/vpn-lemanapro.sh" --configure-keychain
fi
log ""
log_ok "Done."
log_info "Open a new shell or run: exec zsh"
log_info "Connect: vpn"
log_info "Status: vpn --status"
if [ "$INSTALL_APP" -eq 1 ]; then
log_info "App: open '$APP_DIR'"
fi
}
main "$@"