diff --git a/README.md b/README.md index ee03aa2..e783764 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,13 @@ CLI-установка корпоративного VPN `vpn.lemanapro.ru` для macOS. +**Модули по умолчанию:** Core: включён; Bitwarden: включён; Touch ID: включён; DNS cleanup: включён; runtime-патчи: применяются автоматически перед подключением. + Репозиторий собирает в один воспроизводимый пакет то, что раньше было ручной локальной настройкой: - `openconnect` как VPN-клиент; - `openconnect-lite` для SAML SSO через Keycloak; -- опциональный Bitwarden CLI для LDAP-пароля и TOTP; +- опциональный Bitwarden CLI для LDAP-пароля и TOTP seed; - опциональный Touch ID helper для мастер-пароля Bitwarden; - безопасный DNS cleanup через root-owned wrapper; - алиасы `vpn`, `vpn-debug`, `vpn-fix-dns`. @@ -38,7 +40,7 @@ curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh | curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh | sh -s -- --without-touchid ``` -Минимальная установка без Bitwarden и Touch ID. Пароль LDAP и TOTP будут один раз записаны в macOS Keychain вручную: +Минимальная установка без Bitwarden и Touch ID. В macOS Keychain вручную будут записаны LDAP-пароль и TOTP secret. Не текущий 30-секундный TOTP-код, а постоянный seed из настройки 2FA. ```sh curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh | sh -s -- --minimal --configure-keychain @@ -72,6 +74,35 @@ curl -fsSL https://example.org/dokril/lemana-vpn/raw/branch/main/install.sh \ | `/etc/sudoers.d/lemana-vpn-dns` | `NOPASSWD` только для DNS cleanup wrapper | | `~/.zshrc` | Идемпотентный блок алиасов `vpn`, `vpn-debug`, `vpn-fix-dns` | +## Статус модулей + +`vpn` и `vpn --status` первой строкой показывают, какие модули включены в конфиге и реально установлены на машине: + +```sh +vpn --status +Modules: core=ok, bitwarden=on, touchid=on, dns=on, patches=active, keychain=password:yes/totp_seed:yes +VPN disconnected +``` + +Значения: + +| Поле | Значение | +| --- | --- | +| `core=ok` | Есть `openconnect`, `openconnect-lite` и config | +| `bitwarden=on` | Модуль включён и `bw` установлен | +| `bitwarden=off` | Модуль отключён через `--without-bitwarden` или `LEMANA_VPN_USE_BITWARDEN=0` | +| `bitwarden=missing` | Модуль включён, но `bw` не найден | +| `touchid=on/off/missing` | Состояние Touch ID helper | +| `dns=on/missing` | Наличие DNS cleanup wrapper | +| `patches=active/pending` | Применены ли runtime-патчи `openconnect-lite` | +| `keychain=password:yes/totp_seed:yes` | Есть ли LDAP-пароль и TOTP seed в Keychain | + +JSON-режим тоже отдаёт модульный статус: + +```sh +vpn --status --json +``` + ## Модули ### Core @@ -87,7 +118,9 @@ curl -fsSL https://example.org/dokril/lemana-vpn/raw/branch/main/install.sh \ ### Bitwarden -Включён по умолчанию. CLI при каждом запуске `vpn` пытается получить LDAP-пароль и TOTP из записи Bitwarden `LM LDAP`, затем записывает их в macOS Keychain для `openconnect-lite`. +Включён по умолчанию. CLI при каждом запуске `vpn` пытается получить LDAP-пароль и TOTP seed из записи Bitwarden `LM LDAP`, затем записывает их в macOS Keychain для `openconnect-lite`. + +TOTP seed — это постоянный секрет 2FA. Сам одноразовый TOTP-код меняется каждые 30 секунд и генерируется `openconnect-lite` в момент входа. Отключить: @@ -101,6 +134,34 @@ sh install.sh --without-bitwarden vpn-lemanapro.sh --configure-keychain ``` +### Если Bitwarden нет + +Bitwarden не обязателен. Без него установка работает как обычный `openconnect-lite` profile с секретами в macOS Keychain. + +Установка: + +```sh +curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh \ + | sh -s -- --without-bitwarden --without-touchid --configure-keychain +``` + +Что понадобится: + +- LDAP username; +- LDAP password; +- TOTP secret из корпоративной 2FA настройки. + +Важно: вводить нужно не текущие 6 цифр из authenticator-приложения, а постоянный secret. Обычно он есть в QR-коде как `secret=BASE32...` или может быть показан при ручной настройке TOTP. + +Если secret есть только в QR-коде: + +1. Открой QR-код в приложении/на портале, где настраивалась 2FA. +2. Найди режим ручной настройки, где показывается secret. +3. Если доступен только QR, его нужно расшифровать любым локальным QR-сканером и взять параметр `secret`. +4. Вставь secret в prompt `TOTP secret (BASE32...)`. + +Если TOTP secret получить нельзя, автоматический headless-вход невозможен: `openconnect-lite` не сможет сам генерировать свежий TOTP-код на каждом входе. + ### Touch ID Включён по умолчанию. Установщик собирает `keychain-fingerprint` из `https://github.com/dss99911/keychain-fingerprint.git` и кладёт бинарник в `~/bin/keychain-fingerprint`. @@ -128,7 +189,7 @@ vpn-fix-dns # сбросить корпоративные DNS после 1. CLI проверит `bw`. 2. Если vault locked, попросит мастер-пароль. 3. Если установлен Touch ID helper, предложит сохранить мастер-пароль за Touch ID prompt. -4. Достанет `LM LDAP`, запишет LDAP-пароль и TOTP в Keychain. +4. Достанет `LM LDAP`, запишет LDAP-пароль и TOTP seed в Keychain. 5. Запустит `openconnect-lite` и пройдёт Keycloak SSO. ## Настройка @@ -159,6 +220,8 @@ curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh \ - password: LDAP пароль; - TOTP: `otpauth://...secret=BASE32...` или raw BASE32 secret. +Это не 6-значный одноразовый код. В Bitwarden должен лежать постоянный TOTP secret, из которого коды генерируются автоматически. + ## Почему DNS wrapper, а не wildcard sudoers Старый вариант давал `NOPASSWD` на `networksetup -setdnsservers *`. Это слишком широкое право: любой локальный процесс пользователя мог поменять DNS на произвольный сервер. diff --git a/bin/vpn-lemanapro.sh b/bin/vpn-lemanapro.sh index def47c8..d60dd66 100755 --- a/bin/vpn-lemanapro.sh +++ b/bin/vpn-lemanapro.sh @@ -45,7 +45,7 @@ Usage: vpn-lemanapro.sh [--debug] [--json] [--status] [--configure-keychain] --status --json Show current VPN status as JSON --debug Run visible browser and passthrough debug logs --json Emit JSON Lines events for UI wrappers - --configure-keychain Prompt for LDAP password/TOTP and save them to Keychain + --configure-keychain Prompt for LDAP password and TOTP secret, then save them to Keychain HELP exit 0 ;; @@ -75,11 +75,113 @@ _json_get() { python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('$key',''))" 2>/dev/null || true } +_find_webengine_process() { + if [[ -n "${LEMANA_VPN_WEBENGINE_PROCESS:-}" ]]; then + printf '%s\n' "$LEMANA_VPN_WEBENGINE_PROCESS" + return 0 + fi + find "$OC_VENV/lib" -path '*/site-packages/openconnect_lite/browser/webengine_process.py' -print -quit 2>/dev/null +} + +_module_bool() { + if "$@" >/dev/null 2>&1; then + printf true + else + printf false + fi +} + +_patches_active() { + local wep + wep="$(_find_webengine_process)" + [[ -n "$wep" && -f "$wep" ]] || return 1 + grep -q '"offscreen"' "$wep" \ + && grep -q 'new Event("input", {{bubbles: true}})' "$wep" \ + && grep -q 'new RegExp' "$wep" +} + +_keychain_has() { + security find-generic-password -s "$1" -a "$2" >/dev/null 2>&1 +} + +_module_status_json() { + local openconnect_installed openconnect_lite_installed bitwarden_installed touchid_installed dns_cleanup_installed + local config_present oc_config_present patch_backup_present patches_active keychain_password keychain_totp_seed + openconnect_installed="$(_module_bool command -v openconnect)" + openconnect_lite_installed="$(_module_bool test -x "$OC_BIN")" + bitwarden_installed="$(_module_bool command -v bw)" + touchid_installed="$(_module_bool test -x "$KC_FP")" + dns_cleanup_installed="$(_module_bool test -x "$DNS_CLEANUP")" + config_present="$(_module_bool test -f "$CONFIG_FILE")" + oc_config_present="$(_module_bool test -f "$HOME/.config/openconnect-lite/config.toml")" + patch_backup_present="$(_module_bool test -f "$PATCH_BACKUP_DIR/webengine_process.py.before-lemana-vpn")" + patches_active="$(_module_bool _patches_active)" + keychain_password="$(_module_bool _keychain_has openconnect-lite "$KC_USERNAME")" + keychain_totp_seed="$(_module_bool _keychain_has openconnect-lite "totp/$KC_USERNAME")" + + printf '{"core":{"openconnect":%s,"openconnect_lite":%s,"config":%s,"openconnect_lite_config":%s},"bitwarden":{"enabled":%s,"installed":%s,"item":"%s"},"touchid":{"enabled":%s,"installed":%s},"keychain":{"password":%s,"totp_seed":%s},"dns_cleanup":{"installed":%s},"patches":{"active":%s,"backup":%s}}' \ + "$openconnect_installed" \ + "$openconnect_lite_installed" \ + "$config_present" \ + "$oc_config_present" \ + "$([[ "$USE_BITWARDEN" == "1" ]] && printf true || printf false)" \ + "$bitwarden_installed" \ + "$BW_ITEM_NAME" \ + "$([[ "$USE_TOUCHID" == "1" ]] && printf true || printf false)" \ + "$touchid_installed" \ + "$keychain_password" \ + "$keychain_totp_seed" \ + "$dns_cleanup_installed" \ + "$patches_active" \ + "$patch_backup_present" +} + +_module_human_part() { + local name="$1" enabled="$2" installed="$3" + if [[ "$enabled" == "0" ]]; then + printf '%s=off' "$name" + elif [[ "$installed" == "true" ]]; then + printf '%s=on' "$name" + else + printf '%s=missing' "$name" + fi +} + +_module_status_human() { + local core bitwarden_installed touchid_installed dns_cleanup_installed patches_active keychain_password keychain_totp_seed + if command -v openconnect >/dev/null 2>&1 && [[ -x "$OC_BIN" && -f "$HOME/.config/openconnect-lite/config.toml" ]]; then + core="core=ok" + else + core="core=missing" + fi + + bitwarden_installed="$(_module_bool command -v bw)" + touchid_installed="$(_module_bool test -x "$KC_FP")" + dns_cleanup_installed="$(_module_bool test -x "$DNS_CLEANUP")" + patches_active="$(_module_bool _patches_active)" + keychain_password="$(_module_bool _keychain_has openconnect-lite "$KC_USERNAME")" + keychain_totp_seed="$(_module_bool _keychain_has openconnect-lite "totp/$KC_USERNAME")" + + printf 'Modules: %s, ' "$core" + _module_human_part "bitwarden" "$USE_BITWARDEN" "$bitwarden_installed" + printf ', ' + _module_human_part "touchid" "$USE_TOUCHID" "$touchid_installed" + printf ', dns=%s, patches=%s, keychain=password:%s/totp_seed:%s\n' \ + "$([[ "$dns_cleanup_installed" == "true" ]] && printf on || printf missing)" \ + "$([[ "$patches_active" == "true" ]] && printf active || printf pending)" \ + "$([[ "$keychain_password" == "true" ]] && printf yes || printf no)" \ + "$([[ "$keychain_totp_seed" == "true" ]] && printf yes || printf no)" +} + _check_status() { + local modules_json + modules_json="$(_module_status_json)" + if [[ ! -f "$STATUS_FILE" ]]; then if $JSON_MODE; then - printf '%s\n' '{"state":"disconnected","reason":"no status file"}' + printf '{"state":"disconnected","reason":"no status file","modules":%s}\n' "$modules_json" else + _module_status_human printf '%s\n' "VPN disconnected (нет status-файла)" fi return 0 @@ -100,8 +202,9 @@ _check_status() { if [[ "$state" != "connected" ]] || ! $process_alive; then if $JSON_MODE; then - printf '{"state":"disconnected","reason":"%s"}\n' "$(! $process_alive && printf 'process dead (pid=%s)' "$pid" || printf '%s' "$state")" + printf '{"state":"disconnected","reason":"%s","modules":%s}\n' "$(! $process_alive && printf 'process dead (pid=%s)' "$pid" || printf '%s' "$state")" "$modules_json" else + _module_status_human printf '%s\n' "VPN disconnected" fi return 0 @@ -125,22 +228,15 @@ _check_status() { fi if $JSON_MODE; then - printf '{"state":"connected","ip":"%s","healthy":%s,"remaining_sec":%s,"expires":"%s","pid":%s,"dns_target":"%s"}\n' \ - "$ip" "$healthy" "$remaining_sec" "$expires" "$pid" "$dns_target" + printf '{"state":"connected","ip":"%s","healthy":%s,"remaining_sec":%s,"expires":"%s","pid":%s,"dns_target":"%s","modules":%s}\n' \ + "$ip" "$healthy" "$remaining_sec" "$expires" "$pid" "$dns_target" "$modules_json" else + _module_status_human printf 'VPN connected - %s (session: %sh %sm, tunnel: %s)\n' \ "$ip" "$hours" "$mins" "$($healthy && printf healthy || printf unhealthy)" fi } -_find_webengine_process() { - if [[ -n "${LEMANA_VPN_WEBENGINE_PROCESS:-}" ]]; then - printf '%s\n' "$LEMANA_VPN_WEBENGINE_PROCESS" - return 0 - fi - find "$OC_VENV/lib" -path '*/site-packages/openconnect_lite/browser/webengine_process.py' -print -quit 2>/dev/null -} - _patch_oc() { local wep wep="$(_find_webengine_process)" @@ -433,6 +529,12 @@ fi trap '_dns_cleanup; _clear_status' EXIT +if ! $JSON_MODE; then + _module_status_human +else + printf '{"event":"modules","modules":%s}\n' "$(_module_status_json)" +fi + _patch_oc _sync_bitwarden diff --git a/install.sh b/install.sh index aef5889..2e2c330 100755 --- a/install.sh +++ b/install.sh @@ -29,7 +29,7 @@ Options: --without-bitwarden Do not install/use Bitwarden CLI; use Keychain credentials --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/TOTP after install + --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