diff --git a/README.md b/README.md index c8aef01..b05e1a7 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,15 @@ CLI-установка корпоративного VPN `vpn.lemanapro.ru` для macOS. -**Модули по умолчанию:** Core: включён; Bitwarden: включён; Touch ID: включён; DNS cleanup: включён; Swift Menu Bar app: включён; автозапуск приложения: включён; runtime-патчи: применяются автоматически перед подключением. +**По умолчанию:** ручной вход без сохранения паролей. Откроется окно корпоративного входа, а логин, пароль и одноразовый код вводятся вручную. Bitwarden, Touch ID, сохранение пароля, подстановка 2FA и автоматический вход включаются только явно. -**Credential sources:** `bitwarden` синхронизирует LDAP-пароль и TOTP seed из Bitwarden в macOS Keychain; `keychain` хранит LDAP-пароль и постоянный TOTP seed напрямую в macOS Keychain. Оба источника используют один и тот же runtime `openconnect-lite` для SSO/autofill. +**Режимы входа:** `manual` ничего не сохраняет и ничего не подставляет; `saved-login` хранит LDAP логин/пароль в macOS Keychain и оставляет одноразовый код ручным; `saved-totp` дополнительно хранит постоянный код настройки 2FA; `auto` запускается только явно через `vpn-auto`/`--auto`. Репозиторий собирает в один воспроизводимый пакет то, что раньше было ручной локальной настройкой: - `openconnect` как VPN-клиент; - `openconnect-lite` для SAML SSO через Keycloak; -- опциональный Bitwarden CLI для LDAP-пароля и TOTP seed; +- опциональный Bitwarden CLI для LDAP-пароля и постоянного кода настройки 2FA; - опциональный Touch ID helper для мастер-пароля Bitwarden; - Swift Menu Bar app `LemanaVPN.app`; - безопасный DNS cleanup через root-owned wrapper; @@ -22,7 +22,7 @@ CLI-установка корпоративного VPN `vpn.lemanapro.ru` дл curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh | sh ``` -Если установка запущена из терминала, скрипт работает как интерактивный wizard: проверит, что уже стоит, спросит как хранить credentials, предложит нужные модули и проведёт через настройку. +Если установка запущена из терминала, скрипт работает как простой wizard: объяснит, что будет установлено, по Enter оставит безопасный ручной режим и предложит дополнительные удобства только отдельным выбором. После установки открой новый shell или выполни: @@ -39,23 +39,30 @@ vpn curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh | sh ``` -Первый важный вопрос — как хранить VPN credentials: +Первый важный вопрос — как подключаться: ```text -Как хранить VPN credentials? - 1) Bitwarden -> macOS Keychain - 2) macOS Keychain: ввести LDAP password и TOTP seed сейчас - 3) macOS Keychain: настрою вручную позже +Как подключаться по умолчанию? + + 1) Вводить всё вручную (рекомендуется) + Откроется окно входа. Вы сами введёте логин, пароль и одноразовый код. + + 2) Запомнить логин и пароль на этом Mac + При входе логин и пароль подставятся сами. Одноразовый код вы введёте вручную. + + 3) Настроить автоматический вход + Нужно заранее сохранить пароль и постоянный код настройки 2FA. + Выбор [1/2/3, Enter=1]: ``` Что означают варианты: -- `1` — использовать Bitwarden как sync-provider: installer поставит/проверит `bw`, а при запуске `vpn` CLI переложит LDAP password и TOTP seed из Bitwarden в macOS Keychain. -- `2` — бесплатный Keychain-only путь: после установки CLI спросит LDAP password и постоянный TOTP seed или `otpauth://...secret=...`, затем сохранит их в macOS Keychain. -- `3` — поставить CLI/app сейчас, а credentials настроить позже командой `vpn --configure-keychain`. +- `1` — ничего не сохранять. Это самый понятный старт: при подключении открывается окно входа, всё вводится руками. +- `2` — сохранить LDAP логин и пароль в macOS Keychain. Одноразовый код всё равно вводится руками. +- `3` — продвинутый путь: сохранить полный набор данных для подстановки или подключить Bitwarden. Если не уверены, выбирайте `1`. -Дальше installer спросит только про системные модули: Touch ID для Bitwarden, sudoers, aliases, Swift Menu Bar app и автозапуск. +Дальше installer спросит только про системные модули: sudoers, aliases, Swift Menu Bar app и автозапуск. Bitwarden и Touch ID появляются только если вы явно выбрали продвинутый путь. Флаги остаются для CI, повторяемых установок и диагностики. Для обычной установки они не нужны. @@ -69,7 +76,7 @@ curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh | ```sh curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh \ - | sh -s -- --non-interactive --credential-source keychain --configure-keychain + | sh -s -- --non-interactive --auth-profile saved-login --configure-login ``` Принудительно включить интерактивные вопросы: @@ -114,7 +121,7 @@ curl -fsSL https://example.org/dokril/lemana-vpn/raw/branch/main/install.sh \ ```sh vpn --status -Modules: ✅ core=ok, ✅ bitwarden=on, ✅ touchid=on, ✅ dns=on, ✅ app=on, ✅ autostart=on, ✅ patches=active, ✅ keychain=password:yes/totp_seed:yes +Modules: ✅ core=ok, 🔐 auth_profile=manual, 🔐 credential_source=none, ⏭️ bitwarden=off, ⏭️ touchid=off, ✅ dns=on, ✅ app=on, ✅ autostart=on, ✅ patches=active, ✅ keychain=password:no/totp_seed:no VPN disconnected ``` @@ -129,15 +136,17 @@ Emoji в human-выводе помогают быстро отличать но | Поле | Значение | | --- | --- | | `core=ok` | Есть `openconnect`, `openconnect-lite` и config | -| `bitwarden=on` | Модуль включён и `bw` установлен | -| `bitwarden=off` | Модуль отключён через `--without-bitwarden` или `LEMANA_VPN_USE_BITWARDEN=0` | +| `auth_profile=manual/saved-login/saved-totp/auto` | Как CLI будет открывать окно входа и использовать сохранённые данные | +| `credential_source=none/keychain/bitwarden` | Откуда брать сохранённые данные, если выбран режим с подстановкой | +| `bitwarden=on` | Модуль явно включён и `bw` установлен | +| `bitwarden=off` | Модуль отключён по умолчанию или через `--without-bitwarden` | | `bitwarden=missing` | Модуль включён, но `bw` не найден | | `touchid=on/off/missing` | Состояние Touch ID helper | | `dns=on/missing` | Наличие DNS cleanup wrapper | | `app=on/missing` | Установлен ли `~/Applications/LemanaVPN.app` | | `autostart=on/off` | Есть ли LaunchAgent для запуска приложения при логине | | `patches=active/pending` | Применены ли runtime-патчи `openconnect-lite` | -| `keychain=password:yes/totp_seed:yes` | Есть ли LDAP-пароль и TOTP seed в Keychain | +| `keychain=password:yes/totp_seed:yes` | Есть ли LDAP-пароль и постоянный код настройки 2FA в Keychain | JSON-режим тоже отдаёт модульный статус: @@ -163,20 +172,20 @@ Detected state: Menu Bar app: no LaunchAgent: no Keychain password: no - Keychain TOTP seed: no + Keychain 2FA setup: no ``` Если доступен терминал, скрипт ведёт установку вопросами: -- выбрать credential source: Bitwarden sync, Keychain с вводом credentials сейчас, или Keychain с настройкой позже; -- поставить ли Bitwarden CLI, если выбран Bitwarden и `bw` не найден; -- собрать ли Touch ID helper, если выбран Bitwarden и helper не найден; +- оставить ручной вход, сохранить только логин/пароль или настроить подстановку сохранённых данных; +- поставить Bitwarden CLI только если выбран Bitwarden; +- собрать Touch ID helper только если выбран Bitwarden; - собрать ли Swift Menu Bar app, если `~/Applications/LemanaVPN.app` не найден; - включить ли автозапуск Menu Bar app при логине; - настроить ли sudoers для `openconnect` и DNS cleanup; - добавить ли алиасы в `~/.zshrc`. -Флаги имеют приоритет над вопросами и нужны в основном для CI, диагностики или повторяемых unattended installs. Например, `--credential-source keychain --configure-keychain` сразу выберет Keychain-only flow, а `--no-shell` не будет предлагать алиасы. +Флаги имеют приоритет над вопросами и нужны в основном для CI, диагностики или повторяемых unattended installs. Например, `--auth-profile saved-login --configure-login` сразу выберет сохранение логина/пароля, а `--no-shell` не будет предлагать алиасы. В неинтерактивной среде скрипт не задаёт вопросов и использует выбранные флаги/дефолты. Для CI или повторяемой установки лучше явно указывать `--non-interactive`. @@ -211,54 +220,67 @@ LEMANA_VPN_NO_EMOJI=1 sh uninstall.sh - `openconnect-lite` config; - DNS cleanup wrapper. -### Как работают credential sources - -У Lemana VPN есть два разных способа подготовить credentials, но один общий runtime-контракт: перед запуском SSO в macOS Keychain должны лежать LDAP-пароль и постоянный TOTP seed для `openconnect-lite`. +### Как работают режимы входа Keychain entries: - service `openconnect-lite`, account `` — корпоративный LDAP/domain пароль; -- service `openconnect-lite`, account `totp/` — постоянный TOTP seed. +- service `openconnect-lite`, account `totp/` — постоянный код настройки 2FA, нужен только для подстановки одноразового кода и auto mode. -`credential_source=bitwarden` — это sync-режим. CLI открывает Bitwarden vault, читает item `LM LDAP`, берёт из него LDAP password и TOTP seed, нормализует `otpauth://...secret=...` если нужно, затем записывает оба секрета в macOS Keychain. После этого подключение идёт так же, как в остальных режимах: `openconnect-lite` читает данные из Keychain, генерирует текущий одноразовый TOTP-код из seed и заполняет Keycloak форму. +`auth_profile=manual` — режим по умолчанию. CLI не проверяет Keychain и Bitwarden, открывает видимое окно входа и ничего не подставляет. Логин, пароль и одноразовый код вводятся вручную. -`credential_source=keychain` — это бесплатный built-in режим без Bitwarden. Пользователь один раз запускает `vpn --configure-keychain` или установку с `--credential-source keychain --configure-keychain`, вводит LDAP password и постоянный TOTP seed. CLI сохраняет их напрямую в macOS Keychain и при следующих подключениях не спрашивает Bitwarden, master password или текущий OTP-код. +`auth_profile=saved-login` — CLI берёт из Keychain только LDAP пароль и подставляет логин/пароль в видимое окно входа. Одноразовый код вводится руками. Настройка: -Важно: Lemana VPN не хранит и не принимает текущий 6-значный код как постоянную настройку. Такой код живёт около 30 секунд. Для автоматического SSO нужен именно seed: raw Base32 или `otpauth://totp/...?...secret=BASE32`. +```sh +vpn --configure-login +``` -Если запуск идёт из menu-bar app, интерактивного terminal prompt нет. Поэтому при пустом Keychain приложение покажет ошибку, а настройку нужно один раз выполнить в Terminal: +`auth_profile=saved-totp` — CLI берёт LDAP пароль и постоянный код настройки 2FA из Keychain, подставляет поля в видимое окно входа, но submit не нажимает. Настройка: ```sh vpn --configure-keychain ``` +`auth_profile=auto` — старый автоматический путь: скрытый браузер, подстановка всех полей и submit. Запускается только явно: + +```sh +vpn-auto +vpn --auto +``` + +`credential_source=bitwarden` — это sync-provider. CLI открывает Bitwarden vault, читает item `LM LDAP`, берёт из него LDAP password и постоянный код настройки 2FA, нормализует `otpauth://...secret=...` если нужно, затем записывает оба секрета в macOS Keychain. Bitwarden не включён по умолчанию. + +Важно: Lemana VPN не хранит и не принимает текущий 6-значный код как постоянную настройку. Такой код живёт около 30 секунд. Для автоматического SSO нужен именно постоянный secret: raw Base32 или `otpauth://totp/...?...secret=BASE32`. + +Если запуск идёт из menu-bar app, интерактивного terminal prompt нет. Поэтому настройку сохранённого логина или полного набора данных нужно один раз выполнить в Terminal. + ### Bitwarden -Включён по умолчанию. CLI при каждом запуске `vpn` пытается получить LDAP-пароль и TOTP seed из записи Bitwarden `LM LDAP`, затем записывает их в macOS Keychain для `openconnect-lite`. +Отключён по умолчанию. Его стоит включать только если у пользователя уже есть запись Bitwarden с корпоративным LDAP-паролем и постоянным кодом настройки 2FA. -TOTP seed — это постоянный секрет 2FA. Сам одноразовый TOTP-код меняется каждые 30 секунд и генерируется `openconnect-lite` в момент входа. +Постоянный код настройки 2FA — это секрет, который обычно показывается при ручной настройке authenticator. Сам одноразовый код меняется каждые 30 секунд и генерируется `openconnect-lite` в момент входа. -Если vault заблокирован и Touch ID helper не смог его открыть, CLI спросит `Bitwarden master password`. Это пароль от хранилища Bitwarden, а не корпоративный LDAP-пароль. Он нужен только чтобы достать LDAP password/TOTP seed из item `LM LDAP` и переложить их в macOS Keychain. +Если vault заблокирован и Touch ID helper не смог его открыть, CLI спросит `Bitwarden master password`. Это пароль от хранилища Bitwarden, а не корпоративный LDAP-пароль. Он нужен только чтобы достать LDAP password и постоянный код настройки 2FA из item `LM LDAP` и переложить их в macOS Keychain. -Выбрать Keychain вместо Bitwarden можно прямо в installer wizard: пункт `2` вводит LDAP password и TOTP seed сразу после установки, пункт `3` оставляет настройку на потом. +Включить Bitwarden можно через продвинутый путь в installer wizard или флагами: То же самое можно задать флагами для неинтерактивной установки: ```sh -sh install.sh --non-interactive --credential-source keychain --configure-keychain +sh install.sh --non-interactive --with-bitwarden --auth-profile saved-totp ``` -В этом режиме credentials нужно положить в Keychain вручную: +Для Keychain-only режима без Bitwarden используйте: ```sh -vpn-lemanapro.sh --configure-keychain +sh install.sh --non-interactive --auth-profile saved-login --configure-login ``` -Если credentials уже лежат в Keychain, подключение без Bitwarden не будет спрашивать пароль заново. CLI явно напишет, что Bitwarden отключён и используются сохранённые LDAP password/TOTP seed из macOS Keychain. +Если сохранённые данные уже лежат в Keychain, подключение без Bitwarden не будет спрашивать пароль заново. Для режима `saved-login` одноразовый код всё равно вводится вручную. ### Если Bitwarden нет -Bitwarden не обязателен. Без него установка работает как обычный `openconnect-lite` profile с секретами в macOS Keychain. +Bitwarden не обязателен. Без него установка по умолчанию работает в полностью ручном режиме: откроется окно входа, а логин, пароль и одноразовый код вводятся руками. Интерактивная установка: @@ -266,27 +288,34 @@ Bitwarden не обязателен. Без него установка рабо curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh | sh ``` -В первом вопросе выбери пункт `2`: +В первом вопросе можно просто нажать Enter: ```text -2) macOS Keychain: ввести LDAP password и TOTP seed сейчас +1) Вводить всё вручную (рекомендуется) ``` -Это встроенный бесплатный путь: не нужен Bitwarden account, платный Bitwarden TOTP или внешний password manager. Setup prompt спросит корпоративный LDAP-пароль и постоянный TOTP seed. Seed можно вставить как raw Base32 или как `otpauth://totp/...?...secret=BASE32` URI. +Это встроенный бесплатный путь: не нужен Bitwarden account, платный Bitwarden TOTP или внешний password manager. + +Если хочется, чтобы логин и пароль подставлялись сами, выбери пункт `2` или выполни позже: + +```sh +vpn --configure-login +``` Что понадобится: - LDAP username; - LDAP password: корпоративный LDAP/domain пароль, не мастер-пароль Bitwarden; -- TOTP secret из корпоративной 2FA настройки. +- одноразовый код из корпоративной 2FA при каждом подключении. -Важно: вводить нужно не текущие 6 цифр из authenticator-приложения, а постоянный secret. Обычно он есть в QR-коде как `secret=BASE32...` или может быть показан при ручной настройке TOTP. +Постоянный код настройки 2FA нужен только для `vpn --configure-keychain`, `saved-totp` и `vpn-auto`. -Не вставляй текущий 6-значный authenticator code в `vpn --configure-keychain`. Lemana VPN сохраняет в Keychain постоянный TOTP seed, а `openconnect-lite` по нему генерирует свежие одноразовые коды во время каждого SSO login. +Не вставляй текущий 6-значный authenticator code в `vpn --configure-keychain`. Lemana VPN сохраняет в Keychain постоянный код настройки 2FA, а `openconnect-lite` по нему генерирует свежие одноразовые коды во время каждого SSO login. -Если запуск идёт из `LemanaVPN.app`, приложение не может безопасно показать интерактивный terminal prompt для ввода LDAP/TOTP. Если Keychain пустой, приложение покажет ошибку. В этом случае один раз выполни в Terminal: +Если запуск идёт из `LemanaVPN.app`, приложение не может безопасно показать интерактивный terminal prompt для сохранения пароля. Настройку saved-login или saved-totp нужно один раз выполнить в Terminal: ```sh +vpn --configure-login vpn --configure-keychain ``` @@ -295,13 +324,13 @@ vpn --configure-keychain 1. Открой QR-код в приложении/на портале, где настраивалась 2FA. 2. Найди режим ручной настройки, где показывается secret. 3. Если доступен только QR, его нужно расшифровать любым локальным QR-сканером и взять параметр `secret`. -4. Вставь secret в prompt `TOTP secret (BASE32...)`. +4. Вставь secret в prompt `2FA setup secret BASE32`. -Если TOTP secret получить нельзя, автоматический headless-вход невозможен: `openconnect-lite` не сможет сам генерировать свежий TOTP-код на каждом входе. +Если постоянный 2FA secret получить нельзя, автоматический headless-вход невозможен: `openconnect-lite` не сможет сам генерировать свежий одноразовый код на каждом входе. ### Touch ID -Включён по умолчанию. Установщик собирает `keychain-fingerprint` из `https://github.com/dss99911/keychain-fingerprint.git` и кладёт бинарник в `~/bin/keychain-fingerprint`. +Отключён по умолчанию и нужен только для Bitwarden-пути. Если пользователь выбрал Bitwarden, установщик может собрать `keychain-fingerprint` из `https://github.com/dss99911/keychain-fingerprint.git` и положить бинарник в `~/bin/keychain-fingerprint`. Важно: этот helper показывает системный Touch ID prompt перед чтением мастер-пароля Bitwarden, но это не аппаратный Keychain ACL. Это удобный локальный гейт поверх записи Keychain. @@ -321,6 +350,15 @@ sh install.sh --without-touchid Приложение живёт в macOS status bar, запускает `~/bin/vpn-lemanapro.sh --json`, показывает состояние VPN, IP, оставшееся время сессии, health-check тоннеля и строку состояния модулей. +В отключённом состоянии меню показывает текущий режим установки: + +- `Режим: полностью вручную` — доступна только обычная кнопка `Подключить`; логин, пароль и одноразовый код вводятся руками. +- `Режим: логин и пароль сохранены` — доступен вход с подстановкой логина/пароля и ручной ввод одноразового кода. +- `Режим: сохранённые данные без автоотправки` — доступны сохранённые данные, ручной fallback и явный automatic mode. +- `Режим: автоматический вход` — обычная кнопка `Подключить` запускает настроенный auto-flow, а ручные fallback-кнопки остаются доступны. + +Кнопки, для которых текущая установка не готова, не показываются. Например, если не сохранён постоянный код настройки 2FA, в меню не будет автоматического входа. Если рядом с режимом есть подсказка `Настройте в Terminal`, выполните указанную команду в Terminal и нажмите `Обновить статус модулей`. + Строка состояния модулей в меню приложения использует те же маркеры, что CLI: `✅` для готового модуля, `⏭️` для отключённого опционального модуля и `⚠️` для проблемы. Иконка строки тоже меняется: `checkmark.circle` для полностью готового набора и `exclamationmark.triangle` для неполной установки. Если в меню видно `modules unavailable: update CLI`, значит запущенное приложение обращается к старому `~/bin/vpn-lemanapro.sh`, который ещё не умеет отдавать модульный статус. Повтори установку через `curl`; установщик обновит CLI и перезапустит уже запущенное `LemanaVPN.app`. @@ -352,9 +390,10 @@ open ~/Applications/LemanaVPN.app ## Использование ```sh -vpn # подключиться -vpn-auto # автоматический режим: скрытый браузер, auto-fill и submit -vpn-manual # ручной режим: видимый браузер, auto-fill без submit +vpn # подключиться в настроенном режиме; fresh install = полностью вручную +vpn-auto # явный автоматический режим: скрытый браузер, auto-fill и submit +vpn --saved-login # подставить сохранённые логин/пароль, одноразовый код руками +vpn-manual # видимый браузер, сохранённые данные auto-fill без submit vpn-manual-full # полностью ручной режим: видимый браузер без auto-fill и submit vpn --manual # то же самое без alias vpn --manual-full # то же самое без alias @@ -368,18 +407,19 @@ open ~/Applications/LemanaVPN.app # открыть Swift-приложение Режимы подключения: -- `auto` — режим по умолчанию. Браузер скрытый, LDAP-пароль и TOTP берутся из Bitwarden/Keychain, Keycloak форма заполняется и отправляется автоматически. -- `manual` — браузер видимый, LDAP-пароль и TOTP берутся из Bitwarden/Keychain и подставляются в поля, но кнопки входа не нажимаются. Нажимаешь сам после проверки формы. -- `manual-full` — браузер видимый, auto-fill отключён полностью, проверки сохранённых LDAP/TOTP credentials пропускаются: поля Keycloak заполняешь и отправляешь сам. +- `manual` — режим fresh install по умолчанию. Браузер видимый, auto-fill отключён полностью, проверки сохранённых LDAP/TOTP credentials пропускаются: поля Keycloak заполняешь и отправляешь сам. +- `saved-login` — браузер видимый, логин/пароль берутся из Keychain, одноразовый код вводится руками, submit не нажимается. +- `saved-totp` / `--manual` — браузер видимый, LDAP-пароль и код 2FA берутся из Keychain/Bitwarden и подставляются в поля, но кнопка входа не нажимается. +- `auto` / `vpn-auto` — явный автоматический режим. Браузер скрытый, LDAP-пароль и код 2FA берутся из Keychain/Bitwarden, форма заполняется и отправляется автоматически. - `--manual-sso` оставлен как совместимый alias для `--manual`. - `--manual-no-autofill` оставлен как совместимый alias для `--manual-full`. -Первый запуск с Bitwarden: +Первый запуск с Bitwarden, если он выбран явно: 1. CLI проверит `bw`. 2. Если vault locked, попросит мастер-пароль. 3. Если установлен Touch ID helper, предложит сохранить мастер-пароль за Touch ID prompt. -4. Достанет `LM LDAP`, запишет LDAP-пароль и TOTP seed в Keychain. +4. Достанет `LM LDAP`, запишет LDAP-пароль и постоянный код настройки 2FA в Keychain. 5. Запустит `openconnect-lite` и пройдёт Keycloak SSO. ## Настройка @@ -388,17 +428,19 @@ open ~/Applications/LemanaVPN.app # открыть Swift-приложение ```sh LEMANA_VPN_USERNAME="" -LEMANA_VPN_CREDENTIAL_SOURCE="bitwarden" +LEMANA_VPN_AUTH_PROFILE="manual" +LEMANA_VPN_CREDENTIAL_SOURCE="none" LEMANA_VPN_BW_ITEM="LM LDAP" -LEMANA_VPN_USE_BITWARDEN="1" -LEMANA_VPN_USE_TOUCHID="1" +LEMANA_VPN_USE_BITWARDEN="0" +LEMANA_VPN_USE_TOUCHID="0" LEMANA_VPN_DNS_CLEANUP="/usr/local/sbin/lemana-vpn-dns-cleanup" ``` -Для бесплатного Keychain-only источника: +Для режима с сохранённым логином и паролем: ```sh LEMANA_VPN_USERNAME="" +LEMANA_VPN_AUTH_PROFILE="saved-login" LEMANA_VPN_CREDENTIAL_SOURCE="keychain" LEMANA_VPN_USE_BITWARDEN="0" LEMANA_VPN_USE_TOUCHID="0" @@ -421,7 +463,7 @@ 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, из которого коды генерируются автоматически. +Это не 6-значный одноразовый код. В Bitwarden должен лежать постоянный 2FA secret, из которого коды генерируются автоматически. ## Почему DNS wrapper, а не wildcard sudoers diff --git a/app/Sources/LemanaVPN/LemanaVPNApp.swift b/app/Sources/LemanaVPN/LemanaVPNApp.swift index 0e894a6..15ac663 100644 --- a/app/Sources/LemanaVPN/LemanaVPNApp.swift +++ b/app/Sources/LemanaVPN/LemanaVPNApp.swift @@ -74,6 +74,9 @@ struct VPNMenuView: View { Button("Обновить статус модулей") { vpnManager.refreshStatus() } + Button("Изменить логин и пароль") { + vpnManager.configureLogin() + } Divider() Button("Открыть логи") { let logPath = FileManager.default.homeDirectoryForCurrentUser @@ -91,19 +94,18 @@ struct VPNMenuView: View { Group { Label("VPN отключён", systemImage: "circle") .disabled(true) + Label(vpnManager.installModeLabel, systemImage: "slider.horizontal.3") + .disabled(true) + if let setupHint = vpnManager.installSetupHint { + Label(setupHint, systemImage: "info.circle") + .disabled(true) + } Divider() - Button("Подключить автоматически") { - vpnManager.connect(mode: .auto) + ForEach(vpnManager.availableLaunchModes) { mode in + Button(mode.menuTitle) { + vpnManager.connect(mode: mode) + } } - .keyboardShortcut("c") - Button("Подключить вручную") { - vpnManager.connect(mode: .manual) - } - .keyboardShortcut("m") - Button("Подключить полностью вручную") { - vpnManager.connect(mode: .manualFull) - } - .keyboardShortcut("f") } } diff --git a/app/Sources/LemanaVPN/VPNManager.swift b/app/Sources/LemanaVPN/VPNManager.swift index 32f85a9..ed5cd5d 100644 --- a/app/Sources/LemanaVPN/VPNManager.swift +++ b/app/Sources/LemanaVPN/VPNManager.swift @@ -11,6 +11,8 @@ struct VPNEvent: Decodable { var reason: String? var message: String? var source: String? + var profile: String? + var auth_profile: String? var tier: Int? var modules: ModuleStatus? } @@ -34,9 +36,18 @@ struct ModuleStatus: Decodable { struct Credentials: Decodable { var source: String + var auth_profile: String? + var login_ready: Bool? var keychain_ready: Bool } + struct AppMenu: Decodable { + var install_label: String + var configured_mode: String + var available_modes: [String] + var setup_hint: String? + } + struct Keychain: Decodable { var password: Bool var totp_seed: Bool @@ -58,6 +69,7 @@ struct ModuleStatus: Decodable { var core: Core var credentials: Credentials? + var app_menu: AppMenu? var bitwarden: ToggleModule var touchid: ToggleModule var keychain: Keychain @@ -69,7 +81,16 @@ struct ModuleStatus: Decodable { let coreReady = core.openconnect && core.openconnect_lite && core.openconnect_lite_config let bitwardenReady = !bitwarden.enabled || bitwarden.installed let touchReady = !touchid.enabled || touchid.installed - let keychainReady = keychain.password && keychain.totp_seed + let profile = credentials?.auth_profile ?? "manual" + let keychainReady: Bool + switch profile { + case "manual": + keychainReady = true + case "saved-login": + keychainReady = keychain.password + default: + keychainReady = keychain.password && keychain.totp_seed + } let appReady = app?.installed ?? true return !coreReady @@ -87,14 +108,23 @@ struct ModuleStatus: Decodable { var summary: String { let coreState = core.openconnect && core.openconnect_lite && core.openconnect_lite_config ? "✅ core" : "⚠️ core" - let credentialState = credentials.map { "🔐 \($0.source)" } ?? "🔐 legacy" + let credentialState = credentials.map { "🔐 \($0.auth_profile ?? "manual")/\($0.source)" } ?? "🔐 legacy" let bwState = bitwarden.enabled ? (bitwarden.installed ? "✅ bw" : "⚠️ bw") : "⏭️ bw" let touchState = touchid.enabled ? (touchid.installed ? "✅ touch" : "⚠️ touch") : "⏭️ touch" let dnsState = dns_cleanup.installed ? "✅ dns" : "⚠️ dns" let appState = app.map { $0.installed ? "✅ app" : "⚠️ app" } ?? "❔ app" let autostartState = app.map { $0.autostart ? "✅ autostart" : "⏭️ autostart" } ?? "❔ autostart" let patchState = patches.active ? "✅ patches" : "⚠️ patches" - let keychainState = "\(keychain.password && keychain.totp_seed ? "✅" : "⚠️") kc \(keychain.password ? "pass" : "-")/\(keychain.totp_seed ? "totp" : "-")" + let keychainReady: Bool + switch credentials?.auth_profile ?? "manual" { + case "manual": + keychainReady = true + case "saved-login": + keychainReady = keychain.password + default: + keychainReady = keychain.password && keychain.totp_seed + } + let keychainState = "\(keychainReady ? "✅" : "⚠️") kc \(keychain.password ? "pass" : "-")/\(keychain.totp_seed ? "totp" : "-")" return [coreState, credentialState, bwState, touchState, dnsState, appState, autostartState, patchState, keychainState].joined(separator: " | ") } } @@ -119,18 +149,34 @@ enum VPNState: Equatable { } } -enum VPNLaunchMode: String { - case auto - case manual - case manualFull +enum VPNLaunchMode: String, Identifiable { + case configured = "configured" + case auto = "auto" + case savedLogin = "saved-login" + case manual = "saved-totp" + case manualFull = "manual-full" - var cliArgument: String { + var id: String { rawValue } + + var cliArgument: String? { switch self { + case .configured: return nil case .auto: return "--auto" + case .savedLogin: return "--saved-login" case .manual: return "--manual" case .manualFull: return "--manual-full" } } + + var menuTitle: String { + switch self { + case .configured: return "Подключить" + case .auto: return "Подключить автоматически" + case .savedLogin: return "Подставить только логин и пароль" + case .manual: return "Подключить с сохранёнными данными" + case .manualFull: return "Подключить полностью вручную" + } + } } @MainActor @@ -140,6 +186,7 @@ class VPNManager: ObservableObject { @Published var tunnelHealthy: Bool = true @Published var moduleSummary: String = "modules loading..." @Published var moduleStatusSystemImage: String = "hourglass" + @Published var moduleStatus: ModuleStatus? private var process: Process? private var outputPipe: Pipe? @@ -153,7 +200,7 @@ class VPNManager: ObservableObject { private var autoReconnectAttempts: Int = 0 private var reconnectTimer: Timer? private var consecutiveHealthFailures: Int = 0 - private var currentLaunchMode: VPNLaunchMode = .auto + private var currentLaunchMode: VPNLaunchMode = .configured private let healthCheckInterval: TimeInterval = 10 private let maxAutoReconnectAttempts: Int = 3 @@ -191,6 +238,28 @@ class VPNManager: ObservableObject { } } + var installModeLabel: String { + moduleStatus?.app_menu?.install_label ?? "Режим: проверяю установку" + } + + var installSetupHint: String? { + if let hint = moduleStatus?.app_menu?.setup_hint { + return hint + } + if moduleStatus != nil, moduleStatus?.app_menu == nil { + return "Обновите CLI: sh install.sh" + } + return nil + } + + var availableLaunchModes: [VPNLaunchMode] { + guard let menu = moduleStatus?.app_menu else { + return [.configured] + } + let modes = menu.available_modes.compactMap(VPNLaunchMode.init(rawValue:)) + return modes.isEmpty ? [.configured] : modes + } + func refreshStatus() { let proc = Process() proc.executableURL = URL(fileURLWithPath: "/bin/bash") @@ -211,6 +280,7 @@ class VPNManager: ObservableObject { guard !lastLine.isEmpty, let jsonData = lastLine.data(using: .utf8) else { self.moduleSummary = "modules unavailable" self.moduleStatusSystemImage = "questionmark.circle" + self.moduleStatus = nil self.log("[modules] status refresh returned no JSON output") return } @@ -221,6 +291,7 @@ class VPNManager: ObservableObject { } catch { self.moduleSummary = "modules unavailable" self.moduleStatusSystemImage = "exclamationmark.triangle" + self.moduleStatus = nil let compact = text.replacingOccurrences(of: "\n", with: "\\n") let preview = compact.count > 500 ? String(compact.prefix(500)) + "..." : compact self.log("[modules] status decode failed: \(error.localizedDescription); output=\(preview)") @@ -230,12 +301,14 @@ class VPNManager: ObservableObject { guard let modules = response.modules else { self.moduleSummary = "modules unavailable: update CLI" self.moduleStatusSystemImage = "exclamationmark.triangle" + self.moduleStatus = nil self.log("[modules] status has no modules field; reinstall CLI with install.sh") return } self.moduleSummary = modules.summary self.moduleStatusSystemImage = modules.systemImage + self.moduleStatus = modules } } @@ -246,7 +319,7 @@ class VPNManager: ObservableObject { } } - func connect(mode: VPNLaunchMode = .auto) { + func connect(mode: VPNLaunchMode = .configured) { guard !isRunning else { log("connect() called but process already running") return @@ -264,7 +337,11 @@ class VPNManager: ObservableObject { let proc = Process() proc.executableURL = URL(fileURLWithPath: "/bin/bash") - proc.arguments = ["-l", scriptPath, "--json", mode.cliArgument] + var arguments = ["-l", scriptPath, "--json"] + if let cliArgument = mode.cliArgument { + arguments.append(cliArgument) + } + proc.arguments = arguments proc.environment = processEnvironment() let stdoutPipe = Pipe() @@ -337,6 +414,10 @@ class VPNManager: ObservableObject { proc.interrupt() } + func configureLogin() { + openTerminalCommand(name: "configure-login", command: "\(shellQuote(scriptPath)) --configure-login") + } + func quit() { log("-- App quit --") userInitiatedDisconnect = true @@ -357,6 +438,7 @@ class VPNManager: ObservableObject { if let modules = event.modules { moduleSummary = modules.summary moduleStatusSystemImage = modules.systemImage + moduleStatus = modules } log("[event] \(event.event)" + { @@ -367,6 +449,7 @@ class VPNManager: ObservableObject { if let a = event.attempt { extras.append("attempt=\(a)") } if let m = event.message { extras.append("msg=\(m)") } if let r = event.reason { extras.append("reason=\(r)") } + if let p = event.auth_profile ?? event.profile { extras.append("profile=\(p)") } return extras.isEmpty ? "" : " (\(extras.joined(separator: ", ")))" }()) @@ -383,6 +466,26 @@ class VPNManager: ObservableObject { log(" Credential source: \(source)") } return + case "auth_profile": + if let message = event.message { + log(" \(message)") + } else if let profile = event.profile { + log(" Auth profile: \(profile)") + } + return + case "manual_sso", "auto_sso": + let profile = event.auth_profile ?? event.profile ?? "configured" + log(" SSO mode: \(profile)") + return + case "username_required", "username_saved": + if let message = event.message { + log(" \(message)") + } else if event.event == "username_required" { + log(" LDAP username is missing; configure it in Terminal") + } else { + log(" LDAP username saved") + } + return case "bw_cached": state = .unlocking(tier: "cached") case "bw_touchid": @@ -446,6 +549,46 @@ class VPNManager: ObservableObject { } } + private func openTerminalCommand(name: String, command: String) { + let stateDir = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".local/state/vpn-lemanapro") + let commandURL = stateDir.appendingPathComponent("\(name).command") + let script = """ + #!/bin/zsh + \(command) + status=$? + printf '\\n' + if [ "$status" -eq 0 ]; then + printf 'Логин и пароль обновлены.\\n' + else + printf 'Команда завершилась с ошибкой: %s\\n' "$status" + fi + printf 'Нажмите Enter, чтобы закрыть окно...' + read -r _ + exit "$status" + """ + + do { + try FileManager.default.createDirectory(at: stateDir, withIntermediateDirectories: true) + try script.write(to: commandURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: commandURL.path) + + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/usr/bin/open") + proc.arguments = ["-a", "Terminal", commandURL.path] + try proc.run() + log("Opened Terminal credential setup: \(commandURL.path)") + } catch { + log("Credential setup launch failed: \(error.localizedDescription)") + lastError = error.localizedDescription + state = .error(message: error.localizedDescription) + } + } + + private func shellQuote(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + private func handleTermination(exitCode: Int32) { log("-- Process terminated (exit=\(exitCode), userInitiated=\(userInitiatedDisconnect)) --") stopTimer() diff --git a/bin/vpn-lemanapro.sh b/bin/vpn-lemanapro.sh index 86f0852..e14a1c6 100755 --- a/bin/vpn-lemanapro.sh +++ b/bin/vpn-lemanapro.sh @@ -7,6 +7,7 @@ OC_CONFIG_DIR="${OPENCONNECT_LITE_CONFIG_DIR:-$HOME/.config/openconnect-lite}" _ENV_LEMANA_VPN_USERNAME="${LEMANA_VPN_USERNAME+x}${LEMANA_VPN_USERNAME-}" _ENV_LEMANA_VPN_BW_ITEM="${LEMANA_VPN_BW_ITEM+x}${LEMANA_VPN_BW_ITEM-}" +_ENV_LEMANA_VPN_AUTH_PROFILE="${LEMANA_VPN_AUTH_PROFILE+x}${LEMANA_VPN_AUTH_PROFILE-}" _ENV_LEMANA_VPN_USE_BITWARDEN="${LEMANA_VPN_USE_BITWARDEN+x}${LEMANA_VPN_USE_BITWARDEN-}" _ENV_LEMANA_VPN_CREDENTIAL_SOURCE="${LEMANA_VPN_CREDENTIAL_SOURCE+x}${LEMANA_VPN_CREDENTIAL_SOURCE-}" _ENV_LEMANA_VPN_USE_TOUCHID="${LEMANA_VPN_USE_TOUCHID+x}${LEMANA_VPN_USE_TOUCHID-}" @@ -19,6 +20,7 @@ fi [[ "${_ENV_LEMANA_VPN_USERNAME:0:1}" == "x" ]] && LEMANA_VPN_USERNAME="${_ENV_LEMANA_VPN_USERNAME:1}" [[ "${_ENV_LEMANA_VPN_BW_ITEM:0:1}" == "x" ]] && LEMANA_VPN_BW_ITEM="${_ENV_LEMANA_VPN_BW_ITEM:1}" +[[ "${_ENV_LEMANA_VPN_AUTH_PROFILE:0:1}" == "x" ]] && LEMANA_VPN_AUTH_PROFILE="${_ENV_LEMANA_VPN_AUTH_PROFILE:1}" [[ "${_ENV_LEMANA_VPN_USE_BITWARDEN:0:1}" == "x" ]] && LEMANA_VPN_USE_BITWARDEN="${_ENV_LEMANA_VPN_USE_BITWARDEN:1}" [[ "${_ENV_LEMANA_VPN_CREDENTIAL_SOURCE:0:1}" == "x" ]] && LEMANA_VPN_CREDENTIAL_SOURCE="${_ENV_LEMANA_VPN_CREDENTIAL_SOURCE:1}" [[ "${_ENV_LEMANA_VPN_USE_TOUCHID:0:1}" == "x" ]] && LEMANA_VPN_USE_TOUCHID="${_ENV_LEMANA_VPN_USE_TOUCHID:1}" @@ -30,19 +32,28 @@ OC_BIN="${LEMANA_VPN_OC_BIN:-$HOME/.local/bin/openconnect-lite}" BW_ITEM_NAME="${LEMANA_VPN_BW_ITEM:-LM LDAP}" KC_USERNAME="${LEMANA_VPN_USERNAME:-}" KC_FP="${LEMANA_VPN_KEYCHAIN_FINGERPRINT:-$HOME/bin/keychain-fingerprint}" +AUTH_PROFILE="${LEMANA_VPN_AUTH_PROFILE:-manual}" CREDENTIAL_SOURCE="${LEMANA_VPN_CREDENTIAL_SOURCE:-}" if [[ -z "$CREDENTIAL_SOURCE" ]]; then - if [[ "${LEMANA_VPN_USE_BITWARDEN:-1}" == "1" ]]; then + if [[ "${LEMANA_VPN_USE_BITWARDEN:-0}" == "1" ]]; then CREDENTIAL_SOURCE="bitwarden" else - CREDENTIAL_SOURCE="keychain" + CREDENTIAL_SOURCE="none" fi fi -case "$CREDENTIAL_SOURCE" in - bitwarden|keychain) ;; +case "$AUTH_PROFILE" in + manual|saved-login|saved-totp|auto) ;; *) - printf 'Unknown credential source: %s. Use bitwarden or keychain.\n' "$CREDENTIAL_SOURCE" >&2 + printf 'Unknown auth profile: %s. Use manual, saved-login, saved-totp, or auto.\n' "$AUTH_PROFILE" >&2 + exit 2 + ;; +esac + +case "$CREDENTIAL_SOURCE" in + none|bitwarden|keychain) ;; + *) + printf 'Unknown credential source: %s. Use none, bitwarden, or keychain.\n' "$CREDENTIAL_SOURCE" >&2 exit 2 ;; esac @@ -52,8 +63,8 @@ if [[ "$CREDENTIAL_SOURCE" == "bitwarden" ]]; then else USE_BITWARDEN="0" fi -USE_TOUCHID="${LEMANA_VPN_USE_TOUCHID:-1}" -if [[ "$CREDENTIAL_SOURCE" == "keychain" ]]; then +USE_TOUCHID="${LEMANA_VPN_USE_TOUCHID:-0}" +if [[ "$CREDENTIAL_SOURCE" != "bitwarden" ]]; then USE_TOUCHID="0" fi CACHE_BW_SESSION="${LEMANA_VPN_CACHE_BW_SESSION:-0}" @@ -69,38 +80,45 @@ PATCH_BACKUP_DIR="${LEMANA_VPN_PATCH_BACKUP_DIR:-$CONFIG_DIR/patch-backups}" CONNECT_LOG_DIR="${LEMANA_VPN_LOG_DIR:-$HOME/Library/Logs}" CONNECT_LOG_FILE="${LEMANA_VPN_CONNECT_LOG:-$CONNECT_LOG_DIR/LemanaVPN-openconnect-lite.log}" CONNECT_WAIT_SECONDS="${LEMANA_VPN_CONNECT_WAIT_SECONDS:-20}" +OC_RUNTIME_XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" +MANUAL_XDG_CONFIG_HOME="" DEBUG=false JSON_MODE=false STATUS_MODE=false +CONFIGURE_LOGIN_MODE=false CONFIGURE_KEYCHAIN_MODE=false PATCH_ONLY_MODE=false -CONNECT_MODE="${LEMANA_VPN_MODE:-auto}" +CONNECT_MODE="${LEMANA_VPN_MODE:-}" for arg in "$@"; do case "$arg" in --debug) DEBUG=true ;; --json) JSON_MODE=true ;; --status) STATUS_MODE=true ;; + --configure-login) CONFIGURE_LOGIN_MODE=true ;; --configure-keychain) CONFIGURE_KEYCHAIN_MODE=true ;; --patch-only) PATCH_ONLY_MODE=true ;; - --auto|auto) CONNECT_MODE="auto" ;; - --manual|manual|--manual-sso) CONNECT_MODE="manual" ;; - --manual-full|manual-full|--manual-no-autofill) CONNECT_MODE="manual-full" ;; + --auto|auto) AUTH_PROFILE="auto"; CONNECT_MODE="auto" ;; + --manual|manual|--manual-sso) AUTH_PROFILE="saved-totp"; CONNECT_MODE="saved-totp" ;; + --saved-login|saved-login|--login-only) AUTH_PROFILE="saved-login"; CONNECT_MODE="saved-login" ;; + --manual-full|manual-full|--manual-no-autofill) AUTH_PROFILE="manual"; CONNECT_MODE="manual-full" ;; --help|-h) cat <<'HELP' -Usage: vpn-lemanapro.sh [--auto|--manual|--manual-full] [--debug] [--json] [--status] [--configure-keychain] [--patch-only] +Usage: vpn-lemanapro.sh [--auto|--manual|--saved-login|--manual-full] [--debug] [--json] [--status] [--configure-login] [--configure-keychain] [--patch-only] --status Show current VPN status without connecting --status --json Show current VPN status as JSON - --auto Hidden browser, auto-fill and auto-submit (default) - --manual Visible browser, auto-fill fields, do not press submit - --manual-full Visible browser, no auto-fill, do not press submit + --auto Hidden browser, auto-fill and auto-submit + --manual Visible browser, saved fields auto-filled, do not press submit + --saved-login Visible browser, saved username/password only, OTP manual + --manual-full Visible browser, no auto-fill, do not press submit (default on fresh install) --manual-no-autofill Compatibility alias for --manual-full --manual-sso Compatibility alias for --manual --debug Passthrough debug logs; also shows browser in auto mode --json Emit JSON Lines events for UI wrappers - --configure-keychain Configure the keychain credential source: LDAP password plus permanent TOTP seed or otpauth:// URI + --configure-login Save LDAP username/password in macOS Keychain; OTP remains manual + --configure-keychain Save LDAP password plus permanent 2FA setup code or otpauth:// URI --patch-only Apply openconnect-lite runtime patches and exit HELP exit 0 @@ -109,9 +127,23 @@ HELP done case "$CONNECT_MODE" in - auto|manual|manual-full) ;; + "") + ;; + manual-full) + AUTH_PROFILE="manual" + ;; + manual) + AUTH_PROFILE="saved-totp" + ;; + saved-login|saved-totp|auto) + AUTH_PROFILE="$CONNECT_MODE" + ;; +esac + +case "$AUTH_PROFILE" in + manual|saved-login|saved-totp|auto) ;; *) - printf 'Unknown VPN mode: %s. Use --auto, --manual, or --manual-full.\n' "$CONNECT_MODE" >&2 + printf 'Unknown auth profile: %s. Use manual, saved-login, saved-totp, or auto.\n' "$AUTH_PROFILE" >&2 exit 2 ;; esac @@ -134,11 +166,56 @@ _clear_status() { _write_status "{\"pid\":$$,\"state\":\"disconnected\",\"updated_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" } +_cleanup_manual_openconnect_config() { + if [[ -n "${MANUAL_XDG_CONFIG_HOME:-}" && -d "$MANUAL_XDG_CONFIG_HOME" ]]; then + rm -rf "$MANUAL_XDG_CONFIG_HOME" + MANUAL_XDG_CONFIG_HOME="" + fi +} + +_prepare_manual_openconnect_config() { + local source_config temp_root temp_config_dir + source_config="$OC_CONFIG_DIR/config.toml" + temp_root="$(mktemp -d "${TMPDIR:-/tmp}/lemana-vpn-manual-xdg.XXXXXX")" + temp_config_dir="$temp_root/openconnect-lite" + mkdir -p "$temp_config_dir" + + if [[ -f "$source_config" ]]; then + CONFIG_SRC="$source_config" CONFIG_DST="$temp_config_dir/config.toml" python3 - <<'PY' +import os +from pathlib import Path + +src = Path(os.environ["CONFIG_SRC"]) +dst = Path(os.environ["CONFIG_DST"]) + +lines = src.read_text().splitlines() +rendered = [] +skip_credentials = False + +for line in lines: + stripped = line.strip() + if stripped == "[credentials]" or stripped.startswith("[credentials."): + skip_credentials = True + continue + if skip_credentials and stripped.startswith("[") and stripped.endswith("]"): + skip_credentials = False + if not skip_credentials: + rendered.append(line) + +dst.write_text("\n".join(rendered).rstrip() + "\n") +dst.chmod(0o600) +PY + fi + + MANUAL_XDG_CONFIG_HOME="$temp_root" + OC_RUNTIME_XDG_CONFIG_HOME="$temp_root" +} + _prepare_connection_log() { mkdir -p "$CONNECT_LOG_DIR" { printf '\n==== Lemana VPN openconnect-lite session %s ====\n' "$(date '+%Y-%m-%d %H:%M:%S')" - printf 'Command: %s --browser-display-mode hidden\n' "$OC_BIN" + printf 'Command: %s\n' "$OC_BIN" } >> "$CONNECT_LOG_FILE" chmod 600 "$CONNECT_LOG_FILE" 2>/dev/null || true } @@ -222,11 +299,65 @@ _keychain_has() { security find-generic-password -s "$1" -a "$2" >/dev/null 2>&1 } +_app_menu_json() { + local login_ready="$1" keychain_ready="$2" + local install_label configured_mode available_modes setup_hint_json + + configured_mode="$AUTH_PROFILE" + setup_hint_json="null" + + case "$AUTH_PROFILE" in + manual) + install_label="Режим: полностью вручную" + available_modes='"configured"' + ;; + saved-login) + if [[ "$login_ready" == "true" ]]; then + install_label="Режим: логин и пароль сохранены" + available_modes='"configured","manual-full"' + else + install_label="Режим: логин и пароль не настроены" + available_modes='"manual-full"' + setup_hint_json='"Настройте в Terminal: vpn --configure-login"' + fi + ;; + saved-totp) + if [[ "$keychain_ready" == "true" ]]; then + install_label="Режим: сохранённые данные без автоотправки" + available_modes='"configured","saved-login","manual-full","auto"' + else + install_label="Режим: сохранённые данные не настроены" + available_modes='"manual-full"' + setup_hint_json='"Настройте в Terminal: vpn --configure-keychain"' + fi + ;; + auto) + if [[ "$keychain_ready" == "true" ]]; then + install_label="Режим: автоматический вход" + available_modes='"configured","saved-totp","saved-login","manual-full"' + else + install_label="Режим: автоматический вход не настроен" + available_modes='"manual-full"' + setup_hint_json='"Настройте в Terminal: vpn --configure-keychain"' + fi + ;; + *) + install_label="Режим: неизвестен" + configured_mode="manual" + available_modes='"configured"' + setup_hint_json='"Обновите Lemana VPN через install.sh"' + ;; + esac + + printf '{"install_label":"%s","configured_mode":"%s","available_modes":[%s],"setup_hint":%s}' \ + "$install_label" "$configured_mode" "$available_modes" "$setup_hint_json" +} + _module_status_json() { local openconnect_installed openconnect_lite_installed bitwarden_installed touchid_installed dns_cleanup_installed local app_installed app_autostart local config_present oc_config_present patch_backup_present patches_active keychain_password keychain_totp_seed - local credential_keychain_ready + local credential_keychain_ready credential_login_ready app_menu_json openconnect_installed="$(_module_bool command -v openconnect)" openconnect_lite_installed="$(_module_bool test -x "$OC_BIN")" bitwarden_installed="$(_module_bool command -v bw)" @@ -241,14 +372,19 @@ _module_status_json() { keychain_password="$(_module_bool _keychain_has openconnect-lite "$KC_USERNAME")" keychain_totp_seed="$(_module_bool _keychain_has openconnect-lite "totp/$KC_USERNAME")" credential_keychain_ready="$([[ "$keychain_password" == "true" && "$keychain_totp_seed" == "true" ]] && printf true || printf false)" + credential_login_ready="$([[ "$keychain_password" == "true" ]] && printf true || printf false)" + app_menu_json="$(_app_menu_json "$credential_login_ready" "$credential_keychain_ready")" - printf '{"core":{"openconnect":%s,"openconnect_lite":%s,"config":%s,"openconnect_lite_config":%s},"credentials":{"source":"%s","keychain_ready":%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},"app":{"installed":%s,"autostart":%s}}' \ + printf '{"core":{"openconnect":%s,"openconnect_lite":%s,"config":%s,"openconnect_lite_config":%s},"credentials":{"source":"%s","auth_profile":"%s","login_ready":%s,"keychain_ready":%s},"app_menu":%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},"app":{"installed":%s,"autostart":%s}}' \ "$openconnect_installed" \ "$openconnect_lite_installed" \ "$config_present" \ "$oc_config_present" \ "$CREDENTIAL_SOURCE" \ + "$AUTH_PROFILE" \ + "$credential_login_ready" \ "$credential_keychain_ready" \ + "$app_menu_json" \ "$([[ "$USE_BITWARDEN" == "1" ]] && printf true || printf false)" \ "$bitwarden_installed" \ "$BW_ITEM_NAME" \ @@ -292,7 +428,7 @@ _module_status_human() { app_installed="$(_module_bool test -x "$APP_DIR/Contents/MacOS/LemanaVPN")" app_autostart="$(_module_bool test -f "$LAUNCH_AGENT")" - printf 'Modules: %s %s, 🔐 credential_source=%s, ' "$([[ "$core" == "core=ok" ]] && printf '✅' || printf '⚠️')" "$core" "$CREDENTIAL_SOURCE" + printf 'Modules: %s %s, 🔐 auth_profile=%s, 🔐 credential_source=%s, ' "$([[ "$core" == "core=ok" ]] && printf '✅' || printf '⚠️')" "$core" "$AUTH_PROFILE" "$CREDENTIAL_SOURCE" _module_human_part "bitwarden" "$USE_BITWARDEN" "$bitwarden_installed" printf ', ' _module_human_part "touchid" "$USE_TOUCHID" "$touchid_installed" @@ -305,7 +441,7 @@ _module_status_human() { "$([[ "$app_autostart" == "true" ]] && printf on || printf off)" \ "$([[ "$patches_active" == "true" ]] && printf '✅' || printf '⚠️')" \ "$([[ "$patches_active" == "true" ]] && printf active || printf pending)" \ - "$([[ "$keychain_password" == "true" && "$keychain_totp_seed" == "true" ]] && printf '✅' || printf '⚠️')" \ + "$([[ "$AUTH_PROFILE" == "manual" || "$keychain_password" == "true" && "$AUTH_PROFILE" == "saved-login" || "$keychain_password" == "true" && "$keychain_totp_seed" == "true" ]] && printf '✅' || printf '⚠️')" \ "$([[ "$keychain_password" == "true" ]] && printf yes || printf no)" \ "$([[ "$keychain_totp_seed" == "true" ]] && printf yes || printf no)" } @@ -526,6 +662,8 @@ canonical_selectors = '''def get_selectors(rules, credentials): f"""var elem = document.querySelector({selector}); if (elem) {{ return; }}""" ) elif rule.fill: + if rule.fill == "totp" and os.environ.get("LEMANA_VPN_AUTOFILL_TOTP", "1") == "0": + continue value = json.dumps(getattr(credentials, rule.fill, None)) if value: statements.append( @@ -641,7 +779,7 @@ if not value: sys.exit(0) if not re.fullmatch(r"[A-Z2-7]+=*", value): - print("Invalid TOTP seed. Use a BASE32 secret or an otpauth:// URI with secret=BASE32.", file=sys.stderr) + print("Invalid 2FA setup secret. Use a BASE32 secret or an otpauth:// URI with secret=BASE32.", file=sys.stderr) sys.exit(1) print(value) @@ -753,7 +891,7 @@ _configure_keychain() { printf 'Manual LDAP credentials setup for Lemana VPN\n' printf 'User: %s\n' "$KC_USERNAME" printf 'LDAP password: your corporate LDAP/domain password, not the Bitwarden master password.\n' - printf 'TOTP seed: permanent BASE32 secret from 2FA setup, not the current 6-digit code.\n' + printf '2FA setup secret: permanent BASE32 secret from authenticator setup, not the current 6-digit code.\n' printf 'Saved values go to macOS Keychain service openconnect-lite.\n\n' if $password_present; then @@ -762,9 +900,9 @@ _configure_keychain() { _prompt_read password "Corporate LDAP password for $KC_USERNAME: " 1 fi if $totp_present; then - _prompt_read totp_secret "TOTP seed BASE32 [leave empty to keep saved seed]: " 1 + _prompt_read totp_secret "2FA setup secret BASE32 [leave empty to keep saved secret]: " 1 else - _prompt_read totp_secret "TOTP seed BASE32 from 2FA setup: " 1 + _prompt_read totp_secret "2FA setup secret BASE32: " 1 fi if [[ -z "$password" && "$password_present" != "true" ]]; then @@ -772,7 +910,7 @@ _configure_keychain() { return 1 fi if [[ -z "$totp_secret" && "$totp_present" != "true" ]]; then - printf 'TOTP seed is required because no saved seed was found. Use the BASE32 setup secret, not the current 6-digit code.\n' >&2 + printf '2FA setup secret is required because no saved secret was found. Use the BASE32 setup secret, not the current 6-digit code.\n' >&2 return 1 fi @@ -784,6 +922,53 @@ _configure_keychain() { printf 'Credentials are ready in macOS Keychain for openconnect-lite/%s.\n' "$KC_USERNAME" } +_configure_login() { + local password password_present=false + _keychain_has openconnect-lite "$KC_USERNAME" && password_present=true + + printf 'Manual LDAP login setup for Lemana VPN\n' + printf 'User: %s\n' "$KC_USERNAME" + printf 'LDAP password: your corporate LDAP/domain password, not the Bitwarden master password.\n' + printf 'Saved values go to macOS Keychain service openconnect-lite.\n' + printf 'One-time 2FA codes will still be entered manually during VPN login.\n\n' + + if $password_present; then + _prompt_read password "Corporate LDAP password for $KC_USERNAME [leave empty to keep saved password]: " 1 + else + _prompt_read password "Corporate LDAP password for $KC_USERNAME: " 1 + fi + + if [[ -z "$password" && "$password_present" != "true" ]]; then + printf 'LDAP password is required because no saved password was found.\n' >&2 + return 1 + fi + + _store_keychain "$password" "" + printf 'Login and password are ready in macOS Keychain for openconnect-lite/%s.\n' "$KC_USERNAME" +} + +_ensure_keychain_login() { + local password_present=false + _keychain_has openconnect-lite "$KC_USERNAME" && password_present=true + + if [[ "$password_present" == "true" ]]; then + _emit '{"event":"keychain_ready","source":"keychain","scope":"login"}' \ + "Keychain login is ready: saved LDAP password is available for $KC_USERNAME." + return 0 + fi + + _emit '{"event":"keychain_required","source":"keychain","scope":"login"}' \ + "Saved LDAP password is missing." + + if ! _can_prompt; then + _emit '{"event":"error","message":"Saved LDAP password is missing. Run vpn --configure-login in Terminal, or connect fully manually."}' \ + "Saved LDAP password is missing. Run: vpn --configure-login" + return 1 + fi + + _configure_login +} + _ensure_keychain_credentials() { local password_present=false totp_present=false _keychain_has openconnect-lite "$KC_USERNAME" && password_present=true @@ -793,7 +978,7 @@ _ensure_keychain_credentials() { if [[ "$CREDENTIAL_SOURCE" == "bitwarden" ]]; then _emit '{"event":"keychain_ready","source":"bitwarden"}' "Bitwarden source synced LDAP credentials into macOS Keychain for $KC_USERNAME." else - _emit '{"event":"keychain_ready","source":"keychain"}' "Keychain source is ready: saved LDAP password and TOTP seed are available for $KC_USERNAME." + _emit '{"event":"keychain_ready","source":"keychain"}' "Keychain source is ready: saved LDAP password and 2FA setup secret are available for $KC_USERNAME." fi return 0 fi @@ -805,8 +990,8 @@ _ensure_keychain_credentials() { fi if ! _can_prompt; then - _emit '{"event":"error","message":"LDAP credentials are missing. Run vpn --configure-keychain in Terminal, or reinstall with --configure-keychain."}' \ - "LDAP credentials are missing. Run: vpn --configure-keychain" + _emit '{"event":"error","message":"Automatic sign-in is not configured. Run vpn --configure-keychain in Terminal, or use vpn --manual-full."}' \ + "Automatic sign-in is not configured. Run: vpn --configure-keychain" return 1 fi @@ -926,6 +1111,9 @@ except Exception: _sync_credentials() { case "$CREDENTIAL_SOURCE" in + none) + _emit '{"event":"credential_source","source":"none"}' "Credential source: none" + ;; bitwarden) _sync_bitwarden ;; @@ -1010,6 +1198,12 @@ if $STATUS_MODE; then exit 0 fi +if $CONFIGURE_LOGIN_MODE; then + _ensure_username + _configure_login + exit 0 +fi + if $CONFIGURE_KEYCHAIN_MODE; then _ensure_username _configure_keychain @@ -1027,38 +1221,64 @@ else printf '{"event":"modules","modules":%s}\n' "$(_module_status_json)" fi -if [[ "$CONNECT_MODE" == "manual-full" ]]; then - _emit '{"event":"credential_source","source":"manual-full","message":"Manual-full mode skips saved credential checks"}' \ - "Manual-full mode: skipping saved credential checks." -else - _ensure_username - _sync_credentials - _ensure_keychain_credentials -fi +case "$AUTH_PROFILE" in + manual) + _emit '{"event":"auth_profile","profile":"manual","message":"Manual mode skips saved credential checks"}' \ + "Manual mode: open the visible browser and enter login, password, and one-time code manually." + ;; + saved-login) + _ensure_username + _sync_credentials + _ensure_keychain_login + ;; + saved-totp|auto) + _ensure_username + _sync_credentials + _ensure_keychain_credentials + ;; +esac _patch_oc _emit '{"event":"connecting"}' "Connecting to VPN (lemanapro)..." _write_status "{\"pid\":$$,\"state\":\"connecting\",\"updated_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" _prepare_connection_log _emit '{"event":"log","message":"openconnect-lite log is enabled"}' "openconnect-lite log: $CONNECT_LOG_FILE" -trap '_stop_connect_progress; _dns_cleanup; _clear_status' EXIT +trap '_stop_connect_progress; _dns_cleanup; _cleanup_manual_openconnect_config; _clear_status' EXIT -display_mode="hidden" +display_mode="shown" log_level="" autofill_debug="${LEMANA_VPN_AUTOFILL_DEBUG:-0}" -autofill_disable="${LEMANA_VPN_AUTOFILL_DISABLE:-0}" -autofill_click="${LEMANA_VPN_AUTOFILL_CLICK:-1}" -if [[ "$CONNECT_MODE" == "manual" ]]; then - display_mode="shown" - autofill_disable="0" - autofill_click="0" - _emit '{"event":"manual_sso","autofill":true,"submit":false}' "Manual mode: browser is visible, fields are auto-filled, submit is not pressed." -elif [[ "$CONNECT_MODE" == "manual-full" ]]; then - display_mode="shown" - autofill_disable="1" - autofill_click="0" - _emit '{"event":"manual_sso","autofill":false,"submit":false}' "Full manual mode: browser is visible, auto-fill is disabled, submit is not pressed." -fi +autofill_disable="${LEMANA_VPN_AUTOFILL_DISABLE:-1}" +autofill_click="${LEMANA_VPN_AUTOFILL_CLICK:-0}" +autofill_totp="${LEMANA_VPN_AUTOFILL_TOTP:-0}" +case "$AUTH_PROFILE" in + manual) + autofill_disable="1" + autofill_click="0" + autofill_totp="0" + _prepare_manual_openconnect_config + _emit '{"event":"manual_sso","auth_profile":"manual","autofill":false,"submit":false}' "Manual mode: browser is visible, auto-fill is disabled, submit is not pressed." + ;; + saved-login) + autofill_disable="0" + autofill_click="0" + autofill_totp="0" + _emit '{"event":"manual_sso","auth_profile":"saved-login","autofill":true,"totp":false,"submit":false}' "Saved-login mode: browser is visible, login/password are filled, one-time code is manual." + ;; + saved-totp) + autofill_disable="0" + autofill_click="0" + autofill_totp="1" + _emit '{"event":"manual_sso","auth_profile":"saved-totp","autofill":true,"totp":true,"submit":false}' "Saved-TOTP mode: browser is visible, saved fields are filled, submit is not pressed." + ;; + auto) + display_mode="hidden" + autofill_disable="0" + autofill_click="1" + autofill_totp="1" + _emit '{"event":"auto_sso","auth_profile":"auto","autofill":true,"submit":true}' "Auto mode: hidden browser, auto-fill and auto-submit." + ;; +esac if $DEBUG; then display_mode="shown" log_level="--log-level debug" @@ -1069,9 +1289,11 @@ reconnect_count=0 while true; do _start_connect_progress QTWEBENGINE_CHROMIUM_FLAGS="--disable-gpu" \ + XDG_CONFIG_HOME="$OC_RUNTIME_XDG_CONFIG_HOME" \ LEMANA_VPN_AUTOFILL_DEBUG="$autofill_debug" \ LEMANA_VPN_AUTOFILL_DISABLE="$autofill_disable" \ LEMANA_VPN_AUTOFILL_CLICK="$autofill_click" \ + LEMANA_VPN_AUTOFILL_TOTP="$autofill_totp" \ "$OC_BIN" --browser-display-mode "$display_mode" $log_level 2>&1 \ | _filter_output exit_code=${PIPESTATUS[0]} diff --git a/install.sh b/install.sh index ed0390c..abb80b6 100755 --- a/install.sh +++ b/install.sh @@ -11,25 +11,29 @@ OC_CONFIG_DIR="${OPENCONNECT_LITE_CONFIG_DIR:-$HOME/.config/openconnect-lite}" DNS_CLEANUP="/usr/local/sbin/lemana-vpn-dns-cleanup" USERNAME="${LEMANA_VPN_USERNAME:-}" BW_ITEM="${LEMANA_VPN_BW_ITEM:-LM LDAP}" -CREDENTIAL_SOURCE="${LEMANA_VPN_CREDENTIAL_SOURCE:-bitwarden}" -USE_BITWARDEN=1 -USE_TOUCHID=1 +CREDENTIAL_SOURCE="${LEMANA_VPN_CREDENTIAL_SOURCE:-none}" +AUTH_PROFILE="${LEMANA_VPN_AUTH_PROFILE:-manual}" +USE_BITWARDEN=0 +USE_TOUCHID=0 INSTALL_SUDOERS=1 INSTALL_ALIASES=1 INSTALL_APP=1 INSTALL_AUTOSTART=1 +CONFIGURE_LOGIN=0 CONFIGURE_KEYCHAIN=0 DRY_RUN=0 FORCE=0 INTERACTIVE=auto BITWARDEN_FORCED=0 CREDENTIAL_SOURCE_FORCED=0 +AUTH_PROFILE_FORCED=0 TOUCHID_FORCED=0 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" @@ -75,12 +79,14 @@ 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) + --with-bitwarden Install/use Bitwarden CLI module as an optional credential source + --without-bitwarden Do not install/use Bitwarden CLI (default) + --credential-source VALUE Credential source: none, keychain, or bitwarden + --auth-profile VALUE Auth profile: manual, saved-login, saved-totp, or auto + --with-touchid Install/use keychain-fingerprint Touch ID helper with Bitwarden --without-touchid Do not install/use Touch ID helper - --configure-keychain Prompt for LDAP password and TOTP secret after install + --configure-login Prompt for LDAP username/password after install + --configure-keychain Prompt for LDAP password and permanent 2FA setup code after install --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 @@ -92,15 +98,15 @@ Options: --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 + --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 --minimal --configure-keychain - sh install.sh --credential-source keychain --configure-keychain + sh install.sh --configure-login + sh install.sh --credential-source keychain --auth-profile saved-totp --configure-keychain sh install.sh --without-touchid USAGE } @@ -112,29 +118,53 @@ while [ "$#" -gt 0 ]; do USE_BITWARDEN=1 BITWARDEN_FORCED=1 CREDENTIAL_SOURCE_FORCED=1 + if [ "$AUTH_PROFILE_FORCED" -eq 0 ] && [ "$AUTH_PROFILE" = "manual" ]; then + AUTH_PROFILE="saved-totp" + fi ;; --without-bitwarden) - CREDENTIAL_SOURCE="keychain" + if [ "$CREDENTIAL_SOURCE" = "bitwarden" ]; then + CREDENTIAL_SOURCE="none" + fi 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; } + [ "$#" -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) + USE_BITWARDEN=0 + USE_TOUCHID=0 + ;; bitwarden) USE_BITWARDEN=1 + if [ "$AUTH_PROFILE_FORCED" -eq 0 ] && [ "$AUTH_PROFILE" = "manual" ]; then + AUTH_PROFILE="saved-totp" + fi ;; keychain) USE_BITWARDEN=0 USE_TOUCHID=0 ;; *) - echo "--credential-source requires bitwarden or keychain" >&2 + 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|saved-totp|auto) ;; + *) + echo "--auth-profile requires manual, saved-login, saved-totp, or auto" >&2 exit 1 ;; esac @@ -147,9 +177,31 @@ while [ "$#" -gt 0 ]; do USE_TOUCHID=0 TOUCHID_FORCED=1 ;; + --configure-login) + CONFIGURE_LOGIN=1 + CONFIGURE_LOGIN_FORCED=1 + CONFIGURE_KEYCHAIN=0 + if [ "$AUTH_PROFILE_FORCED" -eq 0 ]; then + AUTH_PROFILE="saved-login" + fi + if [ "$CREDENTIAL_SOURCE_FORCED" -eq 0 ]; then + CREDENTIAL_SOURCE="keychain" + USE_BITWARDEN=0 + USE_TOUCHID=0 + fi + ;; --configure-keychain) CONFIGURE_KEYCHAIN=1 + CONFIGURE_LOGIN=0 CONFIGURE_KEYCHAIN_FORCED=1 + if [ "$AUTH_PROFILE_FORCED" -eq 0 ]; then + AUTH_PROFILE="saved-totp" + fi + if [ "$CREDENTIAL_SOURCE_FORCED" -eq 0 ]; then + CREDENTIAL_SOURCE="keychain" + USE_BITWARDEN=0 + USE_TOUCHID=0 + fi ;; --username) shift @@ -195,11 +247,13 @@ while [ "$#" -gt 0 ]; do --interactive) INTERACTIVE=1 ;; --non-interactive) INTERACTIVE=0 ;; --minimal) - CREDENTIAL_SOURCE="keychain" + CREDENTIAL_SOURCE="none" + AUTH_PROFILE="manual" USE_BITWARDEN=0 USE_TOUCHID=0 BITWARDEN_FORCED=1 CREDENTIAL_SOURCE_FORCED=1 + AUTH_PROFILE_FORCED=1 TOUCHID_FORCED=1 ;; --dry-run) DRY_RUN=1 ;; @@ -329,54 +383,121 @@ yes_no() { done } -choose_credential_source_interactive() { - log_step "Настраиваю способ хранения credentials" - log_detail "Можно выбрать Bitwarden или бесплатный macOS Keychain flow с ручным вводом LDAP password и TOTP seed." +print_install_intro() { + log_step "Lemana VPN установит приложение для подключения к корпоративному VPN" + log_detail "По умолчанию ничего не сохраняется: при подключении откроется окно входа, а логин, пароль и одноразовый код вводятся вручную." + log_detail "Дополнительные удобства можно включить сейчас или позже." +} +choose_advanced_signin_interactive() { 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Автоматический вход требует сохранённых данных.%s\n' "$C_BOLD" "$C_RESET" + prompt_printf ' Автоматический вход работает только если на Mac сохранены корпоративный пароль\n' + prompt_printf ' и постоянный код настройки 2FA. Это не текущие 6 цифр из приложения.\n\n' + prompt_printf ' 1) Вернуться к ручному режиму\n' + prompt_printf ' 2) Сохранить пароль и постоянный код 2FA в macOS Keychain\n' + prompt_printf ' 3) Взять пароль и код 2FA из Bitwarden\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 + AUTH_PROFILE=manual + CREDENTIAL_SOURCE=none + USE_BITWARDEN=0 + USE_TOUCHID=0 + CONFIGURE_LOGIN=0 + CONFIGURE_KEYCHAIN=0 ;; 2) + AUTH_PROFILE=saved-totp CREDENTIAL_SOURCE=keychain USE_BITWARDEN=0 USE_TOUCHID=0 + CONFIGURE_LOGIN=0 CONFIGURE_KEYCHAIN=1 ;; 3) - CREDENTIAL_SOURCE=keychain - USE_BITWARDEN=0 - USE_TOUCHID=0 + AUTH_PROFILE=saved-totp + CREDENTIAL_SOURCE=bitwarden + USE_BITWARDEN=1 + CONFIGURE_LOGIN=0 CONFIGURE_KEYCHAIN=0 ;; *) - prompt_printf 'Введите 1, 2 или 3.\n' + prompt_printf 'Введите 1, 2 или 3. Если не уверены, нажмите Enter.\n' + continue + ;; + esac + break + done +} + +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) Вводить всё вручную (рекомендуется)\n' + prompt_printf ' Откроется окно входа. Вы сами введёте логин, пароль и одноразовый код.\n\n' + prompt_printf ' 2) Запомнить логин и пароль на этом Mac\n' + prompt_printf ' При входе логин и пароль подставятся сами. Одноразовый код вы введёте вручную.\n\n' + prompt_printf ' 3) Настроить автоматический вход\n' + prompt_printf ' Нужно заранее сохранить пароль и постоянный код настройки 2FA.\n' + prompt_printf '%sВыбор [1/2/3, Enter=1]:%s ' "$C_BOLD" "$C_RESET" + + prompt_read_answer + case "$answer" in + ""|1) + AUTH_PROFILE=manual + CREDENTIAL_SOURCE=none + USE_BITWARDEN=0 + USE_TOUCHID=0 + CONFIGURE_LOGIN=0 + CONFIGURE_KEYCHAIN=0 + if yes_no "Сохранить логин и пароль в macOS Keychain для подстановки при входе?" n; then + AUTH_PROFILE=saved-login + CREDENTIAL_SOURCE=keychain + USE_BITWARDEN=0 + USE_TOUCHID=0 + CONFIGURE_LOGIN=1 + CONFIGURE_KEYCHAIN=0 + fi + ;; + 2) + AUTH_PROFILE=saved-login + CREDENTIAL_SOURCE=keychain + USE_BITWARDEN=0 + USE_TOUCHID=0 + CONFIGURE_LOGIN=1 + CONFIGURE_KEYCHAIN=0 + ;; + 3) + choose_advanced_signin_interactive + ;; + *) + prompt_printf 'Введите 1, 2 или 3. Если не уверены, нажмите Enter.\n' continue ;; esac break done - case "$CREDENTIAL_SOURCE" in - bitwarden) - log_info "Credential source: Bitwarden sync into macOS Keychain" + case "$AUTH_PROFILE" in + manual) + log_info "Режим подключения: ручной ввод логина, пароля и одноразового кода" ;; - 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 + saved-login) + log_info "Режим подключения: логин и пароль будут сохранены в macOS Keychain, одноразовый код вводится вручную" + ;; + saved-totp) + log_info "Режим подключения: сохранённые данные будут подставляться в форму входа" + ;; + auto) + log_info "Режим подключения: автоматический вход включается только через явный auto-запуск" ;; esac } @@ -413,13 +534,31 @@ print_detected_state() { 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")" + 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 ;; @@ -438,18 +577,19 @@ choose_modules() { fi log_info "Interactive prompts: on" - log_detail "Установщик проведёт по выбору credential source и отсутствующих опциональных модулей; флаги командной строки имеют приоритет." + log_detail "Установщик проведёт по понятному выбору режима входа; флаги командной строки имеют приоритет." - if [ "$CREDENTIAL_SOURCE_FORCED" -eq 0 ]; then - choose_credential_source_interactive + if [ "$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 CLI не найден. Поставить модуль Bitwarden?" y; then + if yes_no "Bitwarden не найден на этом Mac. Установить его сейчас?" n; then CREDENTIAL_SOURCE=bitwarden USE_BITWARDEN=1 else - CREDENTIAL_SOURCE=keychain + AUTH_PROFILE=manual + CREDENTIAL_SOURCE=none USE_BITWARDEN=0 USE_TOUCHID=0 fi @@ -458,7 +598,7 @@ choose_modules() { 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 + if yes_no "Разрешить Touch ID для открытия Bitwarden?" n; then USE_TOUCHID=1 else USE_TOUCHID=0 @@ -504,9 +644,17 @@ choose_modules() { fi fi - if [ "$CONFIGURE_KEYCHAIN_FORCED" -eq 0 ] && [ "$CREDENTIAL_SOURCE" = "keychain" ] && [ "$CONFIGURE_KEYCHAIN" -eq 0 ]; then + if [ "$CONFIGURE_LOGIN_FORCED" -eq 0 ] && [ "$AUTH_PROFILE" = "saved-login" ] && [ "$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 ] && [ "$AUTH_PROFILE" = "saved-totp" ] && [ "$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 + if yes_no "Сохранить пароль и постоянный код настройки 2FA в macOS Keychain после установки?" y; then CONFIGURE_KEYCHAIN=1 fi fi @@ -516,7 +664,13 @@ choose_modules() { script_dir() { case "$0" in */*) cd "$(dirname "$0")" 2>/dev/null && pwd ;; - *) return 1 ;; + *) + if [ -f "$PWD/$0" ]; then + pwd + else + return 1 + fi + ;; esac } @@ -569,7 +723,7 @@ install_homebrew_packages() { 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'." + log_detail "Bitwarden CLI нужен, чтобы брать LDAP-пароль и постоянный код настройки 2FA из item '$BW_ITEM'." run brew install bitwarden-cli fi else @@ -615,7 +769,7 @@ install_cli() { install_config() { tmp="$1" log_step "Записываю конфигурацию" - log_detail "Здесь сохраняются выбранные модули, LDAP username если он задан, и профиль openconnect-lite для Keycloak SSO." + log_detail "Здесь сохраняются выбранный режим входа, LDAP username если он задан, и профиль openconnect-lite для Keycloak SSO." run mkdir -p "$CONFIG_DIR" "$OC_CONFIG_DIR" @@ -628,6 +782,7 @@ install_config() { fi env_content="LEMANA_VPN_USERNAME=\"$USERNAME\" +LEMANA_VPN_AUTH_PROFILE=\"$AUTH_PROFILE\" LEMANA_VPN_CREDENTIAL_SOURCE=\"$CREDENTIAL_SOURCE\" LEMANA_VPN_BW_ITEM=\"$BW_ITEM\" LEMANA_VPN_USE_BITWARDEN=\"$USE_BITWARDEN\" @@ -878,7 +1033,7 @@ EOF maybe_login_bitwarden() { if [ "$CREDENTIAL_SOURCE" != "bitwarden" ]; then - log_skip "Credential source is keychain; пропускаю Bitwarden login." + log_skip "Bitwarden не выбран; пропускаю Bitwarden login." return 0 fi if ! command -v bw >/dev/null 2>&1; then @@ -899,6 +1054,43 @@ maybe_login_bitwarden() { fi } +auth_profile_label() { + case "$AUTH_PROFILE" in + manual) printf 'ручной ввод без сохранения паролей' ;; + saved-login) printf 'сохранить логин и пароль, одноразовый код вводить вручную' ;; + saved-totp) printf 'подставлять сохранённые данные в окно входа' ;; + auto) printf 'автоматический вход только при явном запуске' ;; + *) printf '%s' "$AUTH_PROFILE" ;; + esac +} + +print_final_next_steps() { + log "" + log_ok "Готово." + log_info "Open a new shell or run: exec zsh" + case "$AUTH_PROFILE" in + manual) + log_info "Как подключиться: откройте LemanaVPN.app и нажмите \"Подключить\", или выполните: vpn" + log_detail "Откроется окно входа. Введите корпоративный логин, пароль и одноразовый код вручную." + ;; + saved-login) + log_info "Как подключиться: Lemana VPN подставит логин и пароль, одноразовый код введите вручную." + log_detail "Команда подключения: vpn" + ;; + saved-totp) + log_info "Как подключиться: Lemana VPN подставит сохранённые данные в видимое окно входа." + log_detail "Для полностью автоматического запуска используйте явно: vpn-auto" + ;; + auto) + log_info "Автоматический вход включён явно. Если он не сработает, используйте ручной режим: vpn --manual-full" + ;; + esac + 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" @@ -909,7 +1101,12 @@ main() { 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" + if interactive_enabled; then + log_info "Выбран режим: $(auth_profile_label)" + log_detail "Bitwarden и Touch ID будут установлены только если они выбраны явно." + else + log_info "Modules: auth_profile=$AUTH_PROFILE credential_source=$CREDENTIAL_SOURCE 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 @@ -924,20 +1121,17 @@ main() { install_shell_aliases "$tmp" maybe_login_bitwarden - if [ "$CONFIGURE_KEYCHAIN" -eq 1 ]; then + 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-пароль и постоянный TOTP seed, не текущий 30-секундный код." + log_detail "Будут запрошены LDAP-пароль и постоянный код настройки 2FA, не текущий 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 + print_final_next_steps } main "$@" diff --git a/tests/smoke.sh b/tests/smoke.sh index b9c7b13..6f36a79 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -15,13 +15,18 @@ output="$(cd "$ROOT" && sh install.sh --dry-run --non-interactive --minimal)" printf '%s\n' "$output" | grep -q 'Detected state:' printf '%s\n' "$output" | grep -q 'Interactive prompts: off' -printf '%s\n' "$output" | grep -q 'Modules: credential_source=keychain bitwarden=0 touchid=0 sudoers=1 shell=1 app=1 autostart=1' +printf '%s\n' "$output" | grep -q 'Modules: auth_profile=manual credential_source=none bitwarden=0 touchid=0 sudoers=1 shell=1 app=1 autostart=1' printf '%s\n' "$output" | grep -q 'Проверяю Homebrew-зависимости' printf '%s\n' "$output" | grep -q 'Swift build может занять минуту' printf '%s\n' "$output" | grep -q 'sudo install -d -m 755 -o root -g wheel /usr/local/sbin' printf '%s\n' "$output" | grep -q 'swift build -c release --package-path' printf '%s\n' "$output" | grep -q 'launchctl load' printf '%s\n' "$output" | grep -q 'restart LemanaVPN.app if running' +printf '%s\n' "$output" | grep -q '+ cp .*/bin/vpn-lemanapro.sh ' +if printf '%s\n' "$output" | grep -q 'curl -fsSL .*raw/branch/main'; then + echo "checkout install dry-run should use local files, not remote main" >&2 + exit 1 +fi esc="$(printf '\033')" if printf '%s\n' "$output" | grep -q "$esc"; then @@ -33,34 +38,130 @@ if command -v expect >/dev/null 2>&1; then interactive_output="$( ROOT="$ROOT" expect <<'EXPECT' set timeout 30 -spawn sh $env(ROOT)/install.sh --dry-run --interactive --without-app --no-shell --no-sudoers -expect "Как хранить VPN credentials?" -expect "Выбор" -send "2\r" -expect eof + spawn sh $env(ROOT)/install.sh --dry-run --interactive --without-app --no-shell --no-sudoers + expect "Как подключаться по умолчанию?" + expect "Выбор" + send "\r" + expect "Сохранить логин и пароль" + send "y\r" + expect eof EXPECT )" - printf '%s\n' "$interactive_output" | grep -q 'Как хранить VPN credentials?' - printf '%s\n' "$interactive_output" | grep -q '1) Bitwarden' - printf '%s\n' "$interactive_output" | grep -q '2) macOS Keychain: ввести LDAP password и TOTP seed сейчас' - printf '%s\n' "$interactive_output" | grep -q '3) macOS Keychain: настрою вручную позже' - printf '%s\n' "$interactive_output" | grep -q 'Modules: credential_source=keychain bitwarden=0 touchid=0 sudoers=0 shell=0 app=0 autostart=0' - printf '%s\n' "$interactive_output" | grep -q "vpn-lemanapro.sh --configure-keychain" + printf '%s\n' "$interactive_output" | grep -q 'По умолчанию ничего не сохраняется' + printf '%s\n' "$interactive_output" | grep -q 'Как подключаться по умолчанию?' + printf '%s\n' "$interactive_output" | grep -q '1) Вводить всё вручную' + printf '%s\n' "$interactive_output" | grep -q '2) Запомнить логин и пароль' + printf '%s\n' "$interactive_output" | grep -q '3) Настроить автоматический вход' + printf '%s\n' "$interactive_output" | grep -q 'Сохранить логин и пароль в macOS Keychain для подстановки при входе?' + printf '%s\n' "$interactive_output" | grep -q 'Выбран режим: сохранить логин и пароль, одноразовый код вводить вручную' + printf '%s\n' "$interactive_output" | grep -q '+ .*/vpn-lemanapro.sh --configure-login' + if printf '%s\n' "$interactive_output" | grep -Eq 'credential_source|auth_profile|TOTP seed'; then + echo "basic interactive wizard should not expose technical credential wording" >&2 + exit 1 + fi fi status_json="$(bash "$ROOT/bin/vpn-lemanapro.sh" --status --json)" printf '%s\n' "$status_json" | grep -q '"modules":' printf '%s\n' "$status_json" | grep -q '"app":' +printf '%s\n' "$status_json" | grep -q '"app_menu":{"install_label":"Режим: полностью вручную","configured_mode":"manual","available_modes":\["configured"\],"setup_hint":null}' keychain_status_json="$(LEMANA_VPN_CREDENTIAL_SOURCE=keychain bash "$ROOT/bin/vpn-lemanapro.sh" --status --json)" -printf '%s\n' "$keychain_status_json" | grep -q '"credentials":{"source":"keychain","keychain_ready":false}' +printf '%s\n' "$keychain_status_json" | grep -q '"credentials":{"source":"keychain","auth_profile":"manual","login_ready":false,"keychain_ready":false}' printf '%s\n' "$keychain_status_json" | grep -q '"bitwarden":{"enabled":false' printf '%s\n' "$keychain_status_json" | grep -q '"touchid":{"enabled":false' bitwarden_status_json="$(LEMANA_VPN_CREDENTIAL_SOURCE=bitwarden bash "$ROOT/bin/vpn-lemanapro.sh" --status --json)" -printf '%s\n' "$bitwarden_status_json" | grep -q '"credentials":{"source":"bitwarden","keychain_ready":false}' +printf '%s\n' "$bitwarden_status_json" | grep -q '"credentials":{"source":"bitwarden","auth_profile":"manual","login_ready":false,"keychain_ready":false}' printf '%s\n' "$bitwarden_status_json" | grep -q '"bitwarden":{"enabled":true' + +status_password_bin="$TMP_DIR/status-password-bin" +mkdir -p "$status_password_bin" +cat > "$status_password_bin/security" <<'SH' +#!/bin/sh +if [ "${1:-}" = "find-generic-password" ]; then + account="" + while [ "$#" -gt 0 ]; do + if [ "$1" = "-a" ]; then + shift + account="${1:-}" + break + fi + shift + done + case "$account" in + ""|totp/*) exit 1 ;; + *) exit 0 ;; + esac +fi +exit 1 +SH +chmod +x "$status_password_bin/security" + +status_full_bin="$TMP_DIR/status-full-bin" +mkdir -p "$status_full_bin" +cat > "$status_full_bin/security" <<'SH' +#!/bin/sh +if [ "${1:-}" = "find-generic-password" ]; then + account="" + while [ "$#" -gt 0 ]; do + if [ "$1" = "-a" ]; then + shift + account="${1:-}" + break + fi + shift + done + [ -n "$account" ] + exit $? +fi +exit 1 +SH +chmod +x "$status_full_bin/security" + +saved_login_missing_status="$( + LEMANA_VPN_USERNAME="lemana-status-login-missing" \ + LEMANA_VPN_AUTH_PROFILE=saved-login \ + LEMANA_VPN_CREDENTIAL_SOURCE=keychain \ + bash "$ROOT/bin/vpn-lemanapro.sh" --status --json +)" +printf '%s\n' "$saved_login_missing_status" | grep -q '"app_menu":{"install_label":"Режим: логин и пароль не настроены","configured_mode":"saved-login","available_modes":\["manual-full"\],"setup_hint":"Настройте в Terminal: vpn --configure-login"}' + +saved_login_ready_status="$( + PATH="$status_password_bin:$PATH" \ + LEMANA_VPN_USERNAME="lemana-status-login-ready" \ + LEMANA_VPN_AUTH_PROFILE=saved-login \ + LEMANA_VPN_CREDENTIAL_SOURCE=keychain \ + bash "$ROOT/bin/vpn-lemanapro.sh" --status --json +)" +printf '%s\n' "$saved_login_ready_status" | grep -q '"app_menu":{"install_label":"Режим: логин и пароль сохранены","configured_mode":"saved-login","available_modes":\["configured","manual-full"\],"setup_hint":null}' + +saved_totp_missing_status="$( + LEMANA_VPN_USERNAME="lemana-status-totp-missing" \ + LEMANA_VPN_AUTH_PROFILE=saved-totp \ + LEMANA_VPN_CREDENTIAL_SOURCE=keychain \ + bash "$ROOT/bin/vpn-lemanapro.sh" --status --json +)" +printf '%s\n' "$saved_totp_missing_status" | grep -q '"app_menu":{"install_label":"Режим: сохранённые данные не настроены","configured_mode":"saved-totp","available_modes":\["manual-full"\],"setup_hint":"Настройте в Terminal: vpn --configure-keychain"}' + +saved_totp_ready_status="$( + PATH="$status_full_bin:$PATH" \ + LEMANA_VPN_USERNAME="lemana-status-totp-ready" \ + LEMANA_VPN_AUTH_PROFILE=saved-totp \ + LEMANA_VPN_CREDENTIAL_SOURCE=keychain \ + bash "$ROOT/bin/vpn-lemanapro.sh" --status --json +)" +printf '%s\n' "$saved_totp_ready_status" | grep -q '"app_menu":{"install_label":"Режим: сохранённые данные без автоотправки","configured_mode":"saved-totp","available_modes":\["configured","saved-login","manual-full","auto"\],"setup_hint":null}' + +auto_ready_status="$( + PATH="$status_full_bin:$PATH" \ + LEMANA_VPN_USERNAME="lemana-status-auto-ready" \ + LEMANA_VPN_AUTH_PROFILE=auto \ + LEMANA_VPN_CREDENTIAL_SOURCE=keychain \ + bash "$ROOT/bin/vpn-lemanapro.sh" --status --json +)" +printf '%s\n' "$auto_ready_status" | grep -q '"app_menu":{"install_label":"Режим: автоматический вход","configured_mode":"auto","available_modes":\["configured","saved-totp","saved-login","manual-full"\],"setup_hint":null}' personal_ldap='601''03293' if grep -R "$personal_ldap" "$ROOT/bin" "$ROOT/install.sh" "$ROOT/uninstall.sh" "$ROOT/README.md" "$ROOT/templates" "$ROOT/app" "$ROOT/.agents" >/dev/null; then echo "personal LDAP username must not be hardcoded in runtime, installer, app, docs, or agent guidance" >&2 @@ -75,13 +176,32 @@ grep -q -- '--manual-full' "$ROOT/bin/vpn-lemanapro.sh" grep -q -- '--manual-sso' "$ROOT/bin/vpn-lemanapro.sh" grep -q 'LEMANA_VPN_AUTOFILL_DISABLE' "$ROOT/bin/vpn-lemanapro.sh" grep -q 'LEMANA_VPN_AUTOFILL_CLICK' "$ROOT/bin/vpn-lemanapro.sh" +grep -q 'LEMANA_VPN_AUTOFILL_TOTP' "$ROOT/bin/vpn-lemanapro.sh" grep -q 'vpn-auto' "$ROOT/install.sh" grep -q 'vpn-manual' "$ROOT/install.sh" grep -q 'vpn-manual-full' "$ROOT/install.sh" -grep -q 'connect(mode: .auto)' "$ROOT/app/Sources/LemanaVPN/LemanaVPNApp.swift" -grep -q 'connect(mode: .manual)' "$ROOT/app/Sources/LemanaVPN/LemanaVPNApp.swift" -grep -q 'connect(mode: .manualFull)' "$ROOT/app/Sources/LemanaVPN/LemanaVPNApp.swift" -grep -q 'Подключить полностью вручную' "$ROOT/app/Sources/LemanaVPN/LemanaVPNApp.swift" +grep -q 'ForEach(vpnManager.availableLaunchModes)' "$ROOT/app/Sources/LemanaVPN/LemanaVPNApp.swift" +grep -q 'vpnManager.installModeLabel' "$ROOT/app/Sources/LemanaVPN/LemanaVPNApp.swift" +grep -q 'vpnManager.installSetupHint' "$ROOT/app/Sources/LemanaVPN/LemanaVPNApp.swift" +grep -q 'Button("Изменить логин и пароль")' "$ROOT/app/Sources/LemanaVPN/LemanaVPNApp.swift" +grep -q 'vpnManager.configureLogin()' "$ROOT/app/Sources/LemanaVPN/LemanaVPNApp.swift" +if grep -Fq 'connect(mode: .auto)' "$ROOT/app/Sources/LemanaVPN/LemanaVPNApp.swift"; then + echo "disconnected app menu should not hardcode the auto button" >&2 + exit 1 +fi +if grep -Fq 'Button("Подставить только логин и пароль")' "$ROOT/app/Sources/LemanaVPN/LemanaVPNApp.swift"; then + echo "disconnected app menu should not hardcode saved-login button visibility" >&2 + exit 1 +fi +grep -q 'app_menu' "$ROOT/app/Sources/LemanaVPN/VPNManager.swift" +grep -q 'available_modes' "$ROOT/app/Sources/LemanaVPN/VPNManager.swift" +grep -q 'menuTitle' "$ROOT/app/Sources/LemanaVPN/VPNManager.swift" +grep -q 'Подключить полностью вручную' "$ROOT/app/Sources/LemanaVPN/VPNManager.swift" +grep -q 'case "auth_profile":' "$ROOT/app/Sources/LemanaVPN/VPNManager.swift" +grep -q 'case "manual_sso", "auto_sso":' "$ROOT/app/Sources/LemanaVPN/VPNManager.swift" +grep -q 'case "username_required", "username_saved":' "$ROOT/app/Sources/LemanaVPN/VPNManager.swift" +grep -q 'func configureLogin()' "$ROOT/app/Sources/LemanaVPN/VPNManager.swift" +grep -q -- '--configure-login' "$ROOT/app/Sources/LemanaVPN/VPNManager.swift" grep -q 'enum VPNLaunchMode' "$ROOT/app/Sources/LemanaVPN/VPNManager.swift" grep -q 'struct Credentials: Decodable' "$ROOT/app/Sources/LemanaVPN/VPNManager.swift" grep -q 'credential_source' "$ROOT/app/Sources/LemanaVPN/VPNManager.swift" @@ -178,6 +298,8 @@ grep -q 'script.setWorldId(QWebEngineScript.ScriptWorldId.ApplicationWorld)' "$f grep -q 'new Event("input", {{bubbles: true}})' "$fake_webengine" grep -q 'LEMANA_VPN_AUTOFILL_CLICK' "$fake_webengine" grep -q 'os.environ.get("LEMANA_VPN_AUTOFILL_CLICK", "1") != "0"' "$fake_webengine" +grep -q 'LEMANA_VPN_AUTOFILL_TOTP' "$fake_webengine" +grep -q 'rule.fill == "totp"' "$fake_webengine" if grep -q 'ScriptWorldId.MainWorld' "$fake_webengine"; then echo "patched auto-fill should keep the original ApplicationWorld behavior" >&2 exit 1 @@ -230,6 +352,7 @@ set +e manual_output="$( HOME="$HOME" \ LEMANA_VPN_USERNAME="$missing_user" \ + LEMANA_VPN_AUTH_PROFILE=saved-login \ LEMANA_VPN_CREDENTIAL_SOURCE=keychain \ bash "$ROOT/bin/vpn-lemanapro.sh" --json 2>&1 )" @@ -238,9 +361,9 @@ set -e [ "$manual_code" -ne 0 ] printf '%s\n' "$manual_output" | grep -q '"event":"keychain_required"' -printf '%s\n' "$manual_output" | grep -q 'vpn --configure-keychain' +printf '%s\n' "$manual_output" | grep -q 'vpn --configure-login' if printf '%s\n' "$manual_output" | grep -q 'Cleaning up VPN DNS'; then - echo "missing manual credentials should fail before VPN cleanup trap is installed" >&2 + echo "missing saved login should fail before VPN cleanup trap is installed" >&2 exit 1 fi @@ -249,10 +372,11 @@ mkdir -p "$missing_username_home" set +e missing_username_output="$( HOME="$missing_username_home" \ - LEMANA_VPN_CONFIG_DIR="$missing_username_home/.config/lemana-vpn" \ - OPENCONNECT_LITE_CONFIG_DIR="$missing_username_home/.config/openconnect-lite" \ - LEMANA_VPN_CREDENTIAL_SOURCE=keychain \ - bash "$ROOT/bin/vpn-lemanapro.sh" --json 2>&1 + LEMANA_VPN_CONFIG_DIR="$missing_username_home/.config/lemana-vpn" \ + OPENCONNECT_LITE_CONFIG_DIR="$missing_username_home/.config/openconnect-lite" \ + LEMANA_VPN_AUTH_PROFILE=saved-login \ + LEMANA_VPN_CREDENTIAL_SOURCE=keychain \ + bash "$ROOT/bin/vpn-lemanapro.sh" --json 2>&1 )" missing_username_code=$? set -e @@ -294,6 +418,25 @@ cat > "$fake_oc_bin" <<'SH' printf 'args=%s\n' "$*" printf 'autofill_disable=%s\n' "${LEMANA_VPN_AUTOFILL_DISABLE:-}" printf 'autofill_click=%s\n' "${LEMANA_VPN_AUTOFILL_CLICK:-}" + printf 'autofill_totp=%s\n' "${LEMANA_VPN_AUTOFILL_TOTP:-}" + config_home="${XDG_CONFIG_HOME:-$HOME/.config}" + config_file="$config_home/openconnect-lite/config.toml" + printf 'xdg_config_home=%s\n' "$config_home" + if [ -f "$config_file" ]; then + if grep -q '^\[credentials\]' "$config_file"; then + printf 'credentials_block=1\n' + else + printf 'credentials_block=0\n' + fi + if grep -q 'username = ""' "$config_file"; then + printf 'empty_username=1\n' + else + printf 'empty_username=0\n' + fi + else + printf 'credentials_block=missing\n' + printf 'empty_username=missing\n' + fi } > "$LEMANA_VPN_CAPTURE_LAUNCH" exit 0 SH @@ -307,9 +450,139 @@ exit 1 SH chmod +x "$fake_missing_keychain_bin/security" +default_manual_home="$TMP_DIR/default-manual-home" +default_manual_capture="$TMP_DIR/default-manual-capture" +mkdir -p "$default_manual_home" +mkdir -p "$default_manual_home/.config/openconnect-lite" +cat > "$default_manual_home/.config/openconnect-lite/config.toml" <<'TOML' +on_disconnect = "true" + +[default_profile] +address = "vpn.lemanapro.ru" +user_group = "" +name = "Default-K" + +[credentials] +username = "" +TOML +default_manual_output="$( + HOME="$default_manual_home" \ + PATH="$fake_missing_keychain_bin:$PATH" \ + LEMANA_VPN_CONFIG_DIR="$default_manual_home/.config/lemana-vpn" \ + OPENCONNECT_LITE_CONFIG_DIR="$default_manual_home/.config/openconnect-lite" \ + LEMANA_VPN_OC_BIN="$fake_oc_bin" \ + LEMANA_VPN_OC_PYTHON=python3 \ + LEMANA_VPN_WEBENGINE_PROCESS="$fake_webengine" \ + LEMANA_VPN_AUTHENTICATOR="$fake_authenticator" \ + LEMANA_VPN_PATCH_BACKUP_DIR="$TMP_DIR/default-manual-patch-backups" \ + LEMANA_VPN_DNS_CLEANUP="$TMP_DIR/no-dns-cleanup" \ + LEMANA_VPN_CONNECT_LOG="$TMP_DIR/default-manual.log" \ + LEMANA_VPN_CAPTURE_LAUNCH="$default_manual_capture" \ + bash "$ROOT/bin/vpn-lemanapro.sh" --json +)" + +printf '%s\n' "$default_manual_output" | grep -q '"event":"manual_sso","auth_profile":"manual","autofill":false,"submit":false' +if printf '%s\n' "$default_manual_output" | grep -Eq '"event":"(username_required|keychain_required)"'; then + echo "default manual mode should open the browser without saved username or Keychain credentials" >&2 + exit 1 +fi +grep -q -- '--browser-display-mode shown' "$default_manual_capture" +grep -q '^autofill_disable=1$' "$default_manual_capture" +grep -q '^autofill_click=0$' "$default_manual_capture" +grep -q '^autofill_totp=0$' "$default_manual_capture" +grep -q '^credentials_block=0$' "$default_manual_capture" +grep -q '^empty_username=0$' "$default_manual_capture" + +fake_login_keychain_bin="$TMP_DIR/fake-login-keychain-bin" +mkdir -p "$fake_login_keychain_bin" +cat > "$fake_login_keychain_bin/security" <<'SH' +#!/bin/sh +if [ "${1:-}" = "find-generic-password" ]; then + account="" + while [ "$#" -gt 0 ]; do + if [ "$1" = "-a" ]; then + shift + account="${1:-}" + break + fi + shift + done + case "$account" in + ""|totp/*) exit 1 ;; + *) exit 0 ;; + esac +fi +exit 1 +SH +chmod +x "$fake_login_keychain_bin/security" + +saved_login_capture="$TMP_DIR/saved-login-capture" +saved_login_output="$( + HOME="$HOME" \ + PATH="$fake_login_keychain_bin:$PATH" \ + LEMANA_VPN_USERNAME="lemana-saved-login-$$" \ + LEMANA_VPN_AUTH_PROFILE=saved-login \ + LEMANA_VPN_CREDENTIAL_SOURCE=keychain \ + LEMANA_VPN_OC_BIN="$fake_oc_bin" \ + LEMANA_VPN_OC_PYTHON=python3 \ + LEMANA_VPN_WEBENGINE_PROCESS="$fake_webengine" \ + LEMANA_VPN_AUTHENTICATOR="$fake_authenticator" \ + LEMANA_VPN_PATCH_BACKUP_DIR="$TMP_DIR/saved-login-patch-backups" \ + LEMANA_VPN_DNS_CLEANUP="$TMP_DIR/no-dns-cleanup" \ + LEMANA_VPN_CONNECT_LOG="$TMP_DIR/saved-login.log" \ + LEMANA_VPN_CAPTURE_LAUNCH="$saved_login_capture" \ + bash "$ROOT/bin/vpn-lemanapro.sh" --json +)" + +printf '%s\n' "$saved_login_output" | grep -q '"event":"manual_sso","auth_profile":"saved-login","autofill":true,"totp":false,"submit":false' +if printf '%s\n' "$saved_login_output" | grep -q '"event":"keychain_required"'; then + echo "saved-login should not require a TOTP seed" >&2 + exit 1 +fi +grep -q -- '--browser-display-mode shown' "$saved_login_capture" +grep -q '^autofill_disable=0$' "$saved_login_capture" +grep -q '^autofill_click=0$' "$saved_login_capture" +grep -q '^autofill_totp=0$' "$saved_login_capture" + +saved_totp_capture="$TMP_DIR/saved-totp-capture" +saved_totp_output="$( + HOME="$HOME" \ + PATH="$fake_path_bin:$PATH" \ + LEMANA_VPN_USERNAME="lemana-saved-totp-$$" \ + LEMANA_VPN_AUTH_PROFILE=saved-totp \ + LEMANA_VPN_CREDENTIAL_SOURCE=keychain \ + LEMANA_VPN_OC_BIN="$fake_oc_bin" \ + LEMANA_VPN_OC_PYTHON=python3 \ + LEMANA_VPN_WEBENGINE_PROCESS="$fake_webengine" \ + LEMANA_VPN_AUTHENTICATOR="$fake_authenticator" \ + LEMANA_VPN_PATCH_BACKUP_DIR="$TMP_DIR/saved-totp-patch-backups" \ + LEMANA_VPN_DNS_CLEANUP="$TMP_DIR/no-dns-cleanup" \ + LEMANA_VPN_CONNECT_LOG="$TMP_DIR/saved-totp.log" \ + LEMANA_VPN_CAPTURE_LAUNCH="$saved_totp_capture" \ + bash "$ROOT/bin/vpn-lemanapro.sh" --json +)" + +printf '%s\n' "$saved_totp_output" | grep -q '"event":"manual_sso","auth_profile":"saved-totp","autofill":true,"totp":true,"submit":false' +grep -q -- '--browser-display-mode shown' "$saved_totp_capture" +grep -q '^autofill_disable=0$' "$saved_totp_capture" +grep -q '^autofill_click=0$' "$saved_totp_capture" +grep -q '^autofill_totp=1$' "$saved_totp_capture" + manual_full_no_credentials_home="$TMP_DIR/manual-full-no-credentials-home" manual_full_no_credentials_capture="$TMP_DIR/manual-full-no-credentials-capture" mkdir -p "$manual_full_no_credentials_home" +mkdir -p "$manual_full_no_credentials_home/.config/openconnect-lite" +cat > "$manual_full_no_credentials_home/.config/openconnect-lite/config.toml" <<'TOML' +on_disconnect = "true" + +[default_profile] +address = "vpn.lemanapro.ru" +user_group = "" +name = "Default-K" + +[credentials] +username = "" +TOML manual_full_no_credentials_output="$( HOME="$manual_full_no_credentials_home" \ PATH="$fake_missing_keychain_bin:$PATH" \ @@ -327,7 +600,7 @@ manual_full_no_credentials_output="$( bash "$ROOT/bin/vpn-lemanapro.sh" --manual-full --json )" -printf '%s\n' "$manual_full_no_credentials_output" | grep -q '"event":"manual_sso","autofill":false,"submit":false' +printf '%s\n' "$manual_full_no_credentials_output" | grep -q '"event":"manual_sso","auth_profile":"manual","autofill":false,"submit":false' if printf '%s\n' "$manual_full_no_credentials_output" | grep -Eq '"event":"(username_required|keychain_required)"'; then echo "manual-full should not require saved username or Keychain credentials before opening the browser" >&2 exit 1 @@ -335,6 +608,9 @@ fi grep -q -- '--browser-display-mode shown' "$manual_full_no_credentials_capture" grep -q '^autofill_disable=1$' "$manual_full_no_credentials_capture" grep -q '^autofill_click=0$' "$manual_full_no_credentials_capture" +grep -q '^autofill_totp=0$' "$manual_full_no_credentials_capture" +grep -q '^credentials_block=0$' "$manual_full_no_credentials_capture" +grep -q '^empty_username=0$' "$manual_full_no_credentials_capture" if command -v expect >/dev/null 2>&1; then first_username_home="$TMP_DIR/first-username-home" @@ -393,17 +669,18 @@ manual_full_output="$( bash "$ROOT/bin/vpn-lemanapro.sh" --manual-full --json )" -printf '%s\n' "$manual_full_output" | grep -q '"event":"manual_sso","autofill":false,"submit":false' +printf '%s\n' "$manual_full_output" | grep -q '"event":"manual_sso","auth_profile":"manual","autofill":false,"submit":false' grep -q -- '--browser-display-mode shown' "$manual_full_capture" grep -q '^autofill_disable=1$' "$manual_full_capture" grep -q '^autofill_click=0$' "$manual_full_capture" +grep -q '^autofill_totp=0$' "$manual_full_capture" fake_oc_python="$TMP_DIR/fake-oc-python" captured_totp="$TMP_DIR/captured-totp" cat > "$fake_oc_python" <<'SH' #!/bin/sh cat >/dev/null -printf '%s\n' "${_VPN_TOTP_SECRET:-}" > "$LEMANA_VPN_CAPTURE_TOTP" +printf '%s' "${_VPN_TOTP_SECRET:-}" > "$LEMANA_VPN_CAPTURE_TOTP" SH chmod +x "$fake_oc_python" @@ -420,6 +697,24 @@ configure_output="$( printf '%s\n' "$configure_output" | grep -q 'Credentials are ready in macOS Keychain' grep -q '^ABCD2345EFGH6723$' "$captured_totp" +captured_login_totp="$TMP_DIR/captured-login-totp" +configure_login_output="$( + printf 'ldap-password\n' | + HOME="$HOME" \ + LEMANA_VPN_USERNAME="lemana-login-$$" \ + LEMANA_VPN_AUTH_PROFILE=saved-login \ + LEMANA_VPN_CREDENTIAL_SOURCE=keychain \ + LEMANA_VPN_OC_PYTHON="$fake_oc_python" \ + LEMANA_VPN_CAPTURE_TOTP="$captured_login_totp" \ + bash "$ROOT/bin/vpn-lemanapro.sh" --configure-login +)" + +printf '%s\n' "$configure_login_output" | grep -q 'Login and password are ready in macOS Keychain' +if [ -s "$captured_login_totp" ]; then + echo "configure-login must not store a TOTP seed" >&2 + exit 1 +fi + fake_pwd="$TMP_DIR/fake-pwd" mkdir -p "$fake_pwd/bin" printf 'stale local cli\n' > "$fake_pwd/bin/vpn-lemanapro.sh"