diff --git a/.agents/skills/lemana-vpn-operations/SKILL.md b/.agents/skills/lemana-vpn-operations/SKILL.md index 06989fe..443ba74 100644 --- a/.agents/skills/lemana-vpn-operations/SKILL.md +++ b/.agents/skills/lemana-vpn-operations/SKILL.md @@ -33,7 +33,7 @@ tail -f ~/Library/Logs/LemanaVPN-openconnect-lite.log tail -f ~/Library/Logs/LemanaVPN.log ``` -Use `vpn --manual` for SSO diagnosis before automatic mode. Use `vpn-lemanapro.sh --patch-only` when only runtime patches need to be applied. +Manual or interactive SSO checks require an explicit request in the current prompt. Within that authorization, use `vpn --manual` for SSO diagnosis before automatic mode. Use `vpn-lemanapro.sh --patch-only` when only runtime patches need to be applied. ## Update Commands @@ -59,7 +59,7 @@ vpn --configure-keychain ## Answer Format -Final answers must always include how to run, how to check, and how to update. Also state whether live VPN was tested or intentionally skipped because an existing connection should not be disturbed. +For behavior or installation changes, include relevant run, check, and update commands, and state whether live VPN was tested. For skill-only or documentation-only changes, report the result and structural checks without unrelated installation steps. ## Common Mistakes diff --git a/.agents/skills/lemana-vpn-testing/SKILL.md b/.agents/skills/lemana-vpn-testing/SKILL.md index bad0714..72b59c6 100644 --- a/.agents/skills/lemana-vpn-testing/SKILL.md +++ b/.agents/skills/lemana-vpn-testing/SKILL.md @@ -45,7 +45,7 @@ Prefer installed status when verifying what the menu-bar app will see, because ` ## Live VPN Checks -Live checks are appropriate only for changes to connection start/stop, reconnect, SSO/autofill, Bitwarden/Keychain, `openconnect-lite` patching, status JSON consumed by the app, or DNS cleanup. +Manual or interactive live checks require an explicit request in the current prompt. Relevance alone is not authorization. Live checks are appropriate only for changes to connection start/stop, reconnect, SSO/autofill, Bitwarden/Keychain, `openconnect-lite` patching, status JSON consumed by the app, or DNS cleanup. Before a live check: @@ -93,9 +93,11 @@ Real install can modify Homebrew packages, pipx packages, sudoers, `/usr/local/s ## Final Report -Report exact commands and results. Always include: +Report exact commands and results relevant to the change. For behavior or installation changes, include: - Run: command to use the changed repo/app. - Check: command to verify state or behavior. - Update: `sh install.sh` from checkout or the remote `curl ... | sh`. - Not run: especially any skipped live VPN reconnect, real install, or real uninstall. + +For skill-only or documentation-only changes, report the result and structural checks; omit unrelated run/update instructions. diff --git a/AGENTS.md b/AGENTS.md index 4f2fc11..1f109b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,11 +55,11 @@ swift build -c release --package-path app git diff --check ``` -Run a live VPN check only when the change affects real connection behavior, credential flow, SSO/autofill, status reporting, reconnect, or DNS cleanup, and only after checking current status. If VPN is already connected, do not start a second session; report that live reconnect was intentionally skipped unless the user explicitly asks for it. +Manual or interactive live checks require an explicit request in the current prompt. Run a live VPN check only when the change affects real connection behavior, credential flow, SSO/autofill, status reporting, reconnect, or DNS cleanup, and only after checking current status. If VPN is already connected, do not start a second session; report that live reconnect was intentionally skipped unless the user explicitly asks for it. ## Answer Contract -Every final answer for this repository must include: +For behavior or installation changes, the final answer includes the relevant items below. For skill-only or documentation-only changes, report the result and structural checks without unrelated run/update steps: - What changed, with file references when useful. - How to run it. diff --git a/README.md b/README.md index b05e1a7..a482d24 100644 --- a/README.md +++ b/README.md @@ -1,621 +1,390 @@ # Lemana VPN -CLI-установка корпоративного VPN `vpn.lemanapro.ru` для macOS. +macOS-пакет для подключения к корпоративному VPN `vpn.lemanapro.ru`. -**По умолчанию:** ручной вход без сохранения паролей. Откроется окно корпоративного входа, а логин, пароль и одноразовый код вводятся вручную. Bitwarden, Touch ID, сохранение пароля, подстановка 2FA и автоматический вход включаются только явно. +В приложении два способа входа: -**Режимы входа:** `manual` ничего не сохраняет и ничего не подставляет; `saved-login` хранит LDAP логин/пароль в macOS Keychain и оставляет одноразовый код ручным; `saved-totp` дополнительно хранит постоянный код настройки 2FA; `auto` запускается только явно через `vpn-auto`/`--auto`. +- **Вручную**: логин и пароль берутся с этого Mac, одноразовый код вводится вручную в видимом окне. +- **Автоматически**: данные подставляются и форма отправляется в скрытом браузере. Источник выбирается отдельно: **Ключ на этом Mac** или **Bitwarden**. -Репозиторий собирает в один воспроизводимый пакет то, что раньше было ручной локальной настройкой: +Кнопка **Подключить** всегда использует сохранённую настройку. Изменение способа входа не запускает VPN и применяется при следующем подключении. Свежая установка по умолчанию выбирает ручной ввод кода. -- `openconnect` как VPN-клиент; -- `openconnect-lite` для SAML SSO через Keycloak; -- опциональный Bitwarden CLI для LDAP-пароля и постоянного кода настройки 2FA; -- опциональный Touch ID helper для мастер-пароля Bitwarden; -- Swift Menu Bar app `LemanaVPN.app`; -- безопасный DNS cleanup через root-owned wrapper; -- алиасы `vpn`, `vpn-auto`, `vpn-manual`, `vpn-manual-full`, `vpn-debug`, `vpn-fix-dns`. - -## Быстрая установка +## Быстрая Установка ```sh curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh | sh ``` -Если установка запущена из терминала, скрипт работает как простой wizard: объяснит, что будет установлено, по Enter оставит безопасный ручной режим и предложит дополнительные удобства только отдельным выбором. - -После установки открой новый shell или выполни: +После установки: ```sh exec zsh +vpn --status vpn ``` -## Интерактивная установка - -Обычный путь — запустить installer без флагов и ответить на вопросы: - -```sh -curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh | sh -``` - -Первый важный вопрос — как подключаться: - -```text -Как подключаться по умолчанию? - - 1) Вводить всё вручную (рекомендуется) - Откроется окно входа. Вы сами введёте логин, пароль и одноразовый код. - - 2) Запомнить логин и пароль на этом Mac - При входе логин и пароль подставятся сами. Одноразовый код вы введёте вручную. - - 3) Настроить автоматический вход - Нужно заранее сохранить пароль и постоянный код настройки 2FA. - -Выбор [1/2/3, Enter=1]: -``` - -Что означают варианты: - -- `1` — ничего не сохранять. Это самый понятный старт: при подключении открывается окно входа, всё вводится руками. -- `2` — сохранить LDAP логин и пароль в macOS Keychain. Одноразовый код всё равно вводится руками. -- `3` — продвинутый путь: сохранить полный набор данных для подстановки или подключить Bitwarden. Если не уверены, выбирайте `1`. - -Дальше installer спросит только про системные модули: sudoers, aliases, Swift Menu Bar app и автозапуск. Bitwarden и Touch ID появляются только если вы явно выбрали продвинутый путь. - -Флаги остаются для CI, повторяемых установок и диагностики. Для обычной установки они не нужны. - -Проверить действия без изменений: - -```sh -curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh | sh -s -- --dry-run -``` - -Пример неинтерактивного режима для автоматизации: - -```sh -curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh \ - | sh -s -- --non-interactive --auth-profile saved-login --configure-login -``` - -Принудительно включить интерактивные вопросы: - -```sh -curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh | sh -s -- --interactive -``` - -Запустить без вопросов, с выбранными флагами и дефолтами: - -```sh -curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh | sh -s -- --non-interactive -``` - -Если raw URL отличается, переопредели базовый адрес: - -```sh -curl -fsSL https://example.org/dokril/lemana-vpn/raw/branch/main/install.sh \ - | LEMANA_VPN_RAW_BASE_URL=https://example.org/dokril/lemana-vpn/raw/branch/main sh -``` - -## Что ставится - -| Путь | Назначение | -| --- | --- | -| `~/bin/vpn-lemanapro.sh` | Основной CLI для подключения, статуса и sync секретов | -| `~/bin/uninstall-lemana-vpn.sh` | Локальный uninstall helper | -| `~/bin/keychain-fingerprint` | Опциональный Touch ID helper для мастер-пароля Bitwarden | -| `~/Applications/LemanaVPN.app` | Swift Menu Bar app для подключения из status bar | -| `~/Library/LaunchAgents/ru.dokops.LemanaVPN.plist` | Автозапуск Menu Bar app при логине | -| `~/.config/lemana-vpn/env` | Локальная конфигурация модулей | -| `~/.config/lemana-vpn/patch-backups/` | Backup исходника `openconnect-lite` перед runtime-патчами | -| `~/.config/openconnect-lite/config.toml` | Профиль SSO и auto-fill правила Keycloak | -| `/usr/local/sbin/lemana-vpn-dns-cleanup` | Root-owned wrapper для сброса только корпоративных DNS | -| `/etc/sudoers.d/lemana-vpn-openconnect` | `NOPASSWD` только для `openconnect` | -| `/etc/sudoers.d/lemana-vpn-dns` | `NOPASSWD` только для DNS cleanup wrapper | -| `~/.zshrc` | Идемпотентный блок алиасов `vpn`, `vpn-auto`, `vpn-manual`, `vpn-manual-full`, `vpn-debug`, `vpn-fix-dns` | - -## Статус модулей - -`vpn` и `vpn --status` первой строкой показывают, какие модули включены в конфиге и реально установлены на машине: - -```sh -vpn --status -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 -``` - -Emoji в human-выводе помогают быстро отличать норму, отключённый опциональный модуль и проблему: - -- `✅` — модуль установлен или состояние готово; -- `⏭️` — модуль осознанно отключён; -- `⚠️` — модуль включён, но чего-то не хватает. - -Значения: - -| Поле | Значение | -| --- | --- | -| `core=ok` | Есть `openconnect`, `openconnect-lite` и config | -| `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-пароль и постоянный код настройки 2FA в Keychain | - -JSON-режим тоже отдаёт модульный статус: - -```sh -vpn --status --json -``` - -## Интерактивная установка - -Перед установкой `install.sh` печатает текущее состояние: - -```text -Detected state: - openconnect: yes - pipx: yes - openconnect-lite: yes - Bitwarden CLI: no - Touch ID helper: no - DNS cleanup: no - sudoers: no/no - shell aliases: no - Swift: yes - Menu Bar app: no - LaunchAgent: no - Keychain password: no - Keychain 2FA setup: no -``` - -Если доступен терминал, скрипт ведёт установку вопросами: - -- оставить ручной вход, сохранить только логин/пароль или настроить подстановку сохранённых данных; -- поставить 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. Например, `--auth-profile saved-login --configure-login` сразу выберет сохранение логина/пароля, а `--no-shell` не будет предлагать алиасы. - -В неинтерактивной среде скрипт не задаёт вопросов и использует выбранные флаги/дефолты. Для CI или повторяемой установки лучше явно указывать `--non-interactive`. - -## Логи установщика и удаления - -Установщик и uninstall script печатают пошаговый лог с emoji, цветом в интерактивном терминале и коротким пояснением, зачем нужен каждый шаг. Например, перед сборкой Swift-приложения установщик отдельно пишет, что `swift build` может занять время и что строки компилятора вида `[2/5] Write swift-version...` являются нормальным выводом. При удалении отдельно показывается откат runtime-патчей `openconnect-lite`, удаление sudoers/DNS wrapper, приложения, aliases и config. - -Отключить цвет: - -```sh -NO_COLOR=1 sh install.sh -NO_COLOR=1 sh uninstall.sh -``` - -Отключить emoji: - -```sh -LEMANA_VPN_NO_EMOJI=1 sh install.sh -LEMANA_VPN_NO_EMOJI=1 sh uninstall.sh -``` - -## Модули - -### Core - -Всегда устанавливается: - -- `openconnect` через Homebrew; -- `pipx` через Homebrew; -- `openconnect-lite` через `pipx`; -- CLI `vpn-lemanapro.sh`; -- `openconnect-lite` config; -- DNS cleanup wrapper. - -### Как работают режимы входа - -Keychain entries: - -- service `openconnect-lite`, account `` — корпоративный LDAP/domain пароль; -- service `openconnect-lite`, account `totp/` — постоянный код настройки 2FA, нужен только для подстановки одноразового кода и auto mode. - -`auth_profile=manual` — режим по умолчанию. CLI не проверяет Keychain и Bitwarden, открывает видимое окно входа и ничего не подставляет. Логин, пароль и одноразовый код вводятся вручную. - -`auth_profile=saved-login` — CLI берёт из Keychain только LDAP пароль и подставляет логин/пароль в видимое окно входа. Одноразовый код вводится руками. Настройка: - -```sh -vpn --configure-login -``` - -`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 - -Отключён по умолчанию. Его стоит включать только если у пользователя уже есть запись Bitwarden с корпоративным LDAP-паролем и постоянным кодом настройки 2FA. - -Постоянный код настройки 2FA — это секрет, который обычно показывается при ручной настройке authenticator. Сам одноразовый код меняется каждые 30 секунд и генерируется `openconnect-lite` в момент входа. - -Если vault заблокирован и Touch ID helper не смог его открыть, CLI спросит `Bitwarden master password`. Это пароль от хранилища Bitwarden, а не корпоративный LDAP-пароль. Он нужен только чтобы достать LDAP password и постоянный код настройки 2FA из item `LM LDAP` и переложить их в macOS Keychain. - -Включить Bitwarden можно через продвинутый путь в installer wizard или флагами: - -То же самое можно задать флагами для неинтерактивной установки: - -```sh -sh install.sh --non-interactive --with-bitwarden --auth-profile saved-totp -``` - -Для Keychain-only режима без Bitwarden используйте: - -```sh -sh install.sh --non-interactive --auth-profile saved-login --configure-login -``` - -Если сохранённые данные уже лежат в Keychain, подключение без Bitwarden не будет спрашивать пароль заново. Для режима `saved-login` одноразовый код всё равно вводится вручную. - -### Если Bitwarden нет - -Bitwarden не обязателен. Без него установка по умолчанию работает в полностью ручном режиме: откроется окно входа, а логин, пароль и одноразовый код вводятся руками. - -Интерактивная установка: - -```sh -curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh | sh -``` - -В первом вопросе можно просто нажать Enter: - -```text -1) Вводить всё вручную (рекомендуется) -``` - -Это встроенный бесплатный путь: не нужен Bitwarden account, платный Bitwarden TOTP или внешний password manager. - -Если хочется, чтобы логин и пароль подставлялись сами, выбери пункт `2` или выполни позже: - -```sh -vpn --configure-login -``` - -Что понадобится: - -- LDAP username; -- LDAP password: корпоративный LDAP/domain пароль, не мастер-пароль Bitwarden; -- одноразовый код из корпоративной 2FA при каждом подключении. - -Постоянный код настройки 2FA нужен только для `vpn --configure-keychain`, `saved-totp` и `vpn-auto`. - -Не вставляй текущий 6-значный authenticator code в `vpn --configure-keychain`. Lemana VPN сохраняет в Keychain постоянный код настройки 2FA, а `openconnect-lite` по нему генерирует свежие одноразовые коды во время каждого SSO login. - -Если запуск идёт из `LemanaVPN.app`, приложение не может безопасно показать интерактивный terminal prompt для сохранения пароля. Настройку saved-login или saved-totp нужно один раз выполнить в Terminal: - -```sh -vpn --configure-login -vpn --configure-keychain -``` - -Если secret есть только в QR-коде: - -1. Открой QR-код в приложении/на портале, где настраивалась 2FA. -2. Найди режим ручной настройки, где показывается secret. -3. Если доступен только QR, его нужно расшифровать любым локальным QR-сканером и взять параметр `secret`. -4. Вставь secret в prompt `2FA setup secret BASE32`. - -Если постоянный 2FA secret получить нельзя, автоматический headless-вход невозможен: `openconnect-lite` не сможет сам генерировать свежий одноразовый код на каждом входе. - -### Touch ID - -Отключён по умолчанию и нужен только для Bitwarden-пути. Если пользователь выбрал Bitwarden, установщик может собрать `keychain-fingerprint` из `https://github.com/dss99911/keychain-fingerprint.git` и положить бинарник в `~/bin/keychain-fingerprint`. - -Важно: этот helper показывает системный Touch ID prompt перед чтением мастер-пароля Bitwarden, но это не аппаратный Keychain ACL. Это удобный локальный гейт поверх записи Keychain. - -Отключить: - -```sh -sh install.sh --without-touchid -``` - -### Swift Menu Bar app - -Включён по умолчанию. Установщик собирает Swift-приложение из исходников в репозитории и кладёт bundle в: - -```sh -~/Applications/LemanaVPN.app -``` - -Приложение живёт в 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`. - -Для сборки нужен Swift 5.9+ из Xcode Command Line Tools: - -```sh -xcode-select --install -``` - -Отключить установку приложения: - -```sh -curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh | sh -s -- --without-app -``` - -Оставить приложение, но отключить автозапуск: - -```sh -curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh | sh -s -- --without-autostart -``` - -Ручной запуск: +Открыть приложение: ```sh open ~/Applications/LemanaVPN.app ``` -## Использование +## Режимы Установки -```sh -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 -vpn --status # статус без нового подключения -vpn --status --json # статус в JSON -vpn-debug # видимый браузер и debug-логи -vpn --manual --debug # ручной режим с debug-логами -vpn-fix-dns # сбросить корпоративные DNS после аварийного завершения -open ~/Applications/LemanaVPN.app # открыть Swift-приложение в menu bar +Интерактивный установщик сначала предлагает способ входа, затем источник для автоматического режима: + +```text +Какой режим использовать? + + 1) Ручной вход: запомнить логин и пароль на этом Mac (рекомендуется) + Логин и пароль подставятся из macOS Keychain. Одноразовый код вводится руками каждый раз. + + 2) Автоматический вход + Пароль и код подставляются автоматически, форма отправляется без ручного ввода. + +Источник данных: 1) Ключ на этом Mac, 2) Bitwarden [1/2, Enter=1] ``` -Режимы подключения: - -- `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, если он выбран явно: - -1. CLI проверит `bw`. -2. Если vault locked, попросит мастер-пароль. -3. Если установлен Touch ID helper, предложит сохранить мастер-пароль за Touch ID prompt. -4. Достанет `LM LDAP`, запишет LDAP-пароль и постоянный код настройки 2FA в Keychain. -5. Запустит `openconnect-lite` и пройдёт Keycloak SSO. - -## Настройка - -Файл `~/.config/lemana-vpn/env`: +Неинтерактивно: ```sh -LEMANA_VPN_USERNAME="" -LEMANA_VPN_AUTH_PROFILE="manual" -LEMANA_VPN_CREDENTIAL_SOURCE="none" -LEMANA_VPN_BW_ITEM="LM LDAP" -LEMANA_VPN_USE_BITWARDEN="0" -LEMANA_VPN_USE_TOUCHID="0" -LEMANA_VPN_DNS_CLEANUP="/usr/local/sbin/lemana-vpn-dns-cleanup" +sh install.sh --non-interactive --mode manual-keychain --configure-login +sh install.sh --non-interactive --mode keychain-auto --import-yandex-key +sh install.sh --non-interactive --mode bitwarden-auto ``` -Для режима с сохранённым логином и паролем: +Проверить действия без изменений: ```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" -LEMANA_VPN_DNS_CLEANUP="/usr/local/sbin/lemana-vpn-dns-cleanup" +sh install.sh --dry-run --non-interactive --mode manual-keychain +sh install.sh --dry-run --non-interactive --mode bitwarden-auto ``` -Если LDAP username не указан при установке, `vpn` спросит его при первом интерактивном запуске в Terminal и сохранит в `~/.config/lemana-vpn/env`. Для неинтерактивной установки можно указать его сразу: +## Ручной Режим + +По умолчанию `manual-keychain` хранит только LDAP username/password: + +- service `openconnect-lite`, account `` - LDAP password; +- TOTP/current 6-digit code не сохраняется и не требуется. + +Настроить или обновить логин и пароль: ```sh -curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh \ - | sh -s -- --username +vpn --configure-login ``` -## Bitwarden item +Из меню LemanaVPN.app доступна команда `Логин и пароль…`; она открывает Terminal и запускает тот же CLI-путь, чтобы пароль вводился в нормальном TTY. -Нужна запись: - -- название: `LM LDAP`; -- username: корпоративный LDAP логин; -- password: LDAP пароль; -- TOTP: `otpauth://...secret=BASE32...` или raw BASE32 secret. - -Это не 6-значный одноразовый код. В Bitwarden должен лежать постоянный 2FA secret, из которого коды генерируются автоматически. - -## Почему DNS wrapper, а не wildcard sudoers - -Старый вариант давал `NOPASSWD` на `networksetup -setdnsservers *`. Это слишком широкое право: любой локальный процесс пользователя мог поменять DNS на произвольный сервер. - -Новый вариант разрешает sudo только на `/usr/local/sbin/lemana-vpn-dns-cleanup`. Wrapper сбрасывает DNS только если текущий DNS начинается с `10.`, то есть похож на корпоративный VPN DNS. - -## Runtime-патчи openconnect-lite - -`openconnect-lite` работает, но для текущей macOS + Keycloak SSO цепочки ему нужны runtime-патчи. CLI применяет их перед подключением в файле: +Подключиться: ```sh -~/.local/pipx/venvs/openconnect-lite/lib/python*/site-packages/openconnect_lite/browser/webengine_process.py +vpn ``` -Патчи: +Ожидаемое поведение: откроется видимое окно входа, логин и пароль подставятся, одноразовый код вводится руками, submit не нажимается автоматически. -| Патч | Что меняет | Зачем | -| --- | --- | --- | -| `minimal -> offscreen` | Меняет Qt platform mode для скрытого браузера | `minimal` падает с Qt WebEngine на macOS | -| `input/change events` | Оставляет старое прямое `value = ...`, но после него отправляет DOM events | Keycloak не реагирует на прямую запись value без событий | -| `legacy auto-fill` | Сохраняет старую рабочую схему `ApplicationWorld`, прямой `value = ...` и простой `click()` | Это ровно тот режим, на котором hidden SSO раньше стабильно проходил Keycloak | -| `URL guard` | Проверяет `location.href` через `new RegExp(...)` перед auto-fill | Qt игнорирует `@include`, без guard auto-fill может кликнуть Cisco ACS и сломать SAML | -| `auth redirect` | Читает 302 с `vpn.lemanapro.ru` без автоматического follow-redirect | Python `requests` может падать на TLS reset при открытии `/` на `sslvpna/b`, хотя для SAML нужен только конечный host | -| `manual submit gate` | Позволяет отключить только auto-click через `LEMANA_VPN_AUTOFILL_CLICK=0` | Ручной режим видит заполненную форму, но сам решает, когда нажать вход | -| `manual SSO disable` | Позволяет полностью отключить auto-fill через `vpn --manual-full` или `LEMANA_VPN_AUTOFILL_DISABLE=1` | Нужен для низкоуровневой диагностики без подстановки полей | +## Одноразовый код из Яндекс Ключа -Перед первым изменением CLI сохраняет оригинальный файл: +В Яндекс Ключе (Яндекс ID) откройте **Настройки → Резервная копия данных → Сохранить в файл** и перенесите файл на Mac. Это штатный [локальный экспорт Яндекса](https://yandex.ru/support/id/ru/authorization/twofa-copy). Он содержит секреты всех записей: не отправляйте его в чат и не загружайте в онлайн-конвертеры. + +В обычном установщике есть отдельный вопрос об импорте. Запустить установку сразу с этим шагом: ```sh -~/.config/lemana-vpn/patch-backups/webengine_process.py.before-lemana-vpn +sh install.sh --mode keychain-auto --import-yandex-key ``` -Откат патчей выполняет uninstall script. Если backup отсутствует, автоматического rollback нет: значит файл был уже патчен старой ручной установкой или `openconnect-lite` переустановили после backup. - -## Диагностика - -Проверить установку: +Для уже установленного CLI после обновления: ```sh -command -v vpn-lemanapro.sh -openconnect --version -~/.local/bin/openconnect-lite --help -sudo -n /usr/local/sbin/lemana-vpn-dns-cleanup +vpn --configure-login # если логин и пароль ещё не сохранены +vpn --import-yandex-key vpn --status ``` -Обычный `vpn` теперь пишет путь к подробному логу `openconnect-lite`: +Мастер спросит путь к файлу, покажет имена записей и предложит выбрать запись Lemana/VPN. Перед сохранением он покажет LDAP-аккаунт назначения и попросит текущий код с телефона. Проверка выполняется локально; введённый код скрыт. LDAP-логин в настройках CLI и профиле SSO должен совпадать. + +После успешной проверки только выбранный секрет сохраняется в macOS Keychain: service `openconnect-lite`, account `totp/`. В `env` выбирается `keychain-auto`: автоматический вход с ключом на этом Mac. Пароль остаётся прежним. Команда `vpn` подставит логин, пароль и код и отправит форму в скрытом браузере. Bitwarden для этого не нужен. Импорт не запускает VPN. + +Поддерживается локальный JSON-экспорт (`key_backup` или `key_backup.txt`), TOTP / SHA1 / 6 цифр / 30 секунд. Другие алгоритмы, HOTP и собственные коды аккаунта Яндекса отклоняются. Лимиты импортёра: файл до 4 МиБ, до 1000 записей. Формат проверен по [импортёру GNOME Authenticator](https://world.pages.gitlab.gnome.org/Authenticator/src/authenticator/backup/yandex.rs.html); облачные копии и QR-коды этот мастер не читает. + +Отмена или неверный код ничего не сохраняют. При ошибке включения настройки импортёр пытается вернуть прежнюю запись Keychain и сообщает, если откат не удался. Исходный файл не копируется и автоматически не удаляется. При выключении компьютера или принудительном завершении процесса между записями проверьте состояние через `vpn --status` и при необходимости повторите импорт. + +Чтобы снова вводить код вручную, выберите «Вручную» в меню или выполните `vpn --set-auth=manual`. Сохранённый секрет останется в Keychain. `vpn --manual-full` по-прежнему отключает любую подстановку. Обновление сохраняет выбранную настройку; режим `bitwarden-auto` использует свой прежний путь. + +## Автоматический вход через Bitwarden + +`bitwarden-auto` использует Bitwarden как источник данных: + +- Bitwarden item: `LM LDAP`; +- Bitwarden username - LDAP username; +- Bitwarden password - LDAP/domain password, не master password от vault; +- Bitwarden TOTP field - permanent 2FA setup secret или `otpauth://...`, не текущие 6 цифр; +- Touch ID helper может открыть master password для Bitwarden; +- runtime sync кладёт username в config, а пароль и permanent 2FA secret в macOS Keychain для `openconnect-lite`. + +В этом режиме Lemana VPN не спрашивает LDAP username/password вручную. Если `~/.config/lemana-vpn/env` содержит пустой `LEMANA_VPN_USERNAME=""`, это нормально: username заполнится из `LM LDAP` после успешного Bitwarden unlock/sync. + +Подключиться: ```sh -~/Library/Logs/LemanaVPN-openconnect-lite.log +vpn ``` -Если после `Connecting to VPN (lemanapro)...` SSO завис или не видно, что происходит, смотри этот файл: +Разово запустить вход через Bitwarden из CLI: ```sh +vpn-auto +vpn bitwarden-auto +``` + +Если Bitwarden не залогинен: + +```sh +bw login +``` + +Если автоматический вход не проходит, сначала проверь статус и логи: + +```sh +vpn --status tail -f ~/Library/Logs/LemanaVPN-openconnect-lite.log ``` -В обычном режиме CLI также печатает heartbeat `Still waiting for SSO/openconnect-lite...` до успешного подключения, чтобы было понятно, что процесс живой. В `vpn-debug` дополнительно показываются raw-логи и видимый браузер. +## Статус -Если в логе повторяется один и тот же URL вида `employee.auth.lemanapro.ru/realms/employee/login-actions/authenticate`, значит hidden-браузер застрял на Keycloak до перехода в Cisco ACS. Сначала обнови и примени runtime-патчи без подключения: +Меню приложения открывается в компактном окне. Пункт **Импорт из Яндекс Ключа…** запускает существующий мастер в Terminal; приложение не читает секреты. В меню всегда видны сохранённый способ входа и источник. Стрелки с разовыми режимами у «Подключить» нет; настройки изменяются отдельными переключателями. Во время сохранения нельзя запустить подключение или второй запрос изменения. + +В разделе **Значок в строке меню** можно выбрать: + +- **Щит**: значок 16 pt с яркой заливкой оставшегося времени и прогрессом по контуру; вариант по умолчанию. +- **Кольцо**: круг с такой же заливкой оставшегося времени и прогрессом по контуру. +- **Часы**: щит с циферблатом и движущейся стрелкой. +- **Время**: щит и компактное оставшееся время `H:MM`. + +Выбор сохраняется между запусками, в том числе ранее выбранные кольцо, часы и время. Значки зелёные, пока остаётся больше часа; янтарные в последний час, красные в последние 15 минут. Нейтральная подложка и более плотная цветная заливка делают прогресс различимым в строке меню. Точное время видно в меню и подсказке значка. Начало и конец берутся из CLI, фиксированной длительности нет. Со старым CLI начало отсчитывается от первого события подключения. Если срок неизвестен или некорректен, прогресс не выдумывается. Истечение таймера само по себе не запускает новое подключение. Значки состояний, действий и источников входа используют единый векторный набор. + +Индикаторы «Щит» и «Кольцо» начинают с полной яркой заливки. По мере расхода времени яркая часть уменьшается сверху вниз, а израсходованная остаётся тусклой и видимой. + +«Подключено» появляется после выдачи адреса туннеля, независимо от наличия срока сессии. CLI понимает оба формата даты OpenConnect, включая числовой часовой пояс. Срок может прийти до или после адреса; последующие сообщения не сдвигают начало сессии. До получения срока меню показывает «Срок сессии неизвестен». `vpn --status` также различает подключение и переподключение. + +### Переподключение и защита входа + +В автоматическом режиме (Keychain или Bitwarden) после завершения поднятого туннеля CLI проверяет остановку процессов и восстановление DNS, затем повторяет подключение через 5 секунд. При неудачных попытках до поднятия туннеля допускаются пять повторов с паузами 5, 10, 20, 40 и 60 секунд. Приложение отображает эти состояния; собственного второго цикла повторов у него нет. Ручной режим и отмена входа не запускают автоматические повторы. + +Каждый этап автоматического входа отправляется один раз: переход от пароля к одноразовому коду разрешён. Видимая ошибка формы или повторный запрос уже отправленного этапа останавливает вход и все автоматические повторы. Ошибка остаётся в меню после очистки DNS. Проверьте пароль и код, затем запустите подключение вручную. Защита не вводит локальную блокировку по времени и не меняет ручную отправку формы. + +Если проверка доступности обнаружит зависший туннель, приложение просит helper завершить только `openconnect` своей сессии. Запоздалый запрос не прерывает уже восстановившийся туннель. CLI остаётся работать и выполняет ту же проверку DNS перед повтором. При активном чужом VPN, незавершённых процессах или ошибке восстановления новая попытка не запускается. «Отключить» и «Выход» отменяют и ожидающий повтор. + +### Восстановление DNS после перезагрузки + +При запуске приложение проверяет, не остались ли настройки DNS после аварийно завершённого VPN. Перед подключением root-owned helper записывает исходные DNS сетевых сервисов и DNS, выданные VPN, в `/var/db/lemana-vpn-dns`. После обычного отключения или следующего запуска он возвращает прежние значения, включая пользовательские DNS; для автоматических настроек возвращается DHCP. + +После «Отключить» root-helper отправляет `TERM` только `openconnect` зарегистрированной сессии. Проверяются пользователь, идентификатор сессии, загрузка системы, PID и время запуска; чужие VPN не останавливаются. До появления туннеля отменяются только пользовательские процессы входа этой сессии. Запись об отмене не позволяет запоздавшему дочернему процессу настроить сеть. + +Меню показывает «Отключение», пока завершаются процессы VPN и проверяется восстановление DNS. Если дочерние процессы ещё выходят, CLI повторяет проверку до 30 раз с интервалом в секунду. «Выход» и Cmd+Q ждут завершения отключения и проверки DNS. Повторное нажатие не прерывает очистку. При ошибке или ожидании дольше 45 секунд приложение остаётся открытым с кнопкой «Повторить отключение»; принудительный `KILL` не применяется. Для этого должны быть обновлены вместе CLI, helper и приложение через `sh install.sh`. + +Восстановление не запускает VPN, не трогает активное подключение и не сбрасывает произвольные адреса `10.x`. Проверяются процессы, сохранённая сетевая конфигурация и совпадение текущих DNS с записанными. Изменённые пользователем DNS сохраняются. Ошибка восстановления видна в меню; «Обновить статус» повторяет проверку. Пока проверка идёт или требуется исправление, «Подключить» недоступна. ```sh -curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh | sh -vpn-lemanapro.sh --patch-only +vpn --repair-dns # Проверить и восстановить без подключения +vpn --repair-dns --json # Результат для приложения ``` -Если лог падает раньше браузера с `SSLEOFError` / `UNEXPECTED_EOF_WHILE_READING` на `sslvpna.lemanapro.ru` или `sslvpnb.lemanapro.ru`, это ломается этап определения конечного Cisco headend. Актуальный runtime-патч `auth redirect` не открывает `/` на `sslvpna/b`, а только берёт `Location` из 302 ответа `vpn.lemanapro.ru` и продолжает штатный SAML init через POST. +`vpn-fix-dns` вызывает ту же проверку. Обычный `sh install.sh` обновляет helper, CLI и приложение; без обновления старого helper новое подключение заблокировано. Функция восстановления требует установленного правила sudoers и работает без запроса пароля. -Если SSO ломается после обновления `openconnect-lite`, запусти: +Настройки подключения, сделанного **до этого обновления**, ещё не имеют сохранённого исходного DNS. Такие остатки автоматически не удаляются: по одному адресу нельзя отличить настройки VPN от пользовательских. Если DNS уже зависли от старой версии, сначала нужно один раз вернуть обычные настройки DNS, затем подключаться обновлённой версией. После этого защита сохраняет их для будущих восстановлений. При смене сетевой конфигурации (Location) незавершённая запись сохраняется до возврата к ней; новая сессия не перезаписывает её. ```sh -vpn-debug +vpn --status +vpn --status --json ``` -Если нужно самому посмотреть форму Keycloak, но оставить подстановку LDAP/TOTP: +Human output показывает активный режим: + +```text +Modules: ✅ core=ok, 🔐 mode=manual-keychain, ⏭️ bitwarden=off, ⏭️ touchid=off, ... +VPN disconnected +``` + +JSON содержит публичный mode contract: + +```json +{ + "modules": { + "mode": { + "id": "manual-keychain", + "label": "Вручную", + "ready": false, + "setup_hint": "Настройте логин и пароль: vpn --configure-login" + }, + "auth": { + "mode": "manual", + "source": "keychain", + "auto_source": "keychain", + "label": "Вручную", + "detail": "Логин и пароль с этого Mac, код вводится вручную", + "ready": false, + "setup_hint": "Настройте логин и пароль: vpn --configure-login" + } + } +} +``` + +Swift-приложение не вычисляет режим само. Оно читает `vpn --status --json` и сохраняет выбор через CLI. Унаследованные от запускающего shell переменные режима не подменяют настройку приложения. + +## Что Ставится + +| Путь | Назначение | +| --- | --- | +| `~/bin/vpn-lemanapro.sh` | основной CLI | +| `~/bin/uninstall-lemana-vpn.sh` | локальный uninstall helper | +| `~/bin/keychain-fingerprint` | optional Touch ID helper для Bitwarden | +| `~/Applications/LemanaVPN.app` | menu-bar приложение | +| `~/Library/LaunchAgents/ru.dokops.LemanaVPN.plist` | автозапуск приложения | +| `~/.config/lemana-vpn/env` | активный выбранный режим | +| `~/.config/openconnect-lite/config.toml` | профиль SSO и Keycloak autofill rules | +| `/usr/local/sbin/lemana-vpn-dns-cleanup` | root-owned DNS cleanup wrapper | +| `/var/db/lemana-vpn-dns` | Защищённый журнал исходных DNS и запись владельца VPN-сессии | +| `/etc/sudoers.d/lemana-vpn-openconnect` | `NOPASSWD` только для `openconnect` | +| `/etc/sudoers.d/lemana-vpn-dns` | `NOPASSWD` только для DNS cleanup wrapper | + +Source-of-truth профили в репозитории: + +- `templates/manual-keychain.env` +- `templates/keychain-auto.env` +- `templates/bitwarden-auto.env` + +Installer рендерит один из них в `~/.config/lemana-vpn/env`. + +## Настройки и журналы + +Пользовательские параметры находятся в `~/.config/lemana-vpn/env`. Это shell-файл: используйте простые присваивания в кавычках, без команд. Пароли и код настройки 2FA остаются в Keychain. + +| Параметр | Значение и назначение | +| --- | --- | +| `LEMANA_VPN_MODE` | `manual-keychain`, `keychain-auto` или `bitwarden-auto` | +| `LEMANA_VPN_BW_ITEM` | Имя записи Bitwarden, по умолчанию `LM LDAP` | +| `LEMANA_VPN_USE_TOUCHID` | `1` или `0`; используется в `bitwarden-auto` | +| `LEMANA_VPN_AUTO_SOURCE` | Последний источник автоматического входа: `keychain` или `bitwarden` | +| `LEMANA_VPN_LOG_MAX_BYTES` | Размер каждого файла журнала в байтах, по умолчанию `5242880` (5 МиБ) | +| `LEMANA_VPN_CONNECT_WAIT_SECONDS` | Интервал сообщения об ожидании SSO, по умолчанию `20` секунд | + +Например, чтобы уменьшить журналы до 1 МиБ на файл, добавьте или измените строку: ```sh -vpn --manual +LEMANA_VPN_LOG_MAX_BYTES="1048576" ``` -В этом режиме браузер видимый, `openconnect-lite` заполняет поля из Keychain/Bitwarden, но не нажимает submit. +Лимит — целое число от `1` до `999999999`. CLI применяет его при следующем запуске; приложение получает его из CLI при обновлении статуса. У каждого журнала в `~/Library/Logs` остаются текущий файл и один предыдущий `.1`: `LemanaVPN.log` и `LemanaVPN-openconnect-lite.log`. По умолчанию это до 20 МиБ суммарно. Ограничение действует при каждой записи, в том числе внутри долгого сеанса; от старого большого файла сохраняется только ограниченный хвост. Само изменение исходников существующие журналы не очищает — нужен обновлённый CLI и приложение. -Если нужно проверить SSO полностью вручную, без подстановки LDAP-пароля и TOTP: +Обновление сохраняет выбранный режим, логин, имя записи Bitwarden, Touch ID и дополнительные параметры в `env`. Флаги установщика имеют приоритет над переменными окружения, затем используются сохранённые значения и значения по умолчанию. Для CLI явно заданные переменные окружения режима, логина, Bitwarden, Touch ID и лимита журнала также имеют приоритет над `env`. + +Существующий `~/.config/openconnect-lite/config.toml` сохраняет адрес, профиль и правила SSO; установщик обновляет только имя пользователя в `[credentials]`. Изменения шаблона SSO при обновлении автоматически не заменяют ваш профиль. Для логина и пароля используйте `vpn --configure-login`. + +## Сохранение способа входа из CLI + +```sh +vpn --set-auth=manual # ручной ввод кода +vpn --set-auth=keychain # автоматический вход с ключом на этом Mac +vpn --set-auth=bitwarden # автоматический вход через Bitwarden +vpn --set-auth=auto # последний выбранный автоматический источник +``` + +Команды только сохраняют настройку. Пароль и секреты не считываются и не изменяются, подключение не начинается. Готовность выбранного источника и нужный шаг настройки видны в `vpn --status --json`. + +Импорт и синхронизация Bitwarden используют существующую запись Keychain `totp/`. Поэтому название «Ключ на этом Mac» обозначает место хранения, а не гарантированное происхождение ключа. + +## Совместимость + +Ранее сохранённое сочетание `manual-keychain` + `LEMANA_VPN_LOCAL_OTP=1` теперь означает **полный автоматический вход** и при обновлении переводится в `keychain-auto`. Для ручной отправки и ввода кода выберите «Вручную» или `vpn --set-auth=manual`. Явные `vpn --manual` и `vpn --manual-full` принудительно отключают автоматический код независимо от сохранённого режима. + +Старые команды оставлены как shims, чтобы существующие shell привычки не ломались: + +```sh +vpn-auto # bitwarden-auto +vpn --auto # bitwarden-auto +vpn-manual # manual-keychain +vpn --manual # manual-keychain +vpn --saved-login # manual-keychain +``` + +Диагностический fallback без подстановки полей остаётся, но это не отдельный режим установки: ```sh vpn --manual-full +vpn-manual-full ``` -Этот режим не требует заранее сохранённого LDAP username/password/TOTP и не запускает Bitwarden/Keychain preflight. +Он нужен только когда надо проверить, что проблема не в autofill. -Если установка падает на строке `install: /usr/local/sbin/...: No such file or directory`, значит на машине не было `/usr/local/sbin`. Актуальный `install.sh` создаёт эту директорию сам; достаточно повторить установку свежей командой `curl`. +## Обновление -CLI перед подключением патчит `openconnect-lite`: +Из checkout: -- `minimal` -> `offscreen`, чтобы Qt WebEngine не падал на macOS; -- добавляет `input` и `change` events для Keycloak auto-fill, сохраняя старое прямое присваивание `value = ...`; -- оставляет auto-fill в старом `ApplicationWorld` и не добавляет stateful click guards/native setters; -- добавляет URL guard, чтобы auto-fill не кликал submit на Cisco ACS; -- добавляет auth redirect patch, чтобы Python не падал на TLS reset при follow-redirect к `sslvpna/b`; -- добавляет manual submit gate для видимой ручной диагностики с auto-fill, но без auto-submit. +```sh +sh install.sh +vpn --status +open ~/Applications/LemanaVPN.app +``` + +Удалённо: + +```sh +curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh | sh +``` + +После изменения Swift app важно убедиться, что установленный бинарь обновился: + +```sh +shasum app/.build/release/LemanaVPN ~/Applications/LemanaVPN.app/Contents/MacOS/LemanaVPN +strings ~/Applications/LemanaVPN.app/Contents/MacOS/LemanaVPN | rg 'manual-keychain|bitwarden-auto|import-yandex-key' +``` ## Удаление -Рекомендуемый способ: - ```sh -curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/uninstall.sh | sh +~/bin/uninstall-lemana-vpn.sh ``` -Или локально: +Посмотреть действия без удаления: ```sh -uninstall-lemana-vpn.sh +~/bin/uninstall-lemana-vpn.sh --dry-run ``` -Что делает uninstall: +## Разработка И Проверка -- восстанавливает `openconnect-lite` из backup, если backup есть; -- удаляет `vpn-lemanapro.sh` и `uninstall-lemana-vpn.sh`; -- удаляет sudoers rules и DNS cleanup wrapper; -- удаляет блок `lemana-vpn` из `~/.zshrc`; -- удаляет `~/.config/openconnect-lite/config.toml`; -- останавливает уже запущенный процесс `LemanaVPN`, удаляет `~/Applications/LemanaVPN.app` и LaunchAgent автозапуска; -- удаляет `~/.config/lemana-vpn`, если не передан `--keep-config`. - -Опциональные режимы: +Для проверок нужен Python 3.11+ (`tomllib`), для изолированного исполнения JavaScript автозаполнения — Node.js. Браузер и настоящее подключение тесты не запускают; Node.js самому VPN не нужен. ```sh -uninstall-lemana-vpn.sh --dry-run -uninstall-lemana-vpn.sh --keep-config -uninstall-lemana-vpn.sh --keep-app -uninstall-lemana-vpn.sh --remove-keychain -uninstall-lemana-vpn.sh --remove-touchid-helper -uninstall-lemana-vpn.sh --remove-openconnect-lite +sh -n install.sh +sh -n uninstall.sh +bash -n bin/vpn-lemanapro.sh +sh -n tests/smoke.sh +tests/smoke.sh +python3 tests/configuration.py +python3 tests/logging.py +python3 tests/menu.py +python3 tests/dns_cli.py +python3 tests/dns_recovery.py +python3 tests/session_stop.py +python3 tests/connection_runtime.py +python3 tests/auth_retry.py +python3 tests/auth_settings.py +"${LEMANA_VPN_OC_PYTHON:-$HOME/.local/pipx/venvs/openconnect-lite/bin/python}" tests/yandex_import.py +swift build -c release --package-path app +git diff --check ``` -Ручной вариант, если нужен полный контроль: +Live VPN проверяется только после статуса: ```sh -rm -f ~/bin/vpn-lemanapro.sh ~/bin/uninstall-lemana-vpn.sh -rm -rf ~/.config/lemana-vpn -rm -f ~/.config/openconnect-lite/config.toml -sudo rm -f /usr/local/sbin/lemana-vpn-dns-cleanup -sudo rm -f /etc/sudoers.d/lemana-vpn-openconnect /etc/sudoers.d/lemana-vpn-dns +vpn --status ``` -Из `~/.zshrc` удалить блок: +Если VPN уже connected, connecting или reconnecting, не запускай вторую сессию. Смотри логи: ```sh -# >>> lemana-vpn -... -# <<< lemana-vpn +tail -f ~/Library/Logs/LemanaVPN-openconnect-lite.log +tail -f ~/Library/Logs/LemanaVPN.log ``` diff --git a/app/Sources/LemanaVPN/BoundedLog.swift b/app/Sources/LemanaVPN/BoundedLog.swift new file mode 100644 index 0000000..dfc7406 --- /dev/null +++ b/app/Sources/LemanaVPN/BoundedLog.swift @@ -0,0 +1,38 @@ +import Foundation + +/// One current file and one bounded archive. The owning process serializes writes. +struct BoundedLog { + let url: URL + var maxBytes = 5 * 1024 * 1024 + + func append(_ line: String) throws { + let files = FileManager.default + try files.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + if !files.fileExists(atPath: url.path) { + guard files.createFile(atPath: url.path, contents: nil, attributes: [.posixPermissions: 0o600]) else { + throw CocoaError(.fileWriteUnknown) + } + } + try files.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + let handle = try FileHandle(forUpdating: url) + defer { try? handle.close() } + let size = try handle.seekToEnd() + let data = Data(line.utf8).suffix(maxBytes) + let archive = url.appendingPathExtension("1") + var archiveSize: UInt64 = 0 + if files.fileExists(atPath: archive.path) { + try files.setAttributes([.posixPermissions: 0o600], ofItemAtPath: archive.path) + archiveSize = (try files.attributesOfItem(atPath: archive.path)[.size] as? NSNumber)?.uint64Value ?? 0 + } + if size + UInt64(data.count) > UInt64(maxBytes) || archiveSize > UInt64(maxBytes) { + try handle.seek(toOffset: size > UInt64(maxBytes) ? size - UInt64(maxBytes) : 0) + let tail = try handle.read(upToCount: maxBytes) ?? Data() + guard files.createFile(atPath: archive.path, contents: tail, attributes: [.posixPermissions: 0o600]) else { + throw CocoaError(.fileWriteUnknown) + } + try handle.truncate(atOffset: 0) + try handle.seek(toOffset: 0) + } + try handle.write(contentsOf: data) + } +} diff --git a/app/Sources/LemanaVPN/LemanaVPNApp.swift b/app/Sources/LemanaVPN/LemanaVPNApp.swift index 15ac663..40c8aa9 100644 --- a/app/Sources/LemanaVPN/LemanaVPNApp.swift +++ b/app/Sources/LemanaVPN/LemanaVPNApp.swift @@ -3,190 +3,398 @@ import SwiftUI @main struct LemanaVPNApp: App { - @StateObject private var vpnManager = VPNManager() + @NSApplicationDelegateAdaptor(LemanaAppDelegate.self) private var appDelegate + @AppStorage("menuBarIndicatorStyle") private var indicatorStyle = VPNIndicatorStyle.shield + + private var vpnManager: VPNManager { appDelegate.vpnManager } var body: some Scene { MenuBarExtra { - VPNMenuView(vpnManager: vpnManager) + VPNMenuView(vpnManager: vpnManager, indicatorStyle: $indicatorStyle) } label: { - StatusBarLabel(vpnManager: vpnManager) + StatusBarLabel(vpnManager: vpnManager, style: indicatorStyle) } + .menuBarExtraStyle(.window) + } +} + +@MainActor +final class LemanaAppDelegate: NSObject, NSApplicationDelegate { + let vpnManager = VPNManager() + private var terminationPending = false + + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + guard vpnManager.needsDisconnectBeforeQuit else { return .terminateNow } + if !terminationPending { + terminationPending = true + vpnManager.quit { [weak self] success in + self?.terminationPending = false + sender.reply(toApplicationShouldTerminate: success) + } + } + return .terminateLater + } +} + +private extension SessionTiming.Urgency { + var color: NSColor { + switch self { + case .normal: return .systemGreen + case .warning: return .systemOrange + case .critical: return .systemRed + case .unknown: return .secondaryLabelColor + } + } +} + +private extension VPNManager { + var statusIcon: VPNIcon { + if state == .disconnecting { return .connecting } + if isCheckingConnection { return .connecting } + if connectionCheckFailed { return .setup } + if isExternallyActive { return .unknown } + switch state { + case .disconnected: + if moduleStatusSystemImage == "exclamationmark.triangle" { return .setup } + if moduleStatusSystemImage == "questionmark.circle" { return .unknown } + return .off + case .unlocking: return .unlock + case .connecting: return .connecting + case .disconnecting: return .connecting + case .connected: + if !tunnelHealthy { return .network } + return sessionTiming.remaining(at: Date()) == 0 ? .expired : .connected + case .reconnecting: return .reconnect + case .error: return .error + } + } +} + +private struct VPNIconView: View { + @Environment(\.colorScheme) private var colorScheme + let icon: VPNIcon + + var body: some View { + Image(nsImage: VPNIcons.image(icon, dark: colorScheme == .dark)) + .frame(width: 16, height: 16) + .accessibilityHidden(true) } } struct StatusBarLabel: View { @ObservedObject var vpnManager: VPNManager + @Environment(\.colorScheme) private var colorScheme + let style: VPNIndicatorStyle var body: some View { HStack(spacing: 4) { - Image(systemName: iconName) - if !vpnManager.statusBarTitle.isEmpty { - Text(vpnManager.statusBarTitle) - .monospacedDigit() + if case .connected = vpnManager.state, vpnManager.tunnelHealthy { + Image(nsImage: VPNIcons.sessionImage( + style: style, + progress: vpnManager.sessionTiming.progress(at: Date()), + urgency: vpnManager.sessionTiming.urgency(at: Date()), + dark: colorScheme == .dark + )) + .renderingMode(.original) + .frame(width: 16, height: 16) + if style == .time, !vpnManager.remainingTime.isEmpty { + Text(vpnManager.remainingTime) + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .fixedSize() + } + } else { + VPNIconView(icon: vpnManager.statusIcon) } } + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityText) + .help(accessibilityText) } - private var iconName: String { + private var accessibilityText: String { + if vpnManager.state == .disconnecting { return "Lemana VPN отключается и восстанавливает DNS" } + if vpnManager.isCheckingConnection || vpnManager.connectionCheckFailed || vpnManager.isExternallyActive, + let message = vpnManager.connectionCheckMessage { return "Lemana VPN: \(message)" } switch vpnManager.state { - case .disconnected: - if vpnManager.moduleStatusSystemImage == "exclamationmark.triangle" { - return "exclamationmark.shield" - } - if vpnManager.moduleStatusSystemImage == "questionmark.circle" { - return "questionmark.circle" - } - return "shield.slash" - case .unlocking, .connecting: - return "shield.lefthalf.filled" case .connected: - return vpnManager.tunnelHealthy ? "checkmark.shield" : "exclamationmark.shield" - case .reconnecting: - return "arrow.trianglehead.2.clockwise" - case .error: - return "exclamationmark.shield" + return "Lemana VPN: \(vpnManager.tunnelHealthy ? "подключён" : "проблема соединения"), \(vpnManager.sessionTiming.remainingLabel(at: Date()).lowercased())" + case .disconnected: return "Lemana VPN отключён" + case .connecting: return "Lemana VPN подключается" + case .disconnecting: return "Lemana VPN отключается и восстанавливает DNS" + case .unlocking: return "Lemana VPN ожидает вход в Bitwarden" + case .reconnecting: return "Lemana VPN переподключается" + case .error(let message): return "Lemana VPN: \(message)" } } } struct VPNMenuView: View { @ObservedObject var vpnManager: VPNManager + @Binding var indicatorStyle: VPNIndicatorStyle var body: some View { - switch vpnManager.state { - case .disconnected: - disconnectedMenu - case .unlocking(let tier): - unlockingMenu(tier: tier) - case .connecting: - connectingMenu - case .connected(let ip, _, let remainingSec): - connectedMenu(ip: ip, remainingSec: remainingSec) - case .reconnecting(let attempt): - reconnectingMenu(attempt: attempt) - case .error(let message): - errorMenu(message: message) - } - - Divider() - Label(vpnManager.moduleSummary, systemImage: vpnManager.moduleStatusSystemImage) - .disabled(true) - Button("Обновить статус модулей") { - vpnManager.refreshStatus() - } - Button("Изменить логин и пароль") { - vpnManager.configureLogin() - } - Divider() - Button("Открыть логи") { - let logPath = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Logs/LemanaVPN.log") - NSWorkspace.shared.open(logPath) - } - Button("Выход") { - vpnManager.quit() - NSApplication.shared.terminate(nil) - } - .keyboardShortcut("q") - } - - private var disconnectedMenu: some 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() - ForEach(vpnManager.availableLaunchModes) { mode in - Button(mode.menuTitle) { - vpnManager.connect(mode: mode) + VStack(alignment: .leading, spacing: 0) { + HStack { + Text("Lemana VPN").fontWeight(.semibold) + Spacer(minLength: 8) + HStack(spacing: 5) { + VPNIconView(icon: vpnManager.statusIcon) + Text(statusTitle).font(.system(size: 11)).foregroundColor(statusColor) } } + .padding(.bottom, 10) + + connectionDetails.padding(.bottom, 9) + connectionActions.padding(.bottom, 3) + separator + authenticationSettings + if let hint = vpnManager.installSetupHint { warning(hint) } + ForEach(moduleWarnings, id: \.self) { warning($0) } + if let error = additionalError { warning(error) } + + separator + menuButton("Импорт из Яндекс Ключа…", image: .importKey) { + vpnManager.importYandexKey() + } + .help("Мастер импорта откроется в Terminal") + .disabled(!vpnManager.canConfigureCredentials) + menuButton("Логин и пароль…", image: .password) { vpnManager.configureLogin() } + .disabled(!vpnManager.canConfigureCredentials) + + separator + Text("Значок в строке меню") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .padding(.bottom, 6) + Picker("Значок в строке меню", selection: $indicatorStyle) { + ForEach(VPNIndicatorStyle.allCases) { style in Text(style.title).tag(style) } + } + .labelsHidden() + .pickerStyle(.segmented) + .controlSize(.small) + .padding(.bottom, 3) + + separator + menuButton("Открыть логи", image: .logs) { + let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Logs/LemanaVPN.log") + NSWorkspace.shared.open(url) + } + HStack { + Button { vpnManager.refreshConnectionStatus() } label: { + iconLabel("Обновить статус", icon: .refresh) + } + .disabled(vpnManager.isCheckingConnection || vpnManager.isSavingAuth) + Spacer() + Button { + NSApplication.shared.terminate(nil) + } label: { + iconLabel("Выход", icon: .quit) + } + .keyboardShortcut("q") + } + .buttonStyle(.plain) + .font(.system(size: 11)) + .foregroundColor(.secondary) + .padding(.top, 7) } + .font(.system(size: 13)) + .padding(12) + .frame(width: 298) + .background(Color(nsColor: .windowBackgroundColor)) + .onAppear { vpnManager.refreshStatus() } } - private func unlockingMenu(tier: String) -> some View { - Group { - Label("Bitwarden: \(tier)...", systemImage: "key") - .disabled(true) - Divider() - Button("Отменить") { - vpnManager.disconnect() + @ViewBuilder private var authenticationSettings: some View { + if let auth = vpnManager.moduleStatus?.auth { + Text("Способ входа") + .font(.system(size: 11)).foregroundColor(.secondary).padding(.bottom, 6) + Picker("Способ входа", selection: Binding( + get: { auth.mode }, + set: { vpnManager.setAuth($0) } + )) { + iconLabel("Вручную", icon: .manual).tag("manual") + iconLabel("Автоматически", icon: .auto).tag("auto") + } + .pickerStyle(.segmented) + .labelsHidden() + .controlSize(.small) + .disabled(!vpnManager.canSetAuth) + if auth.mode == "auto" { + Picker("Источник", selection: Binding( + get: { auth.auto_source }, + set: { vpnManager.setAuth($0) } + )) { + iconLabel("Ключ на этом Mac", icon: .local).tag("keychain") + iconLabel("Bitwarden", icon: .vault).tag("bitwarden") + } + .pickerStyle(.menu) + .controlSize(.small) + .padding(.top, 7) + .disabled(!vpnManager.canSetAuth) + } + Text(vpnManager.isSavingAuth ? "Сохраняем настройку…" : auth.detail) + .font(.system(size: 11)).foregroundColor(.secondary).fixedSize(horizontal: false, vertical: true) + .padding(.top, 6) + if vpnManager.isRunning { + Text("Изменения применятся при следующем подключении") + .font(.system(size: 11)).foregroundColor(.secondary).padding(.top, 4) + } + } else { + Text(vpnManager.installModeLabel).font(.system(size: 11)).foregroundColor(.secondary) + if !vpnManager.isCheckingConnection { + warning("Обновите CLI Lemana VPN, чтобы настраивать способ входа здесь.") } } } - private var connectingMenu: some View { - Group { - Label("Подключение...", systemImage: "circle.dotted") - .disabled(true) - Divider() - Button("Отменить") { - vpnManager.disconnect() - } - } - } - - private func connectedMenu(ip: String, remainingSec: Int) -> some View { - let hours = remainingSec / 3600 - let mins = (remainingSec % 3600) / 60 - - return Group { - Label("VPN подключён", systemImage: "circle.fill") - .disabled(true) - if !vpnManager.tunnelHealthy { - Label("Проблемы с подключением", systemImage: "exclamationmark.triangle") - .disabled(true) + @ViewBuilder private var connectionDetails: some View { + switch vpnManager.state { + case .connected(let ip, _, _): + HStack(alignment: .firstTextBaseline) { + Text(vpnManager.sessionTiming.remainingLabel(at: Date())).monospacedDigit() + Spacer(minLength: 6) + if let expiry = vpnManager.sessionTiming.expiresAt, vpnManager.sessionTiming.remaining(at: Date()) != nil { + Text("до \(expiry.formatted(date: .omitted, time: .shortened))") + .font(.system(size: 11)).foregroundColor(.secondary) + } } if !ip.isEmpty { - Text("IP: \(ip)") - .foregroundColor(.secondary) + HStack(spacing: 7) { + Text(ip).font(.system(size: 11)).foregroundColor(.secondary) + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(ip, forType: .string) + } label: { + VPNIconView(icon: .copy) + } + .buttonStyle(.plain) + .keyboardShortcut("c", modifiers: [.command]) + .accessibilityLabel("Копировать IP") + .help("Копировать IP") + } + .padding(.top, 4) } - if remainingSec > 0 { - Text("Сессия: \(hours)ч \(mins)м") - .foregroundColor(.secondary) - } - Divider() - Button("Копировать IP") { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(ip, forType: .string) - } - .keyboardShortcut("c", modifiers: [.command]) - .disabled(ip.isEmpty) - - Button("Отключить") { - vpnManager.disconnect() - } - .keyboardShortcut("d") + if !vpnManager.tunnelHealthy { warning("Проблема соединения: VPN не отвечает") } + case .disconnected: + Text(vpnManager.connectionCheckMessage ?? "Готов к подключению") + .font(.system(size: 11)).foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + case .disconnecting: + Text("Завершаем VPN и восстанавливаем DNS…").foregroundColor(.secondary) + case .unlocking: + Text("Подтвердите вход в Bitwarden").foregroundColor(.secondary) + case .connecting: + Text("Ожидаем VPN-соединение").foregroundColor(.secondary) + case .reconnecting(let attempt): + Text("Попытка \(attempt)").foregroundColor(.secondary) + case .error(let message): + warning(message) + if let checkMessage = vpnManager.connectionCheckMessage { warning(checkMessage) } } } - private func reconnectingMenu(attempt: Int) -> some View { - Group { - Label("Переподключение (\(attempt)/3)...", systemImage: "arrow.clockwise") - .disabled(true) - Divider() - Button("Отменить") { - vpnManager.disconnect() + @ViewBuilder private var connectionActions: some View { + if vpnManager.canRetryDisconnect { + Button { vpnManager.disconnect() } label: { iconLabel("Повторить отключение", icon: .disconnect).frame(maxWidth: .infinity) } + .buttonStyle(.bordered) + .controlSize(.small) + } else { + switch vpnManager.state { + case .disconnected, .error: + Button { vpnManager.connect() } label: { iconLabel("Подключить", icon: .connect).frame(maxWidth: .infinity) } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!vpnManager.canConnect) + case .connected: + Button { vpnManager.disconnect() } label: { iconLabel("Отключить", icon: .disconnect).frame(maxWidth: .infinity) } + .buttonStyle(.bordered) + .controlSize(.small) + .keyboardShortcut("d") + case .disconnecting: + Button {} label: { iconLabel("Отключение…", icon: .disconnect).frame(maxWidth: .infinity) } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(true) + case .unlocking, .connecting, .reconnecting: + Button { vpnManager.disconnect() } label: { iconLabel("Отменить", icon: .cancel).frame(maxWidth: .infinity) } + .buttonStyle(.bordered) + .controlSize(.small) } } } - private func errorMenu(message: String) -> some View { - Group { - Label("Ошибка", systemImage: "exclamationmark.triangle") - .disabled(true) - Text(message) - .foregroundColor(.secondary) - .lineLimit(3) - Divider() - Button("Подключить заново") { - vpnManager.connect() + private var separator: some View { Divider().padding(.vertical, 7) } + + private func menuButton(_ title: String, image: VPNIcon, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: 9) { + VPNIconView(icon: image).foregroundColor(.secondary) + Text(title) + Spacer(minLength: 0) } + .padding(.vertical, 6) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private func iconLabel(_ title: String, icon: VPNIcon) -> some View { + Label { Text(title) } icon: { VPNIconView(icon: icon) } + } + + private func warning(_ message: String) -> some View { + iconLabel(message, icon: .setup) + .font(.system(size: 11)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 6) + } + + private var additionalError: String? { + if case .error(let message) = vpnManager.state, message == vpnManager.lastError { return nil } + return vpnManager.lastError + } + + private var moduleWarnings: [String] { + if vpnManager.isCheckingConnection { return [] } + guard let modules = vpnManager.moduleStatus else { return ["Не удалось проверить установку. Обновите статус."] } + var warnings: [String] = [] + if !modules.core.openconnect || !modules.core.openconnect_lite || !modules.core.openconnect_lite_config { + warnings.append("Установка не завершена. Запустите установщик Lemana VPN.") + } + if modules.bitwarden.enabled && !modules.bitwarden.installed { warnings.append("Bitwarden не установлен.") } + if modules.touchid.enabled && !modules.touchid.installed { warnings.append("Touch ID не настроен.") } + if !modules.dns_cleanup.installed { warnings.append("Очистка DNS не настроена. Обновите установку.") } + if !modules.patches.active { warnings.append("Подготовка SSO завершится при следующем подключении.") } + if modules.app?.installed == false { warnings.append("Приложение не установлено. Запустите установщик Lemana VPN.") } + return warnings + } + + private var statusTitle: String { + if vpnManager.state == .disconnecting { return "Отключение" } + if vpnManager.isCheckingConnection { return "Проверка" } + if vpnManager.connectionCheckFailed { return "Нужна проверка" } + if vpnManager.isExternallyActive { return "VPN ещё запущен" } + switch vpnManager.state { + case .disconnected: return "Отключён" + case .disconnecting: return "Отключение" + case .unlocking: return "Ожидаем вход" + case .connecting: return "Подключение" + case .connected: + if !vpnManager.tunnelHealthy { return "Проблема сети" } + return vpnManager.sessionTiming.remaining(at: Date()) == 0 ? "Срок истёк" : "Подключён" + case .reconnecting: return "Переподключение" + case .error: return "Ошибка" + } + } + + private var statusColor: Color { + if vpnManager.connectionCheckFailed { return .orange } + switch vpnManager.state { + case .connected: + return vpnManager.tunnelHealthy ? Color(nsColor: vpnManager.sessionTiming.urgency(at: Date()).color) : .red + case .error: return .red + default: return .secondary } } } diff --git a/app/Sources/LemanaVPN/SessionTiming.swift b/app/Sources/LemanaVPN/SessionTiming.swift new file mode 100644 index 0000000..8d9dda9 --- /dev/null +++ b/app/Sources/LemanaVPN/SessionTiming.swift @@ -0,0 +1,71 @@ +import Foundation + +enum VPNIndicatorStyle: String, CaseIterable, Identifiable { + case shield, ring, clock, time + + var id: String { rawValue } + var title: String { + switch self { + case .shield: return "Щит" + case .ring: return "Кольцо" + case .clock: return "Часы" + case .time: return "Время" + } + } +} + +struct SessionTiming { + enum Urgency { case normal, warning, critical, unknown } + + private(set) var startedAt: Date? + private(set) var expiresAt: Date? + + private var duration: TimeInterval? { + guard let start = startedAt, let end = expiresAt, end > start else { return nil } + return end.timeIntervalSince(start) + } + + mutating func observe(startedAt: String?, expires: String?, now: Date = Date()) { + let formatter = ISO8601DateFormatter() + if let start = startedAt.flatMap({ formatter.date(from: $0) }) { + // The CLI timestamp is authoritative, including after a legacy fallback. + self.startedAt = start + } else if self.startedAt == nil { + self.startedAt = now + } + if let expires = expires { + expiresAt = formatter.date(from: expires) + } + } + + func remaining(at now: Date) -> TimeInterval? { + guard duration != nil, let end = expiresAt else { return nil } + return max(0, end.timeIntervalSince(now)) + } + + func progress(at now: Date) -> Double? { + guard let duration = duration, let start = startedAt else { return nil } + return min(1, max(0, now.timeIntervalSince(start) / duration)) + } + + func urgency(at now: Date) -> Urgency { + guard let remaining = remaining(at: now) else { return .unknown } + if remaining <= 15 * 60 { return .critical } + if remaining <= 60 * 60 { return .warning } + return .normal + } + + func compactRemaining(at now: Date) -> String? { + guard let seconds = remaining(at: now) else { return nil } + let minutes = Int(ceil(seconds / 60)) + return String(format: "%d:%02d", minutes / 60, minutes % 60) + } + + func remainingLabel(at now: Date) -> String { + guard let seconds = remaining(at: now) else { return "Срок сессии неизвестен" } + if seconds <= 0 { return "Время сессии истекло" } + let minutes = Int(ceil(seconds / 60)) + if minutes < 60 { return "Осталось \(minutes) мин" } + return "Осталось \(minutes / 60) ч \(minutes % 60) мин" + } +} diff --git a/app/Sources/LemanaVPN/VPNIcons.swift b/app/Sources/LemanaVPN/VPNIcons.swift new file mode 100644 index 0000000..870a6f5 --- /dev/null +++ b/app/Sources/LemanaVPN/VPNIcons.swift @@ -0,0 +1,215 @@ +import AppKit + +enum VPNIcon: CaseIterable { + case off, connecting, unlock, connected, reconnect, network, error, setup, unknown, noTime, expired, clock + case connect, disconnect, cancel, importKey, password, copy, logs, refresh, quit, manual, auto, local, vault + + var isAction: Bool { + switch self { + case .connect, .disconnect, .cancel, .importKey, .password, .copy, .logs, .refresh, .quit, .manual, .auto, .local, .vault: return true + default: return false + } + } +} + +enum VPNIcons { + // Geometry and palettes from the approved artifacts/icon-family-v1/draw_icons.py. + static let shield: CGPath = { + let p = CGMutablePath() + p.move(to: CGPoint(x: 12, y: 2)) + p.addCurve(to: CGPoint(x: 20, y: 5), control1: CGPoint(x: 14.6, y: 3.7), control2: CGPoint(x: 17.3, y: 4.7)) + p.addLine(to: CGPoint(x: 20, y: 11.2)) + p.addCurve(to: CGPoint(x: 12, y: 22), control1: CGPoint(x: 20, y: 16.1), control2: CGPoint(x: 16.7, y: 19.6)) + p.addCurve(to: CGPoint(x: 4, y: 11.2), control1: CGPoint(x: 7.3, y: 19.6), control2: CGPoint(x: 4, y: 16.1)) + p.addLine(to: CGPoint(x: 4, y: 5)) + p.addCurve(to: CGPoint(x: 12, y: 2), control1: CGPoint(x: 6.7, y: 4.7), control2: CGPoint(x: 9.4, y: 3.7)) + p.closeSubpath() + return p + }() + + struct Palette { + let ink, muted, green, amber, red: NSColor + + init(dark: Bool) { + func color(_ hex: UInt32) -> NSColor { + NSColor(srgbRed: CGFloat((hex >> 16) & 255) / 255, green: CGFloat((hex >> 8) & 255) / 255, blue: CGFloat(hex & 255) / 255, alpha: 1) + } + ink = color(dark ? 0xE5EAF0 : 0x27333E) + muted = color(dark ? 0xACB7C3 : 0x657381) + green = color(dark ? 0x78CAA2 : 0x278055) + amber = color(dark ? 0xF0BB68 : 0xA56A13) + red = color(dark ? 0xF58C95 : 0xBE4149) + } + + func color(_ urgency: SessionTiming.Urgency) -> NSColor { + switch urgency { + case .normal: return green + case .warning: return amber + case .critical: return red + case .unknown: return muted + } + } + } + + static func image(_ icon: VPNIcon, dark: Bool) -> NSImage { + let result = image { draw(icon, in: $0, dark: dark) } + result.isTemplate = icon.isAction || [.off, .connecting, .unlock, .reconnect, .unknown, .noTime].contains(icon) + return result + } + + static func sessionImage(style: VPNIndicatorStyle, progress: Double?, urgency: SessionTiming.Urgency, dark: Bool) -> NSImage { + image { drawSession(style: style, progress: progress, urgency: urgency, in: $0, dark: dark) } + } + + private static func image(_ draw: @escaping (CGContext) -> Void) -> NSImage { + NSImage(size: NSSize(width: 16, height: 16), flipped: true) { _ in + guard let context = NSGraphicsContext.current?.cgContext else { return false } + context.saveGState() + context.scaleBy(x: 16 / 24, y: 16 / 24) + draw(context) + context.restoreGState() + return true + } + } + + static func draw(_ icon: VPNIcon, in context: CGContext, dark: Bool) { + let palette = Palette(dark: dark) + let color: NSColor + switch icon { + case .connected, .clock: color = palette.green + case .network, .setup: color = palette.amber + case .error, .expired: color = palette.red + default: color = palette.ink + } + context.saveGState() + if !icon.isAction { stroke(shield, in: context, color: palette.ink) } + stroke(mark(icon), in: context, color: color) + context.setFillColor(color.cgColor) + if icon == .connecting { + for x in [9.0, 12.0, 15.0] { context.fillEllipse(in: CGRect(x: x - 0.7, y: 11.3, width: 1.4, height: 1.4)) } + } else if icon == .setup || icon == .unknown { + context.fillEllipse(in: CGRect(x: 11.35, y: (icon == .setup ? 15.5 : 15.9) - 0.65, width: 1.3, height: 1.3)) + } + context.restoreGState() + } + + static func drawSession(style: VPNIndicatorStyle, progress: Double?, urgency: SessionTiming.Urgency, in context: CGContext, dark: Bool) { + guard let value = progress, value.isFinite else { + draw(.noTime, in: context, dark: dark) + return + } + let progress = min(1, max(0, value)) + let remaining = 1 - progress + let palette = Palette(dark: dark) + let accent = palette.color(urgency) + if style == .time { + stroke(shield, in: context, color: palette.ink) + stroke(mark(progress == 1 ? .expired : .connected), in: context, color: accent) + return + } + if style == .clock { + stroke(shield, in: context, color: palette.ink) + if progress == 1 { + stroke(mark(.expired), in: context, color: accent) + } else { + let hand = CGMutablePath() + hand.move(to: CGPoint(x: 12, y: 7.5)) + hand.addLine(to: CGPoint(x: 12, y: 12)) + let angle = progress * .pi * 2 + hand.addLine(to: CGPoint(x: 12 + sin(angle) * 4.5, y: 12 - cos(angle) * 4.5)) + stroke(hand, in: context, color: accent) + } + return + } + let ring = style == .ring + let shape = ring ? CGPath(ellipseIn: CGRect(x: 3, y: 3, width: 18, height: 18), transform: nil) : shield + let height: CGFloat = ring ? 18 : 20 + let bottom: CGFloat = ring ? 21 : 22 + context.saveGState() + context.addPath(shape) + context.clip() + context.setFillColor(palette.muted.withAlphaComponent(0.30).cgColor) + context.fill(CGRect(x: 0, y: 0, width: 24, height: 24)) + context.setFillColor(accent.withAlphaComponent(0.80).cgColor) + context.fill(CGRect(x: 0, y: bottom - height * remaining, width: 24, height: height * remaining)) + context.restoreGState() + stroke(shape, in: context, color: palette.muted.withAlphaComponent(ring ? 0.65 : 0.68)) + if remaining > 0 { + context.saveGState() + context.setLineCap(.butt) + context.setLineWidth(1.75) + context.setLineJoin(.round) + context.setStrokeColor(accent.cgColor) + if ring { + context.addArc(center: CGPoint(x: 12, y: 12), radius: 9, startAngle: -.pi / 2, endAngle: -.pi / 2 + remaining * .pi * 2, clockwise: false) + } else { + context.setLineDash(phase: 0, lengths: [remaining * 58.04152102, 58.04152102]) + context.addPath(shape) + } + context.strokePath() + context.restoreGState() + } + if progress == 1 { stroke(mark(.expired), in: context, color: palette.ink) } + } + + private static func stroke(_ path: CGPath, in context: CGContext, color: NSColor) { + context.setLineWidth(1.75) + context.setLineCap(.round) + context.setLineJoin(.round) + context.setStrokeColor(color.cgColor) + context.addPath(path) + context.strokePath() + } + + private static func mark(_ icon: VPNIcon) -> CGPath { + let p = CGMutablePath() + func m(_ x: CGFloat, _ y: CGFloat) { p.move(to: CGPoint(x: x, y: y)) } + func l(_ x: CGFloat, _ y: CGFloat) { p.addLine(to: CGPoint(x: x, y: y)) } + func c(_ x1: CGFloat, _ y1: CGFloat, _ x2: CGFloat, _ y2: CGFloat, _ x: CGFloat, _ y: CGFloat) { + p.addCurve(to: CGPoint(x: x, y: y), control1: CGPoint(x: x1, y: y1), control2: CGPoint(x: x2, y: y2)) + } + func circle(_ x: CGFloat, _ y: CGFloat, _ r: CGFloat) { p.addEllipse(in: CGRect(x: x - r, y: y - r, width: 2 * r, height: 2 * r)) } + func rect(_ x: CGFloat, _ y: CGFloat, _ w: CGFloat, _ h: CGFloat, _ r: CGFloat) { + p.addRoundedRect(in: CGRect(x: x, y: y, width: w, height: h), cornerWidth: r, cornerHeight: r) + } + switch icon { + case .off: m(8.7, 8.7); l(15.3, 15.3) + case .connecting: break + case .unlock: circle(10, 10.5, 1.8); m(11.4, 11.9); l(15.4, 15.9); m(13.5, 14); l(15, 12.5) + case .connected: m(7.8, 11.8); l(10.8, 14.7); l(16.2, 9.3) + case .reconnect: + p.addArc(center: CGPoint(x: 12.107508, y: 12.15), radius: 4, startAngle: -0.509224, endAngle: 0.509224, clockwise: true) + m(15.6, 7.3); l(15.6, 10.2); l(12.7, 10.2) + case .network: + m(10, 10); l(9, 10); p.addArc(center: CGPoint(x: 9, y: 12), radius: 2, startAngle: -.pi / 2, endAngle: -.pi * 1.5, clockwise: true); l(10, 14) + m(14, 10); l(15, 10); p.addArc(center: CGPoint(x: 15, y: 12), radius: 2, startAngle: -.pi / 2, endAngle: .pi / 2, clockwise: false); l(14, 14) + case .error: m(9, 9); l(15, 15); m(15, 9); l(9, 15) + case .setup: m(12, 7.8); l(12, 12.3) + case .unknown: m(9.7, 9.2); c(9.8, 6.7, 14.6, 6.7, 14.6, 9.4); c(14.6, 11.3, 12, 11.3, 12, 13) + case .expired: m(9, 8); l(15, 8); m(9, 16); l(15, 16); m(9.7, 8); c(9.7, 10.3, 14.3, 13.7, 14.3, 16); m(14.3, 8); c(14.3, 10.3, 9.7, 13.7, 9.7, 16) + case .noTime: m(9, 12); l(15, 12) + case .clock: m(12, 7.5); l(12, 12); l(15, 13.7) + case .connect: + m(8, 3); l(8, 8); m(16, 3); l(16, 8); m(6, 8); l(18, 8); l(18, 11); c(18, 14.3, 15.3, 17, 12, 17); c(8.7, 17, 6, 14.3, 6, 11); p.closeSubpath(); m(12, 17); l(12, 21) + case .disconnect: + m(8, 2.5); l(8, 6); m(16, 2.5); l(16, 6); m(6, 6); l(18, 6); l(18, 8); c(18, 10.2, 15.3, 11.5, 12, 11.5); c(8.7, 11.5, 6, 10.2, 6, 8); p.closeSubpath() + m(7.5, 15.5); p.addQuadCurve(to: CGPoint(x: 16.5, y: 15.5), control: CGPoint(x: 12, y: 19.5)); m(12, 17.5); l(12, 21.5) + case .cancel: m(6, 6); l(18, 18); m(18, 6); l(6, 18) + case .importKey: m(5, 13); l(5, 20); l(19, 20); l(19, 13); m(12, 3); l(12, 15); m(8, 11); l(12, 15); l(16, 11) + case .password: circle(8, 8, 4); m(11, 11); l(20, 20); m(15, 15); l(17, 13); m(18, 18); l(20, 16) + case .copy: m(9, 4); l(19, 4); l(19, 15); m(5, 8); l(15, 8); l(15, 21); l(5, 21); p.closeSubpath() + case .logs: m(6, 3); l(14, 3); l(19, 8); l(19, 21); l(6, 21); p.closeSubpath(); m(14, 3); l(14, 8); l(19, 8); m(9, 12); l(16, 12); m(9, 16); l(16, 16) + case .refresh: + p.addArc(center: CGPoint(x: 12.174321, y: 11.291312), radius: 7.2, startAngle: -0.323870, endAngle: -2.413483, clockwise: true) + l(4, 9); m(4, 4); l(4, 9); l(9, 9) + m(5, 15); p.addArc(center: CGPoint(x: 11.825679, y: 12.708688), radius: 7.2, startAngle: 2.817723, endAngle: 0.728110, clockwise: true) + l(20, 15); m(20, 20); l(20, 15); l(15, 15) + case .quit: m(11, 3); l(4, 3); l(4, 21); l(11, 21); m(10, 12); l(21, 12); m(17, 8); l(21, 12); l(17, 16) + case .manual: rect(3, 6, 18, 12, 2); m(6.5, 10); l(7, 10); m(11.5, 10); l(12, 10); m(16.5, 10); l(17, 10); m(7, 14); l(17, 14) + case .auto: m(13, 3); l(6, 13); l(11, 13); l(10, 21); l(18, 10); l(13, 10); p.closeSubpath() + case .local: rect(5, 4, 14, 12, 1.5); m(2.5, 19); l(21.5, 19); l(20, 21); l(4, 21); p.closeSubpath() + case .vault: rect(4.5, 3, 15, 18, 2.5); circle(12, 12, 3.8); m(12, 8.2); l(12, 10); m(15.8, 12); l(14, 12); m(12, 15.8); l(12, 14); m(8.2, 12); l(10, 12) + } + return p + } +} diff --git a/app/Sources/LemanaVPN/VPNManager.swift b/app/Sources/LemanaVPN/VPNManager.swift index ed5cd5d..858c058 100644 --- a/app/Sources/LemanaVPN/VPNManager.swift +++ b/app/Sources/LemanaVPN/VPNManager.swift @@ -5,16 +5,20 @@ struct VPNEvent: Decodable { let event: String var ip: String? var expires: String? + var started_at: String? + var attempt_id: String? var remaining_sec: Int? var attempt: Int? var delay: Int? var reason: String? var message: String? var source: String? + var mode: String? var profile: String? var auth_profile: String? var tier: Int? var modules: ModuleStatus? + var result: String? } struct VPNStatusResponse: Decodable { @@ -22,6 +26,26 @@ struct VPNStatusResponse: Decodable { } struct ModuleStatus: Decodable { + struct Auth: Decodable { + var mode: String + var source: String + var auto_source: String + var label: String + var detail: String + var ready: Bool + var setup_hint: String? + } + struct Logging: Decodable { + var max_bytes: Int + } + + struct Mode: Decodable { + var id: String + var label: String + var ready: Bool + var setup_hint: String? + } + struct Core: Decodable { var openconnect: Bool var openconnect_lite: Bool @@ -46,6 +70,7 @@ struct ModuleStatus: Decodable { var configured_mode: String var available_modes: [String] var setup_hint: String? + } struct Keychain: Decodable { @@ -55,6 +80,8 @@ struct ModuleStatus: Decodable { struct DnsCleanup: Decodable { var installed: Bool + var recovery_supported: Bool? + var stop_supported: Bool? } struct Patches: Decodable { @@ -68,6 +95,7 @@ struct ModuleStatus: Decodable { } var core: Core + var mode: Mode? var credentials: Credentials? var app_menu: AppMenu? var bitwarden: ToggleModule @@ -76,20 +104,26 @@ struct ModuleStatus: Decodable { var dns_cleanup: DnsCleanup var patches: Patches var app: AppModule? + var logging: Logging? + var auth: Auth? var hasWarnings: Bool { let coreReady = core.openconnect && core.openconnect_lite && core.openconnect_lite_config let bitwardenReady = !bitwarden.enabled || bitwarden.installed let touchReady = !touchid.enabled || touchid.installed - let profile = credentials?.auth_profile ?? "manual" + let modeId = mode?.id ?? credentials?.auth_profile ?? "manual-keychain" let keychainReady: Bool - switch profile { - case "manual": - keychainReady = true - case "saved-login": - keychainReady = keychain.password - default: - keychainReady = keychain.password && keychain.totp_seed + if let auth = auth { + keychainReady = auth.ready + } else { + switch modeId { + case "manual-keychain", "saved-login": + keychainReady = mode?.ready ?? keychain.password + case "keychain-auto", "bitwarden-auto", "auto", "saved-totp": + keychainReady = keychain.password && keychain.totp_seed + default: + keychainReady = false + } } let appReady = app?.installed ?? true @@ -106,31 +140,12 @@ struct ModuleStatus: Decodable { hasWarnings ? "exclamationmark.triangle" : "checkmark.circle" } - var summary: String { - let coreState = core.openconnect && core.openconnect_lite && core.openconnect_lite_config ? "✅ core" : "⚠️ core" - 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 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: " | ") - } + } enum VPNState: Equatable { case disconnected + case disconnecting case unlocking(tier: String) case connecting case connected(ip: String, expiresAt: Date?, remainingSec: Int) @@ -140,6 +155,7 @@ enum VPNState: Equatable { var label: String { switch self { case .disconnected: return "disconnected" + case .disconnecting: return "disconnecting" case .unlocking(let t): return "unlocking(\(t))" case .connecting: return "connecting" case .connected(let ip, _, let r): return "connected(ip=\(ip), remaining=\(r)s)" @@ -151,6 +167,9 @@ enum VPNState: Equatable { enum VPNLaunchMode: String, Identifiable { case configured = "configured" + case manualKeychain = "manual-keychain" + case bitwardenAuto = "bitwarden-auto" + // Hidden compatibility fallback for older CLI status responses. case auto = "auto" case savedLogin = "saved-login" case manual = "saved-totp" @@ -161,6 +180,8 @@ enum VPNLaunchMode: String, Identifiable { var cliArgument: String? { switch self { case .configured: return nil + case .manualKeychain: return "manual-keychain" + case .bitwardenAuto: return "bitwarden-auto" case .auto: return "--auto" case .savedLogin: return "--saved-login" case .manual: return "--manual" @@ -168,14 +189,22 @@ enum VPNLaunchMode: String, Identifiable { } } - var menuTitle: String { - switch self { - case .configured: return "Подключить" - case .auto: return "Подключить автоматически" - case .savedLogin: return "Подставить только логин и пароль" - case .manual: return "Подключить с сохранёнными данными" - case .manualFull: return "Подключить полностью вручную" + +} + +struct VPNLineBuffer { + private var pending = Data() + + mutating func append(_ data: Data) -> [String] { + pending.append(data) + var lines: [String] = [] + while let end = pending.firstIndex(of: 10) { + if let line = String(data: pending[..? + private var quitCompletion: ((Bool) -> Void)? + private let disconnectTimeout: TimeInterval + private var terminalError: String? + private var currentAttemptID: String? + private var healthCheckGeneration = 0 private var consecutiveHealthFailures: Int = 0 - private var currentLaunchMode: VPNLaunchMode = .configured + private var fileLog: BoundedLog + private var logWriteFailed = false private let healthCheckInterval: TimeInterval = 10 - private let maxAutoReconnectAttempts: Int = 3 - private let maxHealthFailuresBeforeKill: Int = 3 - private let autoReconnectDelay: TimeInterval = 5 + private let maxHealthFailuresBeforeRestart: Int = 3 - private let scriptPath: String = { - let home = FileManager.default.homeDirectoryForCurrentUser.path - return "\(home)/bin/vpn-lemanapro.sh" - }() + private let scriptPath: String - init() { - refreshStatus() + init(scriptPath: String? = nil, logURL: URL? = nil, disconnectTimeout: TimeInterval = 45) { + let home = FileManager.default.homeDirectoryForCurrentUser + self.scriptPath = scriptPath ?? home.appendingPathComponent("bin/vpn-lemanapro.sh").path + self.fileLog = BoundedLog(url: logURL ?? home.appendingPathComponent("Library/Logs/LemanaVPN.log")) + self.disconnectTimeout = disconnectTimeout + refreshConnectionStatus() } var isRunning: Bool { process?.isRunning ?? false } - var remainingTime: String { - guard let expires = sessionExpiresAt else { return "" } - let remaining = Int(expires.timeIntervalSinceNow) - guard remaining > 0 else { return "expired" } - let h = remaining / 3600 - let m = (remaining % 3600) / 60 - return String(format: "%d:%02d", h, m) + var canSetAuth: Bool { + guard canConfigureCredentials, moduleStatus?.auth != nil else { return false } + switch state { + case .connecting, .unlocking, .reconnecting, .disconnecting: return false + default: return true + } } - var statusBarTitle: String { - switch state { - case .connected: - return remainingTime - default: - return "" + var canConnect: Bool { + canConfigureCredentials && !isRunning && !userInitiatedDisconnect && moduleStatus != nil && moduleStatus?.auth?.ready != false + } + + var needsDisconnectBeforeQuit: Bool { + if case .reconnecting = state { return true } + return isRunning || sessionReady || userInitiatedDisconnect || state == .disconnecting + } + var canRetryDisconnect: Bool { userInitiatedDisconnect && disconnectDeadline == nil && !stopInFlight } + + var canConfigureCredentials: Bool { + connectionReady && !isCheckingConnection && !isExternallyActive && !isSavingAuth && state != .disconnecting + } + + func refreshConnectionStatus() { + guard !isCheckingConnection, !isSavingAuth, !stopInFlight else { return } + if isRunning { + refreshStatus() + return } + if case .reconnecting = state { + refreshStatus() + return + } + isCheckingConnection = true + let epoch = connectionCheckEpoch + connectionReady = false + connectionCheckFailed = false + isExternallyActive = false + connectionCheckMessage = "Проверяю подключение и DNS…" + refreshStatus { [weak self] succeeded in + guard let self = self else { return } + guard epoch == self.connectionCheckEpoch else { + self.isCheckingConnection = false + if !self.stopInFlight && !self.isRunning { self.refreshConnectionStatus() } + return + } + guard succeeded else { + self.finishConnectionCheck(error: "Не удалось проверить VPN. Обновите статус; если ошибка повторится, выполните sh install.sh.") + return + } + guard self.moduleStatus?.dns_cleanup.recovery_supported == true else { + self.finishConnectionCheck(error: "Для восстановления DNS обновите Lemana VPN: sh install.sh.") + return + } + guard self.moduleStatus?.dns_cleanup.stop_supported == true else { + self.finishConnectionCheck(error: "Для безопасного отключения обновите Lemana VPN: sh install.sh.") + return + } + self.recoverDNS() + } + } + + private func finishConnectionCheck(error message: String) { + if state == .disconnecting { state = .disconnected } + isCheckingConnection = false + connectionReady = false + connectionCheckFailed = true + connectionCheckMessage = message + log("[dns] \(message)") + if userInitiatedDisconnect || quitCompletion != nil { finishDisconnect(error: message) } + } + + private func recoverDNS() { + let epoch = connectionCheckEpoch + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/bin/bash") + proc.arguments = [scriptPath, "--repair-dns", "--json"] + proc.environment = processEnvironment() + proc.standardInput = FileHandle.nullDevice + proc.standardError = FileHandle.nullDevice + let pipe = Pipe() + proc.standardOutput = pipe + proc.terminationHandler = { [weak self] process in + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let response = try? JSONDecoder().decode(VPNEvent.self, from: data) + Task { @MainActor in + guard let self = self else { return } + // A check started before Stop cannot complete the pending quit. + if self.stopInFlight || epoch != self.connectionCheckEpoch { + self.isCheckingConnection = false + if !self.stopInFlight && !self.isRunning { self.refreshConnectionStatus() } + return + } + guard response?.event == "dns_recovery", process.terminationStatus == 0, + let result = response?.result, ["restored", "unchanged", "no_snapshot", "active"].contains(result) else { + let detail = response?.event == "dns_recovery" && response?.result == "error" ? response?.message : nil + self.finishConnectionCheck(error: detail?.isEmpty == false ? detail! : "Не удалось восстановить DNS. Обновите статус; если ошибка повторится, выполните sh install.sh.") + return + } + self.isCheckingConnection = false + if self.state == .disconnecting { self.state = .disconnected } + self.isExternallyActive = result == "active" + self.connectionReady = result != "active" + self.connectionCheckMessage = result == "active" + ? "Процессы VPN ещё работают. DNS пока не восстановлены. Дождитесь завершения и обновите статус." + : (result == "restored" ? "Исходные DNS восстановлены." : nil) + self.log("[dns] recovery result: \(result)") + if self.userInitiatedDisconnect || self.quitCompletion != nil { + self.finishDisconnect(error: result == "active" ? "Процессы VPN ещё работают. Повторите отключение после их завершения." : nil) + } else if result != "active" { + self.sessionReady = false + self.sessionID = nil + } + } + } + do { + try proc.run() + } catch { + finishConnectionCheck(error: "Не удалось запустить восстановление DNS. Обновите Lemana VPN: sh install.sh.") + } + } + + var remainingTime: String { + sessionTiming.compactRemaining(at: Date()) ?? "" } var installModeLabel: String { @@ -243,6 +395,7 @@ class VPNManager: ObservableObject { } var installSetupHint: String? { + if let auth = moduleStatus?.auth { return auth.setup_hint } if let hint = moduleStatus?.app_menu?.setup_hint { return hint } @@ -252,33 +405,37 @@ class VPNManager: ObservableObject { 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() { + func refreshStatus(completion: ((Bool) -> Void)? = nil) { + guard (!isSavingAuth && !isCheckingConnection) || completion != nil else { return } + statusGeneration += 1 + let generation = statusGeneration let proc = Process() proc.executableURL = URL(fileURLWithPath: "/bin/bash") - proc.arguments = ["-l", scriptPath, "--status", "--json"] + proc.arguments = [scriptPath, "--status", "--json"] proc.environment = processEnvironment() + proc.standardInput = FileHandle.nullDevice let pipe = Pipe() proc.standardOutput = pipe proc.standardError = FileHandle.nullDevice - proc.terminationHandler = { [weak self] _ in + proc.terminationHandler = { [weak self] process in let data = pipe.fileHandleForReading.readDataToEndOfFile() guard let self = self else { return } let text = String(data: data, encoding: .utf8) ?? "" let lastLine = text.split(separator: "\n").map(String.init).last ?? "" Task { @MainActor in + var succeeded = false + defer { completion?(succeeded) } + guard generation == self.statusGeneration else { return } + guard process.terminationStatus == 0 else { + self.moduleStatusSystemImage = "exclamationmark.triangle" + self.moduleStatus = nil + self.log("[modules] status exited with code \(process.terminationStatus)") + return + } 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") @@ -289,7 +446,6 @@ class VPNManager: ObservableObject { do { response = try JSONDecoder().decode(VPNStatusResponse.self, from: jsonData) } catch { - self.moduleSummary = "modules unavailable" self.moduleStatusSystemImage = "exclamationmark.triangle" self.moduleStatus = nil let compact = text.replacingOccurrences(of: "\n", with: "\\n") @@ -299,45 +455,81 @@ 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 + succeeded = true } } do { try proc.run() } catch { + moduleStatus = nil + moduleStatusSystemImage = "exclamationmark.triangle" log("[modules] status refresh failed: \(error.localizedDescription)") + completion?(false) + } + } + + func setAuth(_ choice: String) { + guard canSetAuth, ["manual", "auto", "keychain", "bitwarden"].contains(choice) else { return } + isSavingAuth = true + statusGeneration += 1 // Ignore refreshes started before the settings write. + lastError = nil + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/bin/bash") + proc.arguments = [scriptPath, "--set-auth=\(choice)"] + proc.environment = processEnvironment() + proc.standardOutput = FileHandle.nullDevice + proc.standardError = FileHandle.nullDevice + proc.terminationHandler = { [weak self] process in + Task { @MainActor in + guard let self = self else { return } + if process.terminationStatus != 0 { + self.isSavingAuth = false + self.lastError = "Не удалось сохранить способ входа. Проверьте установку Lemana VPN." + return + } + self.refreshStatus { succeeded in + self.isSavingAuth = false + if !succeeded { self.lastError = "Способ входа сохранён. Обновите статус, чтобы увидеть настройку." } + } + } + } + do { + try proc.run() + } catch { + isSavingAuth = false + lastError = "Не удалось запустить настройку способа входа. Обновите установку." } } func connect(mode: VPNLaunchMode = .configured) { - guard !isRunning else { - log("connect() called but process already running") + guard canConnect else { + log("connect() skipped until connection checks and settings are ready") return } - currentLaunchMode = mode log("-- VPN connect requested (\(mode.rawValue)) --") refreshStatus() state = .connecting + terminalError = nil lastError = nil - sessionExpiresAt = nil + sessionTiming = SessionTiming() + outputBuffer = VPNLineBuffer() currentIP = "" userInitiatedDisconnect = false - reconnectTimer?.invalidate() - reconnectTimer = nil + sessionID = UUID().uuidString.lowercased() + sessionReady = false let proc = Process() proc.executableURL = URL(fileURLWithPath: "/bin/bash") - var arguments = ["-l", scriptPath, "--json"] + var arguments = [scriptPath, "--json", "--session-id=\(sessionID!)"] if let cliArgument = mode.cliArgument { arguments.append(cliArgument) } @@ -354,18 +546,16 @@ class VPNManager: ObservableObject { self.outputPipe = stdoutPipe self.stderrPipe = errPipe - stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self, weak proc] handle in let data = handle.availableData guard !data.isEmpty else { handle.readabilityHandler = nil return } - if let text = String(data: data, encoding: .utf8) { - let lines = text.split(separator: "\n").map(String.init) - Task { @MainActor in - for jsonLine in lines { - self?.parseEvent(jsonLine) - } + Task { @MainActor in + guard let self = self, let proc = proc, self.process === proc else { return } + for jsonLine in self.outputBuffer.append(data) { + self.parseEvent(jsonLine) } } } @@ -388,6 +578,7 @@ class VPNManager: ObservableObject { proc.terminationHandler = { [weak self] proc in Task { @MainActor in + guard self?.process === proc else { return } self?.handleTermination(exitCode: proc.terminationStatus) } } @@ -402,30 +593,101 @@ class VPNManager: ObservableObject { } func disconnect() { - guard let proc = process, proc.isRunning else { - log("disconnect() called but no running process") - reconnectTimer?.invalidate() - reconnectTimer = nil - if case .reconnecting = state { state = .disconnected } - return - } + guard disconnectDeadline == nil, !stopInFlight else { return } userInitiatedDisconnect = true - log("-- VPN disconnect requested (SIGINT -> pid \(proc.processIdentifier)) --") - proc.interrupt() + connectionCheckEpoch += 1 + connectionReady = false + lastError = nil + state = .disconnecting + stopTimer() + stopHealthCheck() + log("-- VPN session stop requested --") + disconnectDeadline = Task { [weak self] in + guard let self = self else { return } + do { try await Task.sleep(nanoseconds: UInt64(self.disconnectTimeout * 1_000_000_000)) } + catch { return } + let pendingStop = self.stopProcess + self.stopProcess = nil + self.stopInFlight = false + if pendingStop?.isRunning == true { pendingStop?.terminate() } + self.finishDisconnect(error: "Отключение не завершилось. Приложение осталось открытым; повторите отключение.") + } + if sessionReady { stopSession() } + else if !isRunning { refreshConnectionStatus() } + // If launch is still registering, session_ready will perform the pending stop. + } + + private func stopSession() { + guard !stopInFlight, let sessionID = sessionID else { return } + stopInFlight = true + let proc = Process() + stopProcess = proc + proc.executableURL = URL(fileURLWithPath: "/bin/bash") + proc.arguments = [scriptPath, "--stop-session=\(sessionID)", "--json"] + proc.environment = processEnvironment() + proc.standardInput = FileHandle.nullDevice + proc.standardError = FileHandle.nullDevice + let pipe = Pipe() + proc.standardOutput = pipe + proc.terminationHandler = { [weak self] process in + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let event = try? JSONDecoder().decode(VPNEvent.self, from: data) + Task { @MainActor in + guard let self = self, self.sessionID == sessionID, self.stopProcess === process else { return } + self.stopProcess = nil + self.stopInFlight = false + guard process.terminationStatus == 0, event?.event == "session_stop", event?.result == "stopping" else { + self.finishDisconnect(error: "Не удалось остановить нашу VPN-сессию. Проверьте установку: sh install.sh; затем повторите отключение.") + return + } + self.log("[disconnect] session stop acknowledged") + if !self.isRunning { self.refreshConnectionStatus() } + } + } + do { try proc.run() } + catch { + stopProcess = nil + stopInFlight = false + finishDisconnect(error: "Не удалось запустить остановку VPN. Обновите установку: sh install.sh.") + } + } + + private func finishDisconnect(error: String?) { + disconnectDeadline?.cancel() + disconnectDeadline = nil + if let error = error { + lastError = error + state = .error(message: error) + connectionReady = false + log("[disconnect] \(error)") + } else { + userInitiatedDisconnect = false + sessionReady = false + sessionID = nil + state = .disconnected + lastError = nil + } + let completion = quitCompletion + quitCompletion = nil + completion?(error == nil) } func configureLogin() { - openTerminalCommand(name: "configure-login", command: "\(shellQuote(scriptPath)) --configure-login") + guard canConfigureCredentials else { return } + openTerminalCommand(name: "configure-login", argument: "--configure-login", successMessage: "Логин и пароль обновлены.") } - func quit() { - log("-- App quit --") - userInitiatedDisconnect = true - reconnectTimer?.invalidate() - reconnectTimer = nil + func importYandexKey() { + guard canConfigureCredentials else { return } + openTerminalCommand(name: "import-yandex-key", argument: "--import-yandex-key", successMessage: "Код импортирован. Обновите статус в меню Lemana VPN.") + } + + func quit(completion: @escaping (Bool) -> Void) { + log("-- App quit requested --") + guard needsDisconnectBeforeQuit else { completion(true); return } + guard quitCompletion == nil else { return } + quitCompletion = completion disconnect() - stopTimer() - stopHealthCheck() } private func parseEvent(_ jsonString: String) { @@ -435,12 +697,6 @@ class VPNManager: ObservableObject { return } - if let modules = event.modules { - moduleSummary = modules.summary - moduleStatusSystemImage = modules.systemImage - moduleStatus = modules - } - log("[event] \(event.event)" + { var extras: [String] = [] if let ip = event.ip { extras.append("ip=\(ip)") } @@ -449,124 +705,159 @@ 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)") } + if let p = event.mode ?? event.auth_profile ?? event.profile { extras.append("mode=\(p)") } return extras.isEmpty ? "" : " (\(extras.joined(separator: ", ")))" }()) - Task { @MainActor in - let prevState = state.label + let prevState = state.label - switch event.event { - case "modules": - return - case "credential_source": - if let message = event.message { - log(" \(message)") - } else if let source = event.source { - 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": - state = .unlocking(tier: "Touch ID") - case "bw_manual": - state = .unlocking(tier: "manual") - case "bw_synced": - log(" Credentials synced to Keychain") - return - case "keychain_ready": - log(" LDAP credentials are ready in macOS Keychain") - return - case "keychain_required": - log(" LDAP credentials are missing or incomplete") - return - case "log": - if let message = event.message { log(" \(message)") } - return - case "waiting": - if let message = event.message { log(" \(message)") } - return - case "connecting": - state = .connecting - case "connected": - currentIP = event.ip ?? "" - if let isoStr = event.expires { - sessionExpiresAt = ISO8601DateFormatter().date(from: isoStr) - } - let remaining = event.remaining_sec ?? Int(sessionExpiresAt?.timeIntervalSinceNow ?? 0) - state = .connected(ip: currentIP, expiresAt: sessionExpiresAt, remainingSec: remaining) - autoReconnectAttempts = 0 - startTimer() - detectVPNDNS() - case "session_info": - currentIP = event.ip ?? currentIP - if let isoStr = event.expires { - sessionExpiresAt = ISO8601DateFormatter().date(from: isoStr) - } - let remaining = event.remaining_sec ?? Int(sessionExpiresAt?.timeIntervalSinceNow ?? 0) - state = .connected(ip: currentIP, expiresAt: sessionExpiresAt, remainingSec: remaining) - case "disconnected": - state = .disconnected - stopTimer() - stopHealthCheck() - case "reconnecting": - state = .reconnecting(attempt: event.attempt ?? 1) - case "dns_cleanup": - log(" DNS cleanup in progress") - return - case "error": - lastError = event.message - state = .error(message: event.message ?? "Unknown error") - default: - log(" Unknown event type, ignoring") - return - } + if userInitiatedDisconnect || state == .disconnecting, + ["bw_cached", "bw_touchid", "bw_manual", "connecting", "connected", "session_info", "reconnecting", "disconnected", "error", "auth_rejected"].contains(event.event) { + return + } - if prevState != state.label { - log("[state] \(prevState) -> \(state.label)") + if terminalError != nil, ["connecting", "connected", "session_info", "reconnecting", "disconnected"].contains(event.event) { return } + + switch event.event { + case "session_ready": + sessionReady = true + if userInitiatedDisconnect { + if disconnectDeadline != nil { stopSession() } + else { disconnect() } } + return + case "modules": + return + case "credential_source": + if let message = event.message { + log(" \(message)") + } else if let source = event.source { + log(" Credential source: \(source)") + } + return + case "mode", "auth_profile": + if let message = event.message { + log(" \(message)") + } else if let mode = event.mode ?? event.profile { + log(" Mode: \(mode)") + } + return + case "manual_sso", "auto_sso": + let mode = event.mode ?? event.auth_profile ?? event.profile ?? "configured" + log(" SSO mode: \(mode)") + 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": + state = .unlocking(tier: "Touch ID") + case "bw_manual": + state = .unlocking(tier: "manual") + case "bw_synced": + log(" Credentials synced to Keychain") + return + case "keychain_ready": + log(" LDAP credentials are ready in macOS Keychain") + return + case "keychain_required": + log(" LDAP credentials are missing or incomplete") + return + case "log": + if let message = event.message { log(" \(message)") } + return + case "waiting": + if let message = event.message { log(" \(message)") } + return + case "connecting": + currentAttemptID = nil + stopHealthCheck() + sessionTiming = SessionTiming() + stopTimer() + state = .connecting + case "connected": + currentAttemptID = event.attempt_id + currentIP = event.ip ?? "" + sessionTiming.observe(startedAt: event.started_at, expires: event.expires) + let remaining = Int(sessionTiming.remaining(at: Date()) ?? Double(event.remaining_sec ?? 0)) + state = .connected(ip: currentIP, expiresAt: sessionTiming.expiresAt, remainingSec: remaining) + startTimer() + detectVPNDNS() + case "session_info": + guard case .connected = state else { return } + currentIP = event.ip ?? currentIP + sessionTiming.observe(startedAt: event.started_at, expires: event.expires) + let remaining = Int(sessionTiming.remaining(at: Date()) ?? Double(event.remaining_sec ?? 0)) + state = .connected(ip: currentIP, expiresAt: sessionTiming.expiresAt, remainingSec: remaining) + if timer == nil { startTimer() } + case "disconnected": + sessionTiming = SessionTiming() + state = .disconnected + stopTimer() + stopHealthCheck() + case "reconnecting": + stopHealthCheck() + sessionTiming = SessionTiming() + stopTimer() + state = .reconnecting(attempt: event.attempt ?? 1) + case "dns_cleanup": + state = .disconnecting + stopTimer() + stopHealthCheck() + log(" DNS cleanup in progress") + return + case "dns_recovery": + log(" DNS recovery result: \(event.result ?? "unknown")") + return + case "error", "auth_rejected": + stopHealthCheck() + sessionTiming = SessionTiming() + stopTimer() + terminalError = event.message ?? "Не удалось подключиться к VPN." + lastError = terminalError + state = .error(message: terminalError!) + default: + log(" Unknown event type, ignoring") + return + } + + if prevState != state.label { + log("[state] \(prevState) -> \(state.label)") } } - 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 = """ + static func terminalScript(scriptPath: String, argument: String, successMessage: String) -> String { + func quote(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + return """ #!/bin/zsh - \(command) - status=$? + \(quote(scriptPath)) \(quote(argument)) + command_status=$? printf '\\n' - if [ "$status" -eq 0 ]; then - printf 'Логин и пароль обновлены.\\n' + if [ "$command_status" -eq 0 ]; then + printf '%s\\n' \(quote(successMessage)) else - printf 'Команда завершилась с ошибкой: %s\\n' "$status" + printf 'Команда завершилась с ошибкой: %s\\n' "$command_status" fi printf 'Нажмите Enter, чтобы закрыть окно...' read -r _ - exit "$status" + exit "$command_status" """ + } + + private func openTerminalCommand(name: String, argument: String, successMessage: String) { + let stateDir = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".local/state/vpn-lemanapro") + let commandURL = stateDir.appendingPathComponent("\(name).command") + let script = Self.terminalScript(scriptPath: scriptPath, argument: argument, successMessage: successMessage) do { try FileManager.default.createDirectory(at: stateDir, withIntermediateDirectories: true) @@ -581,85 +872,55 @@ class VPNManager: ObservableObject { } catch { log("Credential setup launch failed: \(error.localizedDescription)") lastError = error.localizedDescription - state = .error(message: error.localizedDescription) + if !isRunning { 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() + sessionTiming = SessionTiming() stopHealthCheck() process = nil outputPipe = nil stderrPipe = nil - refreshStatus() let prevState = state.label if userInitiatedDisconnect { - state = .disconnected - userInitiatedDisconnect = false - autoReconnectAttempts = 0 - } else if currentLaunchMode != .auto { - log("Manual connection ended; auto-reconnect is disabled for manual mode") - state = .disconnected - autoReconnectAttempts = 0 - } else if [0, 2, 130, 143].contains(exitCode) { - scheduleAutoReconnect(reason: "session ended (exit \(exitCode))") + state = .disconnecting } else { - scheduleAutoReconnect(reason: "process crashed (exit \(exitCode))") + if terminalError == nil && ![0, 2, 130, 143].contains(exitCode) { + terminalError = exitCode == 77 + ? "Автоматический вход остановлен. Проверьте пароль и одноразовый код." + : "Подключение завершилось с ошибкой. Проверьте сеть и настройки входа." + } + lastError = terminalError + state = terminalError.map { .error(message: $0) } ?? .disconnected } if prevState != state.label { log("[state] \(prevState) -> \(state.label)") } - } - - private func scheduleAutoReconnect(reason: String) { - autoReconnectAttempts += 1 - if autoReconnectAttempts > maxAutoReconnectAttempts { - log("Auto-reconnect limit reached (\(maxAutoReconnectAttempts) attempts). Giving up.") - state = .error(message: "Не удалось переподключиться (\(autoReconnectAttempts - 1) попыток)") - autoReconnectAttempts = 0 - return - } - - log("Auto-reconnect #\(autoReconnectAttempts)/\(maxAutoReconnectAttempts) in \(Int(autoReconnectDelay))s (reason: \(reason))") - state = .reconnecting(attempt: autoReconnectAttempts) - - reconnectTimer?.invalidate() - reconnectTimer = Timer.scheduledTimer(withTimeInterval: autoReconnectDelay, repeats: false) { [weak self] _ in - Task { @MainActor in - guard let self = self else { return } - self.reconnectTimer = nil - guard case .reconnecting = self.state else { - self.log("Auto-reconnect cancelled (state changed)") - return - } - self.connect(mode: self.currentLaunchMode) - } - } + refreshConnectionStatus() } private func startTimer() { stopTimer() - timer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in + timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in Task { @MainActor in guard let self = self else { return } if case .connected = self.state { - let remaining = Int(self.sessionExpiresAt?.timeIntervalSinceNow ?? 0) + let remaining = Int(self.sessionTiming.remaining(at: Date()) ?? 0) self.state = .connected( ip: self.currentIP, - expiresAt: self.sessionExpiresAt, + expiresAt: self.sessionTiming.expiresAt, remainingSec: remaining ) } } } + timer?.tolerance = 0.2 } private func stopTimer() { @@ -668,6 +929,7 @@ class VPNManager: ObservableObject { } private func detectVPNDNS() { + let generation = healthCheckGeneration log("[health] Detecting VPN DNS via scutil --dns...") let proc = Process() proc.executableURL = URL(fileURLWithPath: "/usr/sbin/scutil") @@ -699,7 +961,7 @@ class VPNManager: ObservableObject { } Task { @MainActor in - guard let self = self else { return } + guard let self = self, self.healthCheckGeneration == generation, case .connected = self.state else { return } self.healthCheckTarget = dnsServer if let dns = dnsServer { self.log("[health] VPN DNS discovered: \(dns)") @@ -732,6 +994,7 @@ class VPNManager: ObservableObject { } private func stopHealthCheck() { + healthCheckGeneration += 1 if healthTimer != nil { log("[health] Stopped") } @@ -752,9 +1015,10 @@ class VPNManager: ObservableObject { proc.standardError = FileHandle.nullDevice let wasHealthy = tunnelHealthy + let generation = healthCheckGeneration proc.terminationHandler = { [weak self] p in Task { @MainActor in - guard let self = self, case .connected = self.state else { return } + guard let self = self, self.healthCheckGeneration == generation, case .connected = self.state else { return } let nowHealthy = p.terminationStatus == 0 if nowHealthy { @@ -767,15 +1031,12 @@ class VPNManager: ObservableObject { if wasHealthy { self.log("[health] Unreachable - ping \(target) (fail #\(self.consecutiveHealthFailures))") } else { - self.log("[health] Still unreachable (fail #\(self.consecutiveHealthFailures)/\(self.maxHealthFailuresBeforeKill))") + self.log("[health] Still unreachable (fail #\(self.consecutiveHealthFailures)/\(self.maxHealthFailuresBeforeRestart))") } - if self.consecutiveHealthFailures >= self.maxHealthFailuresBeforeKill { - self.log("[health] Tunnel dead after \(self.consecutiveHealthFailures) failures - killing process for reconnect") - self.stopHealthCheck() - if let proc = self.process, proc.isRunning { - proc.terminate() - } + if self.consecutiveHealthFailures >= self.maxHealthFailuresBeforeRestart { + self.log("[health] Tunnel unreachable; requesting an owned-engine restart") + self.restartSession() return } } @@ -794,8 +1055,41 @@ class VPNManager: ObservableObject { } } + private func restartSession() { + guard !userInitiatedDisconnect, sessionReady, let sessionID = sessionID, + let attemptID = currentAttemptID, !attemptID.isEmpty, + let connection = process, connection.isRunning else { return } + stopHealthCheck() + tunnelHealthy = false + let generation = healthCheckGeneration + let request = Process() + request.executableURL = URL(fileURLWithPath: "/bin/bash") + request.arguments = [scriptPath, "--restart-session=\(sessionID)", "--restart-attempt=\(attemptID)"] + request.environment = processEnvironment() + request.standardInput = FileHandle.nullDevice + request.standardOutput = FileHandle.nullDevice + request.standardError = FileHandle.nullDevice + request.terminationHandler = { [weak self] result in + Task { @MainActor in + guard let self = self, self.process === connection, self.sessionID == sessionID, + self.healthCheckGeneration == generation, !self.userInitiatedDisconnect else { return } + if result.terminationStatus != 0 { + self.lastError = "Не удалось безопасно перезапустить туннель. Отключите VPN и подключитесь заново." + self.log("[health] Owned-engine restart failed") + } + } + } + do { try request.run() } + catch { lastError = "Не удалось запустить переподключение. Отключите VPN и подключитесь заново." } + } + private func processEnvironment() -> [String: String] { var env = ProcessInfo.processInfo.environment + env.removeValue(forKey: "BASH_ENV") + // App actions always follow the saved configuration, not a parent shell's mode. + for key in ["LEMANA_VPN_MODE", "LEMANA_VPN_LOCAL_OTP", "LEMANA_VPN_AUTO_SOURCE", "LEMANA_VPN_AUTH_PROFILE", "LEMANA_VPN_CREDENTIAL_SOURCE", "LEMANA_VPN_USE_BITWARDEN"] { + env.removeValue(forKey: key) + } let home = FileManager.default.homeDirectoryForCurrentUser.path let extraPaths = [ "\(home)/bin", @@ -811,11 +1105,6 @@ class VPNManager: ObservableObject { return env } - private static let logURL: URL = { - FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Logs/LemanaVPN.log") - }() - private static let tsFormatter: DateFormatter = { let f = DateFormatter() f.dateFormat = "yyyy-MM-dd HH:mm:ss" @@ -824,17 +1113,18 @@ class VPNManager: ObservableObject { }() func log(_ message: String) { + if let limit = moduleStatus?.logging?.max_bytes, (1...999_999_999).contains(limit) { + fileLog.maxBytes = limit + } let ts = Self.tsFormatter.string(from: Date()) let line = "[\(ts)] \(message)\n" - - let path = Self.logURL.path - if !FileManager.default.fileExists(atPath: path) { - FileManager.default.createFile(atPath: path, contents: nil) - } - if let handle = FileHandle(forWritingAtPath: path) { - handle.seekToEndOfFile() - handle.write(line.data(using: .utf8) ?? Data()) - handle.closeFile() + do { + try fileLog.append(line) + } catch { + if !logWriteFailed { + fputs("Cannot write Lemana VPN log: \(error.localizedDescription)\n", stderr) + logWriteFailed = true + } } } } diff --git a/artifacts/icon-family-v1/README.md b/artifacts/icon-family-v1/README.md new file mode 100644 index 0000000..69751ff --- /dev/null +++ b/artifacts/icon-family-v1/README.md @@ -0,0 +1,19 @@ +# Lemana VPN: единая семья значков + +Проект набора для согласования. Значки работающего приложения не заменены. + +34 варианта: состояния VPN, ход сессии, варианты времени, действия меню и источники входа. Один контур щита, сетка 24 единицы, основная линия 1,75 единицы; рабочий размер — 16 pt. Цвет всегда дополнен формой или подписью. + +- `svg/`: векторные версии для светлой и тёмной темы. +- `png/`: прозрачные превью высотой 96 px для просмотра в разных размерах. +- `icon-sheet-light.png` и `icon-sheet-dark.png`: весь набор крупно и в 16 pt. +- `lemana-vpn-icons-v1.zip`: SVG обеих тем и manifest. +- `draw_icons.py`: исходник геометрии и воспроизводимая сборка (Python 3 + rsvg-convert). + +Индикатор времени заполняется по часовой стрелке от верхушки щита. Внутри остаётся нейтральная подложка (14% цвета основного текста), поверх которой снизу вверх растёт заливка (60% текущего цвета). Высота заливки пропорциональна прошедшей доле сессии. Контур нейтральный, пройденная часть зелёная; последний час янтарный, последние 15 минут красные. На полном красном щите песочные часы контрастные, а не красные. Кольцо использует тот же принцип внутренней заливки. + +Образцы последних минут показаны для условной восьмичасовой сессии; в приложении доля должна вычисляться по реальным started_at/expires. Неизвестный срок имеет отдельную метку, а не нулевое заполнение. Обычные состояния, статические часы и действия меню не изменены. + +Bitwarden обозначен нейтральным значком хранилища, это не копия фирменного логотипа. + +Проверены структура SVG, доли заполнения и рендер контактных листов. Приложение/GUI не запускались. diff --git a/artifacts/icon-family-v1/draw_icons.py b/artifacts/icon-family-v1/draw_icons.py new file mode 100644 index 0000000..2dd4bb8 --- /dev/null +++ b/artifacts/icon-family-v1/draw_icons.py @@ -0,0 +1,168 @@ +"""Deterministic review assets for Lemana VPN; does not modify the application.""" +from pathlib import Path +import html +import json +import math +import subprocess +import zipfile + +ROOT = Path(__file__).resolve().parent +SHIELD = 'M12 2 C14.6 3.7 17.3 4.7 20 5 V11.2 C20 16.1 16.7 19.6 12 22 C7.3 19.6 4 16.1 4 11.2 V5 C6.7 4.7 9.4 3.7 12 2 Z' + +def curve_length(points): + previous=points[0]; length=0.0 + for i in range(1,513): + t=i/512; u=1-t + point=tuple(u**3*points[0][k]+3*u*u*t*points[1][k]+3*u*t*t*points[2][k]+t**3*points[3][k] for k in (0,1)) + length+=math.dist(previous,point); previous=point + return length + +SHIELD_LENGTH=2*(6.2+curve_length(((12,2),(14.6,3.7),(17.3,4.7),(20,5)))+curve_length(((20,11.2),(20,16.1),(16.7,19.6),(12,22)))) +PALETTES = { + 'light': dict(bg='#F6F7F8', surface='#ECEFF2', ink='#27333E', muted='#657381', line='#D6DDE3', green='#278055', amber='#A56A13', red='#BE4149'), + 'dark': dict(bg='#202429', surface='#2B3037', ink='#E5EAF0', muted='#ACB7C3', line='#444D57', green='#78CAA2', amber='#F0BB68', red='#F58C95'), +} + +def path(d, **attrs): + extra = ''.join(f' {key.replace("_", "-")}="{value}"' for key,value in attrs.items()) + return f'' + +def circle(x,y,r,**attrs): + extra = ''.join(f' {key.replace("_", "-")}="{value}"' for key,value in attrs.items()) + return f'' + + +def progress_interior(shape,progress,bottom,height,palette,accent): + body=shape(fill=palette['ink'],fill_opacity='.14',stroke='none') + if progress: + level=height*progress + top=bottom-level + # A nested viewport clips the fill level without shared SVG clip-path IDs. + body+=f'' + body+=shape(fill=accent,fill_opacity='.60',stroke='none')+'' + return body + +MARKS = { + 'off': path('M8.7 8.7 L15.3 15.3'), + 'connecting': ''.join(circle(x,12,.7,fill='currentColor',stroke='none') for x in (9,12,15)), + 'unlock': circle(10,10.5,1.8)+path('M11.4 11.9 L15.4 15.9 M13.5 14 L15 12.5'), + 'check': path('M7.8 11.8 L10.8 14.7 L16.2 9.3'), + 'reconnect': path('M15.6 10.2 A4 4 0 1 0 15.6 14.1 M15.6 7.3 V10.2 H12.7'), + 'network': path('M10 10 H9 A2 2 0 0 0 9 14 H10 M14 10 H15 A2 2 0 0 1 15 14 H14'), + 'error': path('M9 9 L15 15 M15 9 L9 15'), + 'setup': path('M12 7.8 V12.3')+circle(12,15.5,.65,fill='currentColor',stroke='none'), + 'unknown': path('M9.7 9.2 C9.8 6.7 14.6 6.7 14.6 9.4 C14.6 11.3 12 11.3 12 13')+circle(12,15.9,.65,fill='currentColor',stroke='none'), + 'expired': path('M9 8 H15 M9 16 H15 M9.7 8 C9.7 10.3 14.3 13.7 14.3 16 M14.3 8 C14.3 10.3 9.7 13.7 9.7 16'), + 'no-time': path('M9 12 H15'), + 'clock': path('M12 7.5 V12 L15 13.7'), +} +ACTIONS = { + 'connect': path('M8 3 V8 M16 3 V8 M6 8 H18 V11 C18 14.3 15.3 17 12 17 C8.7 17 6 14.3 6 11 Z M12 17 V21'), + 'disconnect': path('M8 2.5 V6 M16 2.5 V6 M6 6 H18 V8 C18 10.2 15.3 11.5 12 11.5 C8.7 11.5 6 10.2 6 8 Z M7.5 15.5 Q12 19.5 16.5 15.5 M12 17.5 V21.5'), + 'cancel': path('M6 6 L18 18 M18 6 L6 18'), + 'import': path('M5 13 V20 H19 V13 M12 3 V15 M8 11 L12 15 L16 11'), + 'password': circle(8,8,4)+path('M11 11 L20 20 M15 15 L17 13 M18 18 L20 16'), + 'copy': path('M9 4 H19 V15 M5 8 H15 V21 H5 Z'), + 'logs': path('M6 3 H14 L19 8 V21 H6 Z M14 3 V8 H19 M9 12 H16 M9 16 H16'), + 'refresh': path('M19 9 A7.2 7.2 0 0 0 6.8 6.5 L4 9 M4 4 V9 H9 M5 15 A7.2 7.2 0 0 0 17.2 17.5 L20 15 M20 20 V15 H15'), + 'quit': path('M11 3 H4 V21 H11 M10 12 H21 M17 8 L21 12 L17 16'), + 'manual': ''+path('M6.5 10 H7 M11.5 10 H12 M16.5 10 H17 M7 14 H17'), + 'auto': path('M13 3 L6 13 H11 L10 21 L18 10 H13 Z'), + 'local': ''+path('M2.5 19 H21.5 L20 21 H4 Z'), + 'vault': ''+circle(12,12,3.8)+path('M12 8.2 V10 M15.8 12 H14 M12 15.8 V14 M8.2 12 H10'), +} +ENTRIES=[] +def add(key,label,section,kind,mark='',tone='ink',progress=None): + ENTRIES.append(dict(key=key,label=label,section=section,kind=kind,mark=mark,tone=tone,progress=progress)) + +for args in [ + ('vpn-off','Отключён','off','ink'),('vpn-connecting','Подключение','connecting','ink'), + ('vpn-unlock','Ожидаем вход','unlock','ink'),('vpn-connected','Подключён','check','green'), + ('vpn-reconnect','Переподключение','reconnect','ink'),('vpn-network','Проблема сети','network','amber'), + ('vpn-error','Ошибка','error','red'),('vpn-setup','Нужна настройка','setup','amber'), + ('vpn-unknown','Статус неизвестен','unknown','ink')]: + add(args[0],args[1],'Состояния VPN','shield',args[2],args[3]) +for key,label,progress,tone in [ + ('time-start','Начало',0,'green'),('time-quarter','25% прошло',.25,'green'), + ('time-half','50% прошло',.5,'green'),('time-three-quarter','75% прошло',.75,'green'), + ('time-hour','Остался час',.875,'amber'),('time-quarter-hour','Осталось 15 мин',.96875,'red'), + ('time-expired','Срок истёк',1,'red')]: + add(key,label,'Время сессии','progress','expired' if progress==1 else '',tone,progress) +add('time-unknown','Срок неизвестен','Время сессии','shield','no-time') +add('format-shield','Щит с прогрессом','Форматы времени','progress','', 'green',.58) +add('format-clock','Щит-часы','Форматы времени','shield','clock','green') +add('format-time','Щит + время','Форматы времени','digital','check','green') +add('format-ring','Кольцо','Форматы времени','ring','', 'green',.58) +for key,label in [('connect','Подключить'),('disconnect','Отключить'),('cancel','Отменить'),('import','Импорт ключа'),('password','Логин и пароль'),('copy','Копировать IP'),('logs','Логи'),('refresh','Обновить статус'),('quit','Выход')]: + add('action-'+key,label,'Действия меню','action',key) +for key,label in [('manual','Вручную'),('auto','Автоматически'),('local','Ключ на Mac'),('vault','Bitwarden')]: + add('source-'+key,label,'Источники и режимы','action',key) + + +def content(entry,palette): + ink=palette['ink'];accent=palette[entry['tone']] + if entry['kind']=='action': return ACTIONS[entry['mark']] + if entry['kind']=='ring': + body=progress_interior(lambda **attrs:circle(12,12,9,**attrs),entry['progress'],21,18,palette,accent) + return body+circle(12,12,9,stroke=palette['muted'],stroke_opacity='.65')+circle(12,12,9,stroke=accent,stroke_dasharray=f'{entry["progress"]*math.tau*9:.5f} {math.tau*9:.5f}',transform='rotate(-90 12 12)',stroke_linecap='butt') + shape=path(SHIELD,stroke=ink) + if entry['kind']=='progress': + shape=progress_interior(lambda **attrs:path(SHIELD,**attrs),entry['progress'],22,20,palette,accent) + shape+=path(SHIELD,stroke=palette['muted'],stroke_opacity='.68') + if entry['progress']: + shape+=path(SHIELD,stroke=accent,stroke_dasharray=f'{entry["progress"]*SHIELD_LENGTH:.5f} {SHIELD_LENGTH:.5f}',stroke_linecap='butt') + if entry['mark']: + mark_color=ink if entry['kind']=='progress' else accent + shape+=f'{MARKS[entry["mark"]]}' + if entry['kind']=='digital': + shape+='2:18' + return shape + + +def svg(entry,theme): + p=PALETTES[theme];width=74 if entry['kind']=='digital' else 24 + return f'{html.escape(entry["label"])}'+content(entry,p)+'' + + +def text(x,y,value,size=14,color='#657381',weight=400): + return f'{html.escape(value)}' + + +def sheet(theme): + p=PALETTES[theme];width=1120;elements=[] + elements.append(text(42,53,'Lemana VPN',27,p['ink'],600)) + elements.append(text(42,82,'Единая семья значков',16,p['muted'])) + elements.append(text(824,53,'Щит / 16 pt',14,p['ink'],500)) + elements.append(text(824,78,'Контур 1,75 · сетка 24',12,p['muted'])) + y=130 + for section in dict.fromkeys(e['section'] for e in ENTRIES): + elements.append(text(42,y,section,16,p['ink'],500));y+=24 + subset=[e for e in ENTRIES if e['section']==section] + for index,e in enumerate(subset): + col=index%6;row=index//6;x=42+col*174;top=y+row*119 + logical_width=74 if e['kind']=='digital' else 24 + large_x=x+(160-logical_width*1.5)/2 + small_x=x+(160-logical_width*2/3)/2 + elements.append(f''+content(e,p)+'') + elements.append(f''+content(e,p)+'') + elements.append(text(x+7,top+91,e['label'],12,p['ink'])) + y+=((len(subset)+5)//6)*119+14 + elements.append(f'') + elements.append(text(42,y+10,'Увеличенный вид + 16 pt. Цвет дополнен формой и подписью.',12,p['muted'])) + elements.append(text(42,y+32,'Пример сессии: 8 ч. Контур и внутренняя заливка растут к её концу. Набор пока не установлен.',12,p['muted'])) + height=y+65 + return f''+''.join(elements)+'' + +for theme in PALETTES: + for e in ENTRIES: + source=ROOT/'svg'/f'{e["key"]}-{theme}.svg' + source.write_text(svg(e,theme)) + subprocess.run(['rsvg-convert',str(source),'-o',str(ROOT/'png'/f'{e["key"]}-{theme}.png'),'-h','96'],check=True) + source=ROOT/f'icon-sheet-{theme}.svg';source.write_text(sheet(theme)) + subprocess.run(['rsvg-convert',str(source),'-o',str(ROOT/f'icon-sheet-{theme}.png'),'-w','2240'],check=True) +(ROOT/'manifest.json').write_text(json.dumps(ENTRIES,ensure_ascii=False,indent=2)+'\n') +with zipfile.ZipFile(ROOT/'lemana-vpn-icons-v1.zip','w',zipfile.ZIP_DEFLATED) as archive: + for file in sorted((ROOT/'svg').glob('*.svg')): + archive.write(file,file.relative_to(ROOT)) + archive.write(ROOT/'manifest.json','manifest.json') +print(f'{len(ENTRIES)} icon entries, two themes, SVG assets and preview sheets created.') diff --git a/artifacts/icon-family-v1/icon-sheet-dark.png b/artifacts/icon-family-v1/icon-sheet-dark.png new file mode 100644 index 0000000..5088d15 Binary files /dev/null and b/artifacts/icon-family-v1/icon-sheet-dark.png differ diff --git a/artifacts/icon-family-v1/icon-sheet-dark.svg b/artifacts/icon-family-v1/icon-sheet-dark.svg new file mode 100644 index 0000000..23528af --- /dev/null +++ b/artifacts/icon-family-v1/icon-sheet-dark.svg @@ -0,0 +1 @@ +Lemana VPNЕдиная семья значковЩит / 16 ptКонтур 1,75 · сетка 24Состояния VPNОтключёнПодключениеОжидаем входПодключёнПереподключениеПроблема сетиОшибкаНужна настройкаСтатус неизвестенВремя сессииНачало25% прошло50% прошло75% прошлоОстался часОсталось 15 минСрок истёкСрок неизвестенФорматы времениЩит с прогрессомЩит-часы2:182:18Щит + времяКольцоДействия менюПодключитьОтключитьОтменитьИмпорт ключаЛогин и парольКопировать IPЛогиОбновить статусВыходИсточники и режимыВручнуюАвтоматическиКлюч на MacBitwardenУвеличенный вид + 16 pt. Цвет дополнен формой и подписью.Пример сессии: 8 ч. Контур и внутренняя заливка растут к её концу. Набор пока не установлен. \ No newline at end of file diff --git a/artifacts/icon-family-v1/icon-sheet-light.png b/artifacts/icon-family-v1/icon-sheet-light.png new file mode 100644 index 0000000..faf54aa Binary files /dev/null and b/artifacts/icon-family-v1/icon-sheet-light.png differ diff --git a/artifacts/icon-family-v1/icon-sheet-light.svg b/artifacts/icon-family-v1/icon-sheet-light.svg new file mode 100644 index 0000000..e054d5d --- /dev/null +++ b/artifacts/icon-family-v1/icon-sheet-light.svg @@ -0,0 +1 @@ +Lemana VPNЕдиная семья значковЩит / 16 ptКонтур 1,75 · сетка 24Состояния VPNОтключёнПодключениеОжидаем входПодключёнПереподключениеПроблема сетиОшибкаНужна настройкаСтатус неизвестенВремя сессииНачало25% прошло50% прошло75% прошлоОстался часОсталось 15 минСрок истёкСрок неизвестенФорматы времениЩит с прогрессомЩит-часы2:182:18Щит + времяКольцоДействия менюПодключитьОтключитьОтменитьИмпорт ключаЛогин и парольКопировать IPЛогиОбновить статусВыходИсточники и режимыВручнуюАвтоматическиКлюч на MacBitwardenУвеличенный вид + 16 pt. Цвет дополнен формой и подписью.Пример сессии: 8 ч. Контур и внутренняя заливка растут к её концу. Набор пока не установлен. \ No newline at end of file diff --git a/artifacts/icon-family-v1/lemana-vpn-icons-v1.zip b/artifacts/icon-family-v1/lemana-vpn-icons-v1.zip new file mode 100644 index 0000000..83c380c Binary files /dev/null and b/artifacts/icon-family-v1/lemana-vpn-icons-v1.zip differ diff --git a/artifacts/icon-family-v1/manifest.json b/artifacts/icon-family-v1/manifest.json new file mode 100644 index 0000000..0a704a2 --- /dev/null +++ b/artifacts/icon-family-v1/manifest.json @@ -0,0 +1,308 @@ +[ + { + "key": "vpn-off", + "label": "Отключён", + "section": "Состояния VPN", + "kind": "shield", + "mark": "off", + "tone": "ink", + "progress": null + }, + { + "key": "vpn-connecting", + "label": "Подключение", + "section": "Состояния VPN", + "kind": "shield", + "mark": "connecting", + "tone": "ink", + "progress": null + }, + { + "key": "vpn-unlock", + "label": "Ожидаем вход", + "section": "Состояния VPN", + "kind": "shield", + "mark": "unlock", + "tone": "ink", + "progress": null + }, + { + "key": "vpn-connected", + "label": "Подключён", + "section": "Состояния VPN", + "kind": "shield", + "mark": "check", + "tone": "green", + "progress": null + }, + { + "key": "vpn-reconnect", + "label": "Переподключение", + "section": "Состояния VPN", + "kind": "shield", + "mark": "reconnect", + "tone": "ink", + "progress": null + }, + { + "key": "vpn-network", + "label": "Проблема сети", + "section": "Состояния VPN", + "kind": "shield", + "mark": "network", + "tone": "amber", + "progress": null + }, + { + "key": "vpn-error", + "label": "Ошибка", + "section": "Состояния VPN", + "kind": "shield", + "mark": "error", + "tone": "red", + "progress": null + }, + { + "key": "vpn-setup", + "label": "Нужна настройка", + "section": "Состояния VPN", + "kind": "shield", + "mark": "setup", + "tone": "amber", + "progress": null + }, + { + "key": "vpn-unknown", + "label": "Статус неизвестен", + "section": "Состояния VPN", + "kind": "shield", + "mark": "unknown", + "tone": "ink", + "progress": null + }, + { + "key": "time-start", + "label": "Начало", + "section": "Время сессии", + "kind": "progress", + "mark": "", + "tone": "green", + "progress": 0 + }, + { + "key": "time-quarter", + "label": "25% прошло", + "section": "Время сессии", + "kind": "progress", + "mark": "", + "tone": "green", + "progress": 0.25 + }, + { + "key": "time-half", + "label": "50% прошло", + "section": "Время сессии", + "kind": "progress", + "mark": "", + "tone": "green", + "progress": 0.5 + }, + { + "key": "time-three-quarter", + "label": "75% прошло", + "section": "Время сессии", + "kind": "progress", + "mark": "", + "tone": "green", + "progress": 0.75 + }, + { + "key": "time-hour", + "label": "Остался час", + "section": "Время сессии", + "kind": "progress", + "mark": "", + "tone": "amber", + "progress": 0.875 + }, + { + "key": "time-quarter-hour", + "label": "Осталось 15 мин", + "section": "Время сессии", + "kind": "progress", + "mark": "", + "tone": "red", + "progress": 0.96875 + }, + { + "key": "time-expired", + "label": "Срок истёк", + "section": "Время сессии", + "kind": "progress", + "mark": "expired", + "tone": "red", + "progress": 1 + }, + { + "key": "time-unknown", + "label": "Срок неизвестен", + "section": "Время сессии", + "kind": "shield", + "mark": "no-time", + "tone": "ink", + "progress": null + }, + { + "key": "format-shield", + "label": "Щит с прогрессом", + "section": "Форматы времени", + "kind": "progress", + "mark": "", + "tone": "green", + "progress": 0.58 + }, + { + "key": "format-clock", + "label": "Щит-часы", + "section": "Форматы времени", + "kind": "shield", + "mark": "clock", + "tone": "green", + "progress": null + }, + { + "key": "format-time", + "label": "Щит + время", + "section": "Форматы времени", + "kind": "digital", + "mark": "check", + "tone": "green", + "progress": null + }, + { + "key": "format-ring", + "label": "Кольцо", + "section": "Форматы времени", + "kind": "ring", + "mark": "", + "tone": "green", + "progress": 0.58 + }, + { + "key": "action-connect", + "label": "Подключить", + "section": "Действия меню", + "kind": "action", + "mark": "connect", + "tone": "ink", + "progress": null + }, + { + "key": "action-disconnect", + "label": "Отключить", + "section": "Действия меню", + "kind": "action", + "mark": "disconnect", + "tone": "ink", + "progress": null + }, + { + "key": "action-cancel", + "label": "Отменить", + "section": "Действия меню", + "kind": "action", + "mark": "cancel", + "tone": "ink", + "progress": null + }, + { + "key": "action-import", + "label": "Импорт ключа", + "section": "Действия меню", + "kind": "action", + "mark": "import", + "tone": "ink", + "progress": null + }, + { + "key": "action-password", + "label": "Логин и пароль", + "section": "Действия меню", + "kind": "action", + "mark": "password", + "tone": "ink", + "progress": null + }, + { + "key": "action-copy", + "label": "Копировать IP", + "section": "Действия меню", + "kind": "action", + "mark": "copy", + "tone": "ink", + "progress": null + }, + { + "key": "action-logs", + "label": "Логи", + "section": "Действия меню", + "kind": "action", + "mark": "logs", + "tone": "ink", + "progress": null + }, + { + "key": "action-refresh", + "label": "Обновить статус", + "section": "Действия меню", + "kind": "action", + "mark": "refresh", + "tone": "ink", + "progress": null + }, + { + "key": "action-quit", + "label": "Выход", + "section": "Действия меню", + "kind": "action", + "mark": "quit", + "tone": "ink", + "progress": null + }, + { + "key": "source-manual", + "label": "Вручную", + "section": "Источники и режимы", + "kind": "action", + "mark": "manual", + "tone": "ink", + "progress": null + }, + { + "key": "source-auto", + "label": "Автоматически", + "section": "Источники и режимы", + "kind": "action", + "mark": "auto", + "tone": "ink", + "progress": null + }, + { + "key": "source-local", + "label": "Ключ на Mac", + "section": "Источники и режимы", + "kind": "action", + "mark": "local", + "tone": "ink", + "progress": null + }, + { + "key": "source-vault", + "label": "Bitwarden", + "section": "Источники и режимы", + "kind": "action", + "mark": "vault", + "tone": "ink", + "progress": null + } +] diff --git a/artifacts/icon-family-v1/png/action-cancel-dark.png b/artifacts/icon-family-v1/png/action-cancel-dark.png new file mode 100644 index 0000000..d3a0ee1 Binary files /dev/null and b/artifacts/icon-family-v1/png/action-cancel-dark.png differ diff --git a/artifacts/icon-family-v1/png/action-cancel-light.png b/artifacts/icon-family-v1/png/action-cancel-light.png new file mode 100644 index 0000000..05b352a Binary files /dev/null and b/artifacts/icon-family-v1/png/action-cancel-light.png differ diff --git a/artifacts/icon-family-v1/png/action-connect-dark.png b/artifacts/icon-family-v1/png/action-connect-dark.png new file mode 100644 index 0000000..43bf35c Binary files /dev/null and b/artifacts/icon-family-v1/png/action-connect-dark.png differ diff --git a/artifacts/icon-family-v1/png/action-connect-light.png b/artifacts/icon-family-v1/png/action-connect-light.png new file mode 100644 index 0000000..69dd6ec Binary files /dev/null and b/artifacts/icon-family-v1/png/action-connect-light.png differ diff --git a/artifacts/icon-family-v1/png/action-copy-dark.png b/artifacts/icon-family-v1/png/action-copy-dark.png new file mode 100644 index 0000000..815fe2d Binary files /dev/null and b/artifacts/icon-family-v1/png/action-copy-dark.png differ diff --git a/artifacts/icon-family-v1/png/action-copy-light.png b/artifacts/icon-family-v1/png/action-copy-light.png new file mode 100644 index 0000000..ceedb84 Binary files /dev/null and b/artifacts/icon-family-v1/png/action-copy-light.png differ diff --git a/artifacts/icon-family-v1/png/action-disconnect-dark.png b/artifacts/icon-family-v1/png/action-disconnect-dark.png new file mode 100644 index 0000000..29272b2 Binary files /dev/null and b/artifacts/icon-family-v1/png/action-disconnect-dark.png differ diff --git a/artifacts/icon-family-v1/png/action-disconnect-light.png b/artifacts/icon-family-v1/png/action-disconnect-light.png new file mode 100644 index 0000000..9beed0a Binary files /dev/null and b/artifacts/icon-family-v1/png/action-disconnect-light.png differ diff --git a/artifacts/icon-family-v1/png/action-import-dark.png b/artifacts/icon-family-v1/png/action-import-dark.png new file mode 100644 index 0000000..8c8828c Binary files /dev/null and b/artifacts/icon-family-v1/png/action-import-dark.png differ diff --git a/artifacts/icon-family-v1/png/action-import-light.png b/artifacts/icon-family-v1/png/action-import-light.png new file mode 100644 index 0000000..f293e2c Binary files /dev/null and b/artifacts/icon-family-v1/png/action-import-light.png differ diff --git a/artifacts/icon-family-v1/png/action-logs-dark.png b/artifacts/icon-family-v1/png/action-logs-dark.png new file mode 100644 index 0000000..240e9d0 Binary files /dev/null and b/artifacts/icon-family-v1/png/action-logs-dark.png differ diff --git a/artifacts/icon-family-v1/png/action-logs-light.png b/artifacts/icon-family-v1/png/action-logs-light.png new file mode 100644 index 0000000..06b82d4 Binary files /dev/null and b/artifacts/icon-family-v1/png/action-logs-light.png differ diff --git a/artifacts/icon-family-v1/png/action-password-dark.png b/artifacts/icon-family-v1/png/action-password-dark.png new file mode 100644 index 0000000..a8b579a Binary files /dev/null and b/artifacts/icon-family-v1/png/action-password-dark.png differ diff --git a/artifacts/icon-family-v1/png/action-password-light.png b/artifacts/icon-family-v1/png/action-password-light.png new file mode 100644 index 0000000..54e20b1 Binary files /dev/null and b/artifacts/icon-family-v1/png/action-password-light.png differ diff --git a/artifacts/icon-family-v1/png/action-quit-dark.png b/artifacts/icon-family-v1/png/action-quit-dark.png new file mode 100644 index 0000000..cf36f0f Binary files /dev/null and b/artifacts/icon-family-v1/png/action-quit-dark.png differ diff --git a/artifacts/icon-family-v1/png/action-quit-light.png b/artifacts/icon-family-v1/png/action-quit-light.png new file mode 100644 index 0000000..1bc59b1 Binary files /dev/null and b/artifacts/icon-family-v1/png/action-quit-light.png differ diff --git a/artifacts/icon-family-v1/png/action-refresh-dark.png b/artifacts/icon-family-v1/png/action-refresh-dark.png new file mode 100644 index 0000000..3543a8d Binary files /dev/null and b/artifacts/icon-family-v1/png/action-refresh-dark.png differ diff --git a/artifacts/icon-family-v1/png/action-refresh-light.png b/artifacts/icon-family-v1/png/action-refresh-light.png new file mode 100644 index 0000000..89a0c4d Binary files /dev/null and b/artifacts/icon-family-v1/png/action-refresh-light.png differ diff --git a/artifacts/icon-family-v1/png/format-clock-dark.png b/artifacts/icon-family-v1/png/format-clock-dark.png new file mode 100644 index 0000000..2bf6631 Binary files /dev/null and b/artifacts/icon-family-v1/png/format-clock-dark.png differ diff --git a/artifacts/icon-family-v1/png/format-clock-light.png b/artifacts/icon-family-v1/png/format-clock-light.png new file mode 100644 index 0000000..4167d24 Binary files /dev/null and b/artifacts/icon-family-v1/png/format-clock-light.png differ diff --git a/artifacts/icon-family-v1/png/format-ring-dark.png b/artifacts/icon-family-v1/png/format-ring-dark.png new file mode 100644 index 0000000..e47bda8 Binary files /dev/null and b/artifacts/icon-family-v1/png/format-ring-dark.png differ diff --git a/artifacts/icon-family-v1/png/format-ring-light.png b/artifacts/icon-family-v1/png/format-ring-light.png new file mode 100644 index 0000000..603836d Binary files /dev/null and b/artifacts/icon-family-v1/png/format-ring-light.png differ diff --git a/artifacts/icon-family-v1/png/format-shield-dark.png b/artifacts/icon-family-v1/png/format-shield-dark.png new file mode 100644 index 0000000..4f075e1 Binary files /dev/null and b/artifacts/icon-family-v1/png/format-shield-dark.png differ diff --git a/artifacts/icon-family-v1/png/format-shield-light.png b/artifacts/icon-family-v1/png/format-shield-light.png new file mode 100644 index 0000000..a77001b Binary files /dev/null and b/artifacts/icon-family-v1/png/format-shield-light.png differ diff --git a/artifacts/icon-family-v1/png/format-time-dark.png b/artifacts/icon-family-v1/png/format-time-dark.png new file mode 100644 index 0000000..da1532f Binary files /dev/null and b/artifacts/icon-family-v1/png/format-time-dark.png differ diff --git a/artifacts/icon-family-v1/png/format-time-light.png b/artifacts/icon-family-v1/png/format-time-light.png new file mode 100644 index 0000000..0384404 Binary files /dev/null and b/artifacts/icon-family-v1/png/format-time-light.png differ diff --git a/artifacts/icon-family-v1/png/source-auto-dark.png b/artifacts/icon-family-v1/png/source-auto-dark.png new file mode 100644 index 0000000..508de22 Binary files /dev/null and b/artifacts/icon-family-v1/png/source-auto-dark.png differ diff --git a/artifacts/icon-family-v1/png/source-auto-light.png b/artifacts/icon-family-v1/png/source-auto-light.png new file mode 100644 index 0000000..98ebd9d Binary files /dev/null and b/artifacts/icon-family-v1/png/source-auto-light.png differ diff --git a/artifacts/icon-family-v1/png/source-local-dark.png b/artifacts/icon-family-v1/png/source-local-dark.png new file mode 100644 index 0000000..d934ffd Binary files /dev/null and b/artifacts/icon-family-v1/png/source-local-dark.png differ diff --git a/artifacts/icon-family-v1/png/source-local-light.png b/artifacts/icon-family-v1/png/source-local-light.png new file mode 100644 index 0000000..9067b7e Binary files /dev/null and b/artifacts/icon-family-v1/png/source-local-light.png differ diff --git a/artifacts/icon-family-v1/png/source-manual-dark.png b/artifacts/icon-family-v1/png/source-manual-dark.png new file mode 100644 index 0000000..92aae30 Binary files /dev/null and b/artifacts/icon-family-v1/png/source-manual-dark.png differ diff --git a/artifacts/icon-family-v1/png/source-manual-light.png b/artifacts/icon-family-v1/png/source-manual-light.png new file mode 100644 index 0000000..6042a52 Binary files /dev/null and b/artifacts/icon-family-v1/png/source-manual-light.png differ diff --git a/artifacts/icon-family-v1/png/source-vault-dark.png b/artifacts/icon-family-v1/png/source-vault-dark.png new file mode 100644 index 0000000..4d6b8c2 Binary files /dev/null and b/artifacts/icon-family-v1/png/source-vault-dark.png differ diff --git a/artifacts/icon-family-v1/png/source-vault-light.png b/artifacts/icon-family-v1/png/source-vault-light.png new file mode 100644 index 0000000..e5bff3d Binary files /dev/null and b/artifacts/icon-family-v1/png/source-vault-light.png differ diff --git a/artifacts/icon-family-v1/png/time-expired-dark.png b/artifacts/icon-family-v1/png/time-expired-dark.png new file mode 100644 index 0000000..141646d Binary files /dev/null and b/artifacts/icon-family-v1/png/time-expired-dark.png differ diff --git a/artifacts/icon-family-v1/png/time-expired-light.png b/artifacts/icon-family-v1/png/time-expired-light.png new file mode 100644 index 0000000..83d6d2c Binary files /dev/null and b/artifacts/icon-family-v1/png/time-expired-light.png differ diff --git a/artifacts/icon-family-v1/png/time-half-dark.png b/artifacts/icon-family-v1/png/time-half-dark.png new file mode 100644 index 0000000..d606544 Binary files /dev/null and b/artifacts/icon-family-v1/png/time-half-dark.png differ diff --git a/artifacts/icon-family-v1/png/time-half-light.png b/artifacts/icon-family-v1/png/time-half-light.png new file mode 100644 index 0000000..e8788dd Binary files /dev/null and b/artifacts/icon-family-v1/png/time-half-light.png differ diff --git a/artifacts/icon-family-v1/png/time-hour-dark.png b/artifacts/icon-family-v1/png/time-hour-dark.png new file mode 100644 index 0000000..4bc76c0 Binary files /dev/null and b/artifacts/icon-family-v1/png/time-hour-dark.png differ diff --git a/artifacts/icon-family-v1/png/time-hour-light.png b/artifacts/icon-family-v1/png/time-hour-light.png new file mode 100644 index 0000000..a33110a Binary files /dev/null and b/artifacts/icon-family-v1/png/time-hour-light.png differ diff --git a/artifacts/icon-family-v1/png/time-quarter-dark.png b/artifacts/icon-family-v1/png/time-quarter-dark.png new file mode 100644 index 0000000..f078d76 Binary files /dev/null and b/artifacts/icon-family-v1/png/time-quarter-dark.png differ diff --git a/artifacts/icon-family-v1/png/time-quarter-hour-dark.png b/artifacts/icon-family-v1/png/time-quarter-hour-dark.png new file mode 100644 index 0000000..1537de6 Binary files /dev/null and b/artifacts/icon-family-v1/png/time-quarter-hour-dark.png differ diff --git a/artifacts/icon-family-v1/png/time-quarter-hour-light.png b/artifacts/icon-family-v1/png/time-quarter-hour-light.png new file mode 100644 index 0000000..5b70cd9 Binary files /dev/null and b/artifacts/icon-family-v1/png/time-quarter-hour-light.png differ diff --git a/artifacts/icon-family-v1/png/time-quarter-light.png b/artifacts/icon-family-v1/png/time-quarter-light.png new file mode 100644 index 0000000..a2e154d Binary files /dev/null and b/artifacts/icon-family-v1/png/time-quarter-light.png differ diff --git a/artifacts/icon-family-v1/png/time-start-dark.png b/artifacts/icon-family-v1/png/time-start-dark.png new file mode 100644 index 0000000..56f63d8 Binary files /dev/null and b/artifacts/icon-family-v1/png/time-start-dark.png differ diff --git a/artifacts/icon-family-v1/png/time-start-light.png b/artifacts/icon-family-v1/png/time-start-light.png new file mode 100644 index 0000000..153c1a8 Binary files /dev/null and b/artifacts/icon-family-v1/png/time-start-light.png differ diff --git a/artifacts/icon-family-v1/png/time-three-quarter-dark.png b/artifacts/icon-family-v1/png/time-three-quarter-dark.png new file mode 100644 index 0000000..f1cb221 Binary files /dev/null and b/artifacts/icon-family-v1/png/time-three-quarter-dark.png differ diff --git a/artifacts/icon-family-v1/png/time-three-quarter-light.png b/artifacts/icon-family-v1/png/time-three-quarter-light.png new file mode 100644 index 0000000..98f3871 Binary files /dev/null and b/artifacts/icon-family-v1/png/time-three-quarter-light.png differ diff --git a/artifacts/icon-family-v1/png/time-unknown-dark.png b/artifacts/icon-family-v1/png/time-unknown-dark.png new file mode 100644 index 0000000..49f4904 Binary files /dev/null and b/artifacts/icon-family-v1/png/time-unknown-dark.png differ diff --git a/artifacts/icon-family-v1/png/time-unknown-light.png b/artifacts/icon-family-v1/png/time-unknown-light.png new file mode 100644 index 0000000..65fc599 Binary files /dev/null and b/artifacts/icon-family-v1/png/time-unknown-light.png differ diff --git a/artifacts/icon-family-v1/png/vpn-connected-dark.png b/artifacts/icon-family-v1/png/vpn-connected-dark.png new file mode 100644 index 0000000..f6150d8 Binary files /dev/null and b/artifacts/icon-family-v1/png/vpn-connected-dark.png differ diff --git a/artifacts/icon-family-v1/png/vpn-connected-light.png b/artifacts/icon-family-v1/png/vpn-connected-light.png new file mode 100644 index 0000000..2d22008 Binary files /dev/null and b/artifacts/icon-family-v1/png/vpn-connected-light.png differ diff --git a/artifacts/icon-family-v1/png/vpn-connecting-dark.png b/artifacts/icon-family-v1/png/vpn-connecting-dark.png new file mode 100644 index 0000000..47a191b Binary files /dev/null and b/artifacts/icon-family-v1/png/vpn-connecting-dark.png differ diff --git a/artifacts/icon-family-v1/png/vpn-connecting-light.png b/artifacts/icon-family-v1/png/vpn-connecting-light.png new file mode 100644 index 0000000..a656b58 Binary files /dev/null and b/artifacts/icon-family-v1/png/vpn-connecting-light.png differ diff --git a/artifacts/icon-family-v1/png/vpn-error-dark.png b/artifacts/icon-family-v1/png/vpn-error-dark.png new file mode 100644 index 0000000..c1b60ea Binary files /dev/null and b/artifacts/icon-family-v1/png/vpn-error-dark.png differ diff --git a/artifacts/icon-family-v1/png/vpn-error-light.png b/artifacts/icon-family-v1/png/vpn-error-light.png new file mode 100644 index 0000000..644b18b Binary files /dev/null and b/artifacts/icon-family-v1/png/vpn-error-light.png differ diff --git a/artifacts/icon-family-v1/png/vpn-network-dark.png b/artifacts/icon-family-v1/png/vpn-network-dark.png new file mode 100644 index 0000000..3d89eff Binary files /dev/null and b/artifacts/icon-family-v1/png/vpn-network-dark.png differ diff --git a/artifacts/icon-family-v1/png/vpn-network-light.png b/artifacts/icon-family-v1/png/vpn-network-light.png new file mode 100644 index 0000000..315379e Binary files /dev/null and b/artifacts/icon-family-v1/png/vpn-network-light.png differ diff --git a/artifacts/icon-family-v1/png/vpn-off-dark.png b/artifacts/icon-family-v1/png/vpn-off-dark.png new file mode 100644 index 0000000..148f804 Binary files /dev/null and b/artifacts/icon-family-v1/png/vpn-off-dark.png differ diff --git a/artifacts/icon-family-v1/png/vpn-off-light.png b/artifacts/icon-family-v1/png/vpn-off-light.png new file mode 100644 index 0000000..eb86e11 Binary files /dev/null and b/artifacts/icon-family-v1/png/vpn-off-light.png differ diff --git a/artifacts/icon-family-v1/png/vpn-reconnect-dark.png b/artifacts/icon-family-v1/png/vpn-reconnect-dark.png new file mode 100644 index 0000000..5d32551 Binary files /dev/null and b/artifacts/icon-family-v1/png/vpn-reconnect-dark.png differ diff --git a/artifacts/icon-family-v1/png/vpn-reconnect-light.png b/artifacts/icon-family-v1/png/vpn-reconnect-light.png new file mode 100644 index 0000000..3c850d8 Binary files /dev/null and b/artifacts/icon-family-v1/png/vpn-reconnect-light.png differ diff --git a/artifacts/icon-family-v1/png/vpn-setup-dark.png b/artifacts/icon-family-v1/png/vpn-setup-dark.png new file mode 100644 index 0000000..392fe13 Binary files /dev/null and b/artifacts/icon-family-v1/png/vpn-setup-dark.png differ diff --git a/artifacts/icon-family-v1/png/vpn-setup-light.png b/artifacts/icon-family-v1/png/vpn-setup-light.png new file mode 100644 index 0000000..4b0b87a Binary files /dev/null and b/artifacts/icon-family-v1/png/vpn-setup-light.png differ diff --git a/artifacts/icon-family-v1/png/vpn-unknown-dark.png b/artifacts/icon-family-v1/png/vpn-unknown-dark.png new file mode 100644 index 0000000..2ac38a7 Binary files /dev/null and b/artifacts/icon-family-v1/png/vpn-unknown-dark.png differ diff --git a/artifacts/icon-family-v1/png/vpn-unknown-light.png b/artifacts/icon-family-v1/png/vpn-unknown-light.png new file mode 100644 index 0000000..1038690 Binary files /dev/null and b/artifacts/icon-family-v1/png/vpn-unknown-light.png differ diff --git a/artifacts/icon-family-v1/png/vpn-unlock-dark.png b/artifacts/icon-family-v1/png/vpn-unlock-dark.png new file mode 100644 index 0000000..06418c2 Binary files /dev/null and b/artifacts/icon-family-v1/png/vpn-unlock-dark.png differ diff --git a/artifacts/icon-family-v1/png/vpn-unlock-light.png b/artifacts/icon-family-v1/png/vpn-unlock-light.png new file mode 100644 index 0000000..d528a5a Binary files /dev/null and b/artifacts/icon-family-v1/png/vpn-unlock-light.png differ diff --git a/artifacts/icon-family-v1/svg/action-cancel-dark.svg b/artifacts/icon-family-v1/svg/action-cancel-dark.svg new file mode 100644 index 0000000..21b2892 --- /dev/null +++ b/artifacts/icon-family-v1/svg/action-cancel-dark.svg @@ -0,0 +1 @@ +Отменить \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/action-cancel-light.svg b/artifacts/icon-family-v1/svg/action-cancel-light.svg new file mode 100644 index 0000000..4ed3fa1 --- /dev/null +++ b/artifacts/icon-family-v1/svg/action-cancel-light.svg @@ -0,0 +1 @@ +Отменить \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/action-connect-dark.svg b/artifacts/icon-family-v1/svg/action-connect-dark.svg new file mode 100644 index 0000000..e194dd2 --- /dev/null +++ b/artifacts/icon-family-v1/svg/action-connect-dark.svg @@ -0,0 +1 @@ +Подключить \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/action-connect-light.svg b/artifacts/icon-family-v1/svg/action-connect-light.svg new file mode 100644 index 0000000..a5e1c3f --- /dev/null +++ b/artifacts/icon-family-v1/svg/action-connect-light.svg @@ -0,0 +1 @@ +Подключить \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/action-copy-dark.svg b/artifacts/icon-family-v1/svg/action-copy-dark.svg new file mode 100644 index 0000000..ffd19d3 --- /dev/null +++ b/artifacts/icon-family-v1/svg/action-copy-dark.svg @@ -0,0 +1 @@ +Копировать IP \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/action-copy-light.svg b/artifacts/icon-family-v1/svg/action-copy-light.svg new file mode 100644 index 0000000..450d978 --- /dev/null +++ b/artifacts/icon-family-v1/svg/action-copy-light.svg @@ -0,0 +1 @@ +Копировать IP \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/action-disconnect-dark.svg b/artifacts/icon-family-v1/svg/action-disconnect-dark.svg new file mode 100644 index 0000000..4c094a8 --- /dev/null +++ b/artifacts/icon-family-v1/svg/action-disconnect-dark.svg @@ -0,0 +1 @@ +Отключить \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/action-disconnect-light.svg b/artifacts/icon-family-v1/svg/action-disconnect-light.svg new file mode 100644 index 0000000..4a2486e --- /dev/null +++ b/artifacts/icon-family-v1/svg/action-disconnect-light.svg @@ -0,0 +1 @@ +Отключить \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/action-import-dark.svg b/artifacts/icon-family-v1/svg/action-import-dark.svg new file mode 100644 index 0000000..3530251 --- /dev/null +++ b/artifacts/icon-family-v1/svg/action-import-dark.svg @@ -0,0 +1 @@ +Импорт ключа \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/action-import-light.svg b/artifacts/icon-family-v1/svg/action-import-light.svg new file mode 100644 index 0000000..f0cc35f --- /dev/null +++ b/artifacts/icon-family-v1/svg/action-import-light.svg @@ -0,0 +1 @@ +Импорт ключа \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/action-logs-dark.svg b/artifacts/icon-family-v1/svg/action-logs-dark.svg new file mode 100644 index 0000000..736a22a --- /dev/null +++ b/artifacts/icon-family-v1/svg/action-logs-dark.svg @@ -0,0 +1 @@ +Логи \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/action-logs-light.svg b/artifacts/icon-family-v1/svg/action-logs-light.svg new file mode 100644 index 0000000..31ef4fd --- /dev/null +++ b/artifacts/icon-family-v1/svg/action-logs-light.svg @@ -0,0 +1 @@ +Логи \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/action-password-dark.svg b/artifacts/icon-family-v1/svg/action-password-dark.svg new file mode 100644 index 0000000..03d0d88 --- /dev/null +++ b/artifacts/icon-family-v1/svg/action-password-dark.svg @@ -0,0 +1 @@ +Логин и пароль \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/action-password-light.svg b/artifacts/icon-family-v1/svg/action-password-light.svg new file mode 100644 index 0000000..7d5d803 --- /dev/null +++ b/artifacts/icon-family-v1/svg/action-password-light.svg @@ -0,0 +1 @@ +Логин и пароль \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/action-quit-dark.svg b/artifacts/icon-family-v1/svg/action-quit-dark.svg new file mode 100644 index 0000000..5931470 --- /dev/null +++ b/artifacts/icon-family-v1/svg/action-quit-dark.svg @@ -0,0 +1 @@ +Выход \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/action-quit-light.svg b/artifacts/icon-family-v1/svg/action-quit-light.svg new file mode 100644 index 0000000..9e46d86 --- /dev/null +++ b/artifacts/icon-family-v1/svg/action-quit-light.svg @@ -0,0 +1 @@ +Выход \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/action-refresh-dark.svg b/artifacts/icon-family-v1/svg/action-refresh-dark.svg new file mode 100644 index 0000000..690e29c --- /dev/null +++ b/artifacts/icon-family-v1/svg/action-refresh-dark.svg @@ -0,0 +1 @@ +Обновить статус \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/action-refresh-light.svg b/artifacts/icon-family-v1/svg/action-refresh-light.svg new file mode 100644 index 0000000..f840a9d --- /dev/null +++ b/artifacts/icon-family-v1/svg/action-refresh-light.svg @@ -0,0 +1 @@ +Обновить статус \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/format-clock-dark.svg b/artifacts/icon-family-v1/svg/format-clock-dark.svg new file mode 100644 index 0000000..f10e260 --- /dev/null +++ b/artifacts/icon-family-v1/svg/format-clock-dark.svg @@ -0,0 +1 @@ +Щит-часы \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/format-clock-light.svg b/artifacts/icon-family-v1/svg/format-clock-light.svg new file mode 100644 index 0000000..7a27388 --- /dev/null +++ b/artifacts/icon-family-v1/svg/format-clock-light.svg @@ -0,0 +1 @@ +Щит-часы \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/format-ring-dark.svg b/artifacts/icon-family-v1/svg/format-ring-dark.svg new file mode 100644 index 0000000..c9f5b57 --- /dev/null +++ b/artifacts/icon-family-v1/svg/format-ring-dark.svg @@ -0,0 +1 @@ +Кольцо \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/format-ring-light.svg b/artifacts/icon-family-v1/svg/format-ring-light.svg new file mode 100644 index 0000000..2ad8d25 --- /dev/null +++ b/artifacts/icon-family-v1/svg/format-ring-light.svg @@ -0,0 +1 @@ +Кольцо \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/format-shield-dark.svg b/artifacts/icon-family-v1/svg/format-shield-dark.svg new file mode 100644 index 0000000..b8c2945 --- /dev/null +++ b/artifacts/icon-family-v1/svg/format-shield-dark.svg @@ -0,0 +1 @@ +Щит с прогрессом \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/format-shield-light.svg b/artifacts/icon-family-v1/svg/format-shield-light.svg new file mode 100644 index 0000000..044cda2 --- /dev/null +++ b/artifacts/icon-family-v1/svg/format-shield-light.svg @@ -0,0 +1 @@ +Щит с прогрессом \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/format-time-dark.svg b/artifacts/icon-family-v1/svg/format-time-dark.svg new file mode 100644 index 0000000..e821901 --- /dev/null +++ b/artifacts/icon-family-v1/svg/format-time-dark.svg @@ -0,0 +1 @@ +Щит + время2:18 \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/format-time-light.svg b/artifacts/icon-family-v1/svg/format-time-light.svg new file mode 100644 index 0000000..5a4ca85 --- /dev/null +++ b/artifacts/icon-family-v1/svg/format-time-light.svg @@ -0,0 +1 @@ +Щит + время2:18 \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/source-auto-dark.svg b/artifacts/icon-family-v1/svg/source-auto-dark.svg new file mode 100644 index 0000000..23aa703 --- /dev/null +++ b/artifacts/icon-family-v1/svg/source-auto-dark.svg @@ -0,0 +1 @@ +Автоматически \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/source-auto-light.svg b/artifacts/icon-family-v1/svg/source-auto-light.svg new file mode 100644 index 0000000..d614e9e --- /dev/null +++ b/artifacts/icon-family-v1/svg/source-auto-light.svg @@ -0,0 +1 @@ +Автоматически \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/source-local-dark.svg b/artifacts/icon-family-v1/svg/source-local-dark.svg new file mode 100644 index 0000000..55a3acf --- /dev/null +++ b/artifacts/icon-family-v1/svg/source-local-dark.svg @@ -0,0 +1 @@ +Ключ на Mac \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/source-local-light.svg b/artifacts/icon-family-v1/svg/source-local-light.svg new file mode 100644 index 0000000..bee121d --- /dev/null +++ b/artifacts/icon-family-v1/svg/source-local-light.svg @@ -0,0 +1 @@ +Ключ на Mac \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/source-manual-dark.svg b/artifacts/icon-family-v1/svg/source-manual-dark.svg new file mode 100644 index 0000000..93a9bbc --- /dev/null +++ b/artifacts/icon-family-v1/svg/source-manual-dark.svg @@ -0,0 +1 @@ +Вручную \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/source-manual-light.svg b/artifacts/icon-family-v1/svg/source-manual-light.svg new file mode 100644 index 0000000..e50acd6 --- /dev/null +++ b/artifacts/icon-family-v1/svg/source-manual-light.svg @@ -0,0 +1 @@ +Вручную \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/source-vault-dark.svg b/artifacts/icon-family-v1/svg/source-vault-dark.svg new file mode 100644 index 0000000..2344ddb --- /dev/null +++ b/artifacts/icon-family-v1/svg/source-vault-dark.svg @@ -0,0 +1 @@ +Bitwarden \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/source-vault-light.svg b/artifacts/icon-family-v1/svg/source-vault-light.svg new file mode 100644 index 0000000..cd83f1a --- /dev/null +++ b/artifacts/icon-family-v1/svg/source-vault-light.svg @@ -0,0 +1 @@ +Bitwarden \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/time-expired-dark.svg b/artifacts/icon-family-v1/svg/time-expired-dark.svg new file mode 100644 index 0000000..63df113 --- /dev/null +++ b/artifacts/icon-family-v1/svg/time-expired-dark.svg @@ -0,0 +1 @@ +Срок истёк \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/time-expired-light.svg b/artifacts/icon-family-v1/svg/time-expired-light.svg new file mode 100644 index 0000000..33aacb1 --- /dev/null +++ b/artifacts/icon-family-v1/svg/time-expired-light.svg @@ -0,0 +1 @@ +Срок истёк \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/time-half-dark.svg b/artifacts/icon-family-v1/svg/time-half-dark.svg new file mode 100644 index 0000000..a8ca7e3 --- /dev/null +++ b/artifacts/icon-family-v1/svg/time-half-dark.svg @@ -0,0 +1 @@ +50% прошло \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/time-half-light.svg b/artifacts/icon-family-v1/svg/time-half-light.svg new file mode 100644 index 0000000..850520c --- /dev/null +++ b/artifacts/icon-family-v1/svg/time-half-light.svg @@ -0,0 +1 @@ +50% прошло \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/time-hour-dark.svg b/artifacts/icon-family-v1/svg/time-hour-dark.svg new file mode 100644 index 0000000..0b510e0 --- /dev/null +++ b/artifacts/icon-family-v1/svg/time-hour-dark.svg @@ -0,0 +1 @@ +Остался час \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/time-hour-light.svg b/artifacts/icon-family-v1/svg/time-hour-light.svg new file mode 100644 index 0000000..c6a231f --- /dev/null +++ b/artifacts/icon-family-v1/svg/time-hour-light.svg @@ -0,0 +1 @@ +Остался час \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/time-quarter-dark.svg b/artifacts/icon-family-v1/svg/time-quarter-dark.svg new file mode 100644 index 0000000..26c0786 --- /dev/null +++ b/artifacts/icon-family-v1/svg/time-quarter-dark.svg @@ -0,0 +1 @@ +25% прошло \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/time-quarter-hour-dark.svg b/artifacts/icon-family-v1/svg/time-quarter-hour-dark.svg new file mode 100644 index 0000000..fe3b2b6 --- /dev/null +++ b/artifacts/icon-family-v1/svg/time-quarter-hour-dark.svg @@ -0,0 +1 @@ +Осталось 15 мин \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/time-quarter-hour-light.svg b/artifacts/icon-family-v1/svg/time-quarter-hour-light.svg new file mode 100644 index 0000000..33d247d --- /dev/null +++ b/artifacts/icon-family-v1/svg/time-quarter-hour-light.svg @@ -0,0 +1 @@ +Осталось 15 мин \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/time-quarter-light.svg b/artifacts/icon-family-v1/svg/time-quarter-light.svg new file mode 100644 index 0000000..e7f830b --- /dev/null +++ b/artifacts/icon-family-v1/svg/time-quarter-light.svg @@ -0,0 +1 @@ +25% прошло \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/time-start-dark.svg b/artifacts/icon-family-v1/svg/time-start-dark.svg new file mode 100644 index 0000000..3ff7020 --- /dev/null +++ b/artifacts/icon-family-v1/svg/time-start-dark.svg @@ -0,0 +1 @@ +Начало \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/time-start-light.svg b/artifacts/icon-family-v1/svg/time-start-light.svg new file mode 100644 index 0000000..7e56012 --- /dev/null +++ b/artifacts/icon-family-v1/svg/time-start-light.svg @@ -0,0 +1 @@ +Начало \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/time-three-quarter-dark.svg b/artifacts/icon-family-v1/svg/time-three-quarter-dark.svg new file mode 100644 index 0000000..f467dc7 --- /dev/null +++ b/artifacts/icon-family-v1/svg/time-three-quarter-dark.svg @@ -0,0 +1 @@ +75% прошло \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/time-three-quarter-light.svg b/artifacts/icon-family-v1/svg/time-three-quarter-light.svg new file mode 100644 index 0000000..50bff36 --- /dev/null +++ b/artifacts/icon-family-v1/svg/time-three-quarter-light.svg @@ -0,0 +1 @@ +75% прошло \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/time-unknown-dark.svg b/artifacts/icon-family-v1/svg/time-unknown-dark.svg new file mode 100644 index 0000000..44d6fbf --- /dev/null +++ b/artifacts/icon-family-v1/svg/time-unknown-dark.svg @@ -0,0 +1 @@ +Срок неизвестен \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/time-unknown-light.svg b/artifacts/icon-family-v1/svg/time-unknown-light.svg new file mode 100644 index 0000000..5f3434a --- /dev/null +++ b/artifacts/icon-family-v1/svg/time-unknown-light.svg @@ -0,0 +1 @@ +Срок неизвестен \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/vpn-connected-dark.svg b/artifacts/icon-family-v1/svg/vpn-connected-dark.svg new file mode 100644 index 0000000..6d1069e --- /dev/null +++ b/artifacts/icon-family-v1/svg/vpn-connected-dark.svg @@ -0,0 +1 @@ +Подключён \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/vpn-connected-light.svg b/artifacts/icon-family-v1/svg/vpn-connected-light.svg new file mode 100644 index 0000000..774bd6b --- /dev/null +++ b/artifacts/icon-family-v1/svg/vpn-connected-light.svg @@ -0,0 +1 @@ +Подключён \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/vpn-connecting-dark.svg b/artifacts/icon-family-v1/svg/vpn-connecting-dark.svg new file mode 100644 index 0000000..da29a25 --- /dev/null +++ b/artifacts/icon-family-v1/svg/vpn-connecting-dark.svg @@ -0,0 +1 @@ +Подключение \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/vpn-connecting-light.svg b/artifacts/icon-family-v1/svg/vpn-connecting-light.svg new file mode 100644 index 0000000..8d85b20 --- /dev/null +++ b/artifacts/icon-family-v1/svg/vpn-connecting-light.svg @@ -0,0 +1 @@ +Подключение \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/vpn-error-dark.svg b/artifacts/icon-family-v1/svg/vpn-error-dark.svg new file mode 100644 index 0000000..66b5b05 --- /dev/null +++ b/artifacts/icon-family-v1/svg/vpn-error-dark.svg @@ -0,0 +1 @@ +Ошибка \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/vpn-error-light.svg b/artifacts/icon-family-v1/svg/vpn-error-light.svg new file mode 100644 index 0000000..d21f449 --- /dev/null +++ b/artifacts/icon-family-v1/svg/vpn-error-light.svg @@ -0,0 +1 @@ +Ошибка \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/vpn-network-dark.svg b/artifacts/icon-family-v1/svg/vpn-network-dark.svg new file mode 100644 index 0000000..bd56bf9 --- /dev/null +++ b/artifacts/icon-family-v1/svg/vpn-network-dark.svg @@ -0,0 +1 @@ +Проблема сети \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/vpn-network-light.svg b/artifacts/icon-family-v1/svg/vpn-network-light.svg new file mode 100644 index 0000000..28e3850 --- /dev/null +++ b/artifacts/icon-family-v1/svg/vpn-network-light.svg @@ -0,0 +1 @@ +Проблема сети \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/vpn-off-dark.svg b/artifacts/icon-family-v1/svg/vpn-off-dark.svg new file mode 100644 index 0000000..c042ceb --- /dev/null +++ b/artifacts/icon-family-v1/svg/vpn-off-dark.svg @@ -0,0 +1 @@ +Отключён \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/vpn-off-light.svg b/artifacts/icon-family-v1/svg/vpn-off-light.svg new file mode 100644 index 0000000..e115160 --- /dev/null +++ b/artifacts/icon-family-v1/svg/vpn-off-light.svg @@ -0,0 +1 @@ +Отключён \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/vpn-reconnect-dark.svg b/artifacts/icon-family-v1/svg/vpn-reconnect-dark.svg new file mode 100644 index 0000000..672785a --- /dev/null +++ b/artifacts/icon-family-v1/svg/vpn-reconnect-dark.svg @@ -0,0 +1 @@ +Переподключение \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/vpn-reconnect-light.svg b/artifacts/icon-family-v1/svg/vpn-reconnect-light.svg new file mode 100644 index 0000000..2476c3c --- /dev/null +++ b/artifacts/icon-family-v1/svg/vpn-reconnect-light.svg @@ -0,0 +1 @@ +Переподключение \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/vpn-setup-dark.svg b/artifacts/icon-family-v1/svg/vpn-setup-dark.svg new file mode 100644 index 0000000..3a70a57 --- /dev/null +++ b/artifacts/icon-family-v1/svg/vpn-setup-dark.svg @@ -0,0 +1 @@ +Нужна настройка \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/vpn-setup-light.svg b/artifacts/icon-family-v1/svg/vpn-setup-light.svg new file mode 100644 index 0000000..cee8367 --- /dev/null +++ b/artifacts/icon-family-v1/svg/vpn-setup-light.svg @@ -0,0 +1 @@ +Нужна настройка \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/vpn-unknown-dark.svg b/artifacts/icon-family-v1/svg/vpn-unknown-dark.svg new file mode 100644 index 0000000..7dc67ca --- /dev/null +++ b/artifacts/icon-family-v1/svg/vpn-unknown-dark.svg @@ -0,0 +1 @@ +Статус неизвестен \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/vpn-unknown-light.svg b/artifacts/icon-family-v1/svg/vpn-unknown-light.svg new file mode 100644 index 0000000..4007c70 --- /dev/null +++ b/artifacts/icon-family-v1/svg/vpn-unknown-light.svg @@ -0,0 +1 @@ +Статус неизвестен \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/vpn-unlock-dark.svg b/artifacts/icon-family-v1/svg/vpn-unlock-dark.svg new file mode 100644 index 0000000..26a0eea --- /dev/null +++ b/artifacts/icon-family-v1/svg/vpn-unlock-dark.svg @@ -0,0 +1 @@ +Ожидаем вход \ No newline at end of file diff --git a/artifacts/icon-family-v1/svg/vpn-unlock-light.svg b/artifacts/icon-family-v1/svg/vpn-unlock-light.svg new file mode 100644 index 0000000..275f17d --- /dev/null +++ b/artifacts/icon-family-v1/svg/vpn-unlock-light.svg @@ -0,0 +1 @@ +Ожидаем вход \ No newline at end of file diff --git a/bin/lemana-vpn-import-yandex.py b/bin/lemana-vpn-import-yandex.py new file mode 100644 index 0000000..9c05a82 --- /dev/null +++ b/bin/lemana-vpn-import-yandex.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +"""Import one verified TOTP from a local Yandex Key backup into Keychain.""" +import argparse +import base64 +import binascii +from contextlib import ExitStack +import getpass +import json +import os +from pathlib import Path +import re +import shlex +import signal +import stat +import sys +import tempfile +import unicodedata +from urllib.parse import parse_qs, urlsplit + +MAX_BACKUP_BYTES = 4 * 1024 * 1024 + + +class ImportFailure(ValueError): + """Only fixed, secret-free messages may cross the CLI boundary.""" + + +def display_name(value, secrets=()): + value = "".join(c for c in value if not unicodedata.category(c).startswith("C")) + if "otpauth:" in value.lower() or any(secret and secret in value.upper() for secret in secrets): + return "" + return value[:120] + + +def read_backup(path): + try: + # Nonblocking open also lets us reject a mistakenly selected FIFO/device. + fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK) + with os.fdopen(fd, "rb") as stream: + if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode): + raise ImportFailure("Выберите обычный файл резервной копии.") + data = stream.read(MAX_BACKUP_BYTES + 1) + if len(data) > MAX_BACKUP_BYTES: + raise ImportFailure("Файл превышает лимит импорта 4 МиБ.") + entries = json.loads(data.decode("utf-8-sig")) + except (OSError, UnicodeError, ValueError) as error: + if isinstance(error, ImportFailure): + raise + raise ImportFailure("Не удалось прочитать локальный JSON-экспорт Яндекс Ключа.") from None + if not isinstance(entries, list) or not 1 <= len(entries) <= 1000: + raise ImportFailure("Ожидается резервная копия с 1–1000 записями.") + if any(not isinstance(entry, dict) or not isinstance(entry.get("techInfo"), str) for entry in entries): + raise ImportFailure("Неверная структура резервной копии Яндекс Ключа.") + return entries + + +def normalize_secret(value): + if not isinstance(value, str): + raise ImportFailure("Запись не содержит корректного секрета 2FA.") + value = value.strip().upper().rstrip("=") + if not re.fullmatch(r"[A-Z2-7]{16,1024}", value): + raise ImportFailure("Некорректный BASE32-секрет в выбранной записи.") + try: + decoded = base64.b32decode(value + "=" * (-len(value) % 8)) + except binascii.Error: + raise ImportFailure("Некорректный BASE32-секрет в выбранной записи.") from None + if base64.b32encode(decoded).decode().rstrip("=") != value: + raise ImportFailure("Некорректный BASE32-секрет в выбранной записи.") + return value + + +def selected_secret(entry): + try: + uri = urlsplit(entry["techInfo"]) + query = parse_qs(uri.query, keep_blank_values=True, max_num_fields=100) + except ValueError: + raise ImportFailure("Некорректные параметры OTP в выбранной записи.") from None + if uri.scheme != "otpauth" or uri.netloc != "totp" or uri.fragment: + raise ImportFailure("Поддерживается только TOTP; HOTP и собственные коды Яндекса не поддерживаются.") + if set(query) - {"secret", "algorithm", "digits", "period", "issuer", "account_id", "created_at", "creation_type", "image", "name", "uid"}: + raise ImportFailure("Выбранная запись содержит неподдерживаемые параметры OTP.") + for key in ("secret", "algorithm", "digits", "period"): + if key in query and len(query[key]) != 1: + raise ImportFailure("В записи повторяются параметры OTP.") + if (query.get("algorithm", ["SHA1"])[0].upper() != "SHA1" + or query.get("digits", ["6"])[0] != "6" + or query.get("period", ["30"])[0] != "30" + or "counter" in query): + raise ImportFailure("Поддерживается только TOTP: SHA1, 6 цифр, период 30 секунд.") + secret = normalize_secret(query.get("secret", [""])[0]) + if "secret" in entry and normalize_secret(entry["secret"]) != secret: + raise ImportFailure("Секрет и параметры выбранной записи не совпадают.") + return secret + + +def activate(secret, username, config_file): + import keyring + + # Stage settings before touching Keychain; retain all unrelated local settings. + text = config_file.read_text() if config_file.exists() else "" + saved = None + for line in text.splitlines(): + match = re.fullmatch(r'(?:export[ \t]+)?LEMANA_VPN_USERNAME=("(?:\\.|[^"\\])*"|\'[^\']*\'|[^\n]*)[ \t]*(?:#.*)?', line) + if match: + raw = match[1] + if raw.startswith('"'): + saved = re.sub(r'\\([$`"\\])', lambda m: m[1], raw[1:-1]) + else: + values = shlex.split(raw, comments=True) + saved = values[0] if len(values) == 1 else None + if saved != username: + raise ImportFailure("Сохранённый LDAP username изменился или не настроен. Сначала выполните vpn --configure-login.") + original_text = text + for key, value in (("LEMANA_VPN_MODE", "keychain-auto"), ("LEMANA_VPN_LOCAL_OTP", "1"), + ("LEMANA_VPN_AUTO_SOURCE", "keychain"), ("LEMANA_VPN_AUTH_PROFILE", "auto"), + ("LEMANA_VPN_CREDENTIAL_SOURCE", "keychain"), ("LEMANA_VPN_USE_BITWARDEN", "0")): + assignment = f'{key}="{value}"\n' + pattern = r"(?m)^(?:export[ \t]+)?" + key + r"=[^\n]*(?:\n|$)" + text = re.sub(pattern, "", text) + text = text.rstrip("\n") + "\n" + assignment + config_file.parent.mkdir(parents=True, exist_ok=True) + fd, staging = tempfile.mkstemp(prefix=".otp-env-", dir=config_file.parent) + try: + with os.fdopen(fd, "w") as stream: + stream.write(text) + account = "totp/" + username + previous = keyring.get_password("openconnect-lite", account) + try: + keyring.set_password("openconnect-lite", account, secret) + if keyring.get_password("openconnect-lite", account) != secret: + raise ImportFailure("Keychain не подтвердил сохранение секрета; локальный код не включён.") + if config_file.read_text() != original_text: + raise ImportFailure("Настройки изменились во время импорта; повторите импорт.") + os.replace(staging, config_file) + except BaseException: + if not os.path.exists(staging): + # replace is the commit point: both stores now contain the new state. + raise ImportFailure("Настройка уже сохранена. Проверьте её командой vpn --status.") from None + # An interrupted activation must not silently replace working credentials. + try: + if previous is None: + if keyring.get_password("openconnect-lite", account) is not None: + keyring.delete_password("openconnect-lite", account) + else: + keyring.set_password("openconnect-lite", account, previous) + if keyring.get_password("openconnect-lite", account) != previous: + raise RuntimeError + except BaseException: + raise ImportFailure("Импорт не завершён, вернуть прежний секрет не удалось. Проверьте запись 2FA в Keychain.") from None + raise + finally: + if os.path.exists(staging): + os.unlink(staging) + + +def configure(username, config_file, ask, ask_secret, say): + import pyotp + + say("В Яндекс Ключе: Настройки → Резервная копия данных → Сохранить в файл.") + entries = read_backup(Path(ask("Путь к файлу резервной копии: ").strip()).expanduser()) + secrets = set() + for entry in entries: + if isinstance(entry.get("secret"), str) and entry["secret"]: + secrets.add(entry["secret"].strip().upper().rstrip("=")) + try: + for value in parse_qs(urlsplit(entry["techInfo"]).query, max_num_fields=100).get("secret", []): + if value: + secrets.add(value.strip().upper().rstrip("=")) + except ValueError: + pass + labels = [] + for index, entry in enumerate(entries, 1): + name = entry.get("name", "") + label = display_name(name, secrets) if isinstance(name, str) else "" + labels.append(label or f"Запись {index}") + say(f"{index}) {labels[-1]}") + choice = ask("Номер записи Lemana/VPN (Enter — отмена): ").strip() + if not re.fullmatch(r"[0-9]{1,4}", choice) or not 1 <= int(choice) <= len(entries): + raise ImportFailure("Запись не выбрана; настройки не изменены.") + entry = entries[int(choice) - 1] + secret = selected_secret(entry) + say(f"Запись: {labels[int(choice) - 1]}; LDAP: {display_name(username)}.") + say("Будет включён автоматический вход с ключом на этом Mac: скрытый браузер, подстановка данных и отправка формы без Bitwarden.") + code = ask_secret("Введите текущие 6 цифр из этой записи на телефоне для проверки и сохранения: ").strip() + if not re.fullmatch(r"[0-9]{6}", code) or not pyotp.TOTP(secret).verify(code, valid_window=1): + raise ImportFailure("Код не совпал. Проверьте выбранную запись и время на устройствах; настройки не изменены.") + activate(secret, username, config_file) + say("Готово: одноразовый код сохранён в Keychain и будет подставляться при следующем подключении.") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--username", required=True) + parser.add_argument("--config", required=True, type=Path) + args = parser.parse_args() + + def interrupted(_signum, _frame): + raise KeyboardInterrupt + + signal.signal(signal.SIGTERM, interrupted) + try: + import toml + from xdg.BaseDirectory import load_first_config + + # Match the actual openconnect-lite read path, including XDG overrides. + config_dir = load_first_config("openconnect-lite") + if not config_dir or toml.load(Path(config_dir) / "config.toml").get("credentials", {}).get("username") != args.username: + raise ImportFailure("LDAP username в профиле SSO отличается. Согласуйте логин через vpn --configure-login или установщик.") + with ExitStack() as handles: + terminal_input, terminal = sys.stdin, sys.stderr + if not sys.stdin.isatty(): + terminal_fd = os.open("/dev/tty", os.O_RDWR | os.O_NOCTTY) + terminal_input = handles.enter_context(os.fdopen(os.dup(terminal_fd), "r", encoding="utf-8")) + terminal = handles.enter_context(os.fdopen(terminal_fd, "w", encoding="utf-8")) + def ask(prompt): + terminal.write(prompt) + terminal.flush() + line = terminal_input.readline() + if not line: + raise EOFError + return line.rstrip("\n") + + configure(args.username, args.config, ask, + lambda prompt: getpass.getpass(prompt, stream=terminal), + lambda message: print(message, file=terminal, flush=True)) + except ImportFailure as error: + print(str(error), file=sys.stderr) + return 1 + except (EOFError, KeyboardInterrupt): + print("Импорт отменён.", file=sys.stderr) + return 1 + except Exception: + # Backend/JSON exceptions may contain sensitive data; never print them. + print("Не удалось завершить импорт. Запустите команду в Terminal и проверьте доступ к файлу и Keychain.", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bin/vpn-lemanapro.sh b/bin/vpn-lemanapro.sh index e14a1c6..bc9b094 100755 --- a/bin/vpn-lemanapro.sh +++ b/bin/vpn-lemanapro.sh @@ -7,11 +7,15 @@ 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_MODE="${LEMANA_VPN_MODE+x}${LEMANA_VPN_MODE-}" _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-}" _ENV_LEMANA_VPN_DNS_CLEANUP="${LEMANA_VPN_DNS_CLEANUP+x}${LEMANA_VPN_DNS_CLEANUP-}" +_ENV_LEMANA_VPN_LOG_MAX_BYTES="${LEMANA_VPN_LOG_MAX_BYTES+x}${LEMANA_VPN_LOG_MAX_BYTES-}" +_ENV_LEMANA_VPN_LOCAL_OTP="${LEMANA_VPN_LOCAL_OTP+x}${LEMANA_VPN_LOCAL_OTP-}" +_ENV_LEMANA_VPN_AUTO_SOURCE="${LEMANA_VPN_AUTO_SOURCE+x}${LEMANA_VPN_AUTO_SOURCE-}" if [[ -f "$CONFIG_FILE" ]]; then # shellcheck disable=SC1090 @@ -20,11 +24,15 @@ 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_MODE:0:1}" == "x" ]] && LEMANA_VPN_MODE="${_ENV_LEMANA_VPN_MODE: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}" [[ "${_ENV_LEMANA_VPN_DNS_CLEANUP:0:1}" == "x" ]] && LEMANA_VPN_DNS_CLEANUP="${_ENV_LEMANA_VPN_DNS_CLEANUP:1}" +[[ "${_ENV_LEMANA_VPN_LOG_MAX_BYTES:0:1}" == "x" ]] && LEMANA_VPN_LOG_MAX_BYTES="${_ENV_LEMANA_VPN_LOG_MAX_BYTES:1}" +[[ "${_ENV_LEMANA_VPN_LOCAL_OTP:0:1}" == "x" ]] && LEMANA_VPN_LOCAL_OTP="${_ENV_LEMANA_VPN_LOCAL_OTP:1}" +[[ "${_ENV_LEMANA_VPN_AUTO_SOURCE:0:1}" == "x" ]] && LEMANA_VPN_AUTO_SOURCE="${_ENV_LEMANA_VPN_AUTO_SOURCE:1}" OC_VENV="${LEMANA_VPN_OC_VENV:-$HOME/.local/pipx/venvs/openconnect-lite}" OC_PYTHON="${LEMANA_VPN_OC_PYTHON:-$OC_VENV/bin/python}" @@ -32,6 +40,13 @@ 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}" +MODE="${LEMANA_VPN_MODE:-}" +LOCAL_OTP="${LEMANA_VPN_LOCAL_OTP-0}" +AUTO_SOURCE="${LEMANA_VPN_AUTO_SOURCE:-keychain}" +case "$LOCAL_OTP" in + 0|1) ;; + *) printf 'LEMANA_VPN_LOCAL_OTP must be 0 or 1.\n' >&2; exit 2 ;; +esac AUTH_PROFILE="${LEMANA_VPN_AUTH_PROFILE:-manual}" CREDENTIAL_SOURCE="${LEMANA_VPN_CREDENTIAL_SOURCE:-}" if [[ -z "$CREDENTIAL_SOURCE" ]]; then @@ -41,32 +56,7 @@ if [[ -z "$CREDENTIAL_SOURCE" ]]; then CREDENTIAL_SOURCE="none" fi fi - -case "$AUTH_PROFILE" in - manual|saved-login|saved-totp|auto) ;; - *) - 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 - -if [[ "$CREDENTIAL_SOURCE" == "bitwarden" ]]; then - USE_BITWARDEN="1" -else - USE_BITWARDEN="0" -fi 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}" DNS_CLEANUP="${LEMANA_VPN_DNS_CLEANUP:-/usr/local/sbin/lemana-vpn-dns-cleanup}" APP_DIR="${LEMANA_VPN_APP_DIR:-$HOME/Applications/LemanaVPN.app}" @@ -79,6 +69,11 @@ STATUS_FILE="$STATUS_DIR/status.json" 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}" +LOG_MAX_BYTES="${LEMANA_VPN_LOG_MAX_BYTES-5242880}" +if [[ ! "$LOG_MAX_BYTES" =~ ^[1-9][0-9]{0,8}$ ]]; then + printf 'LEMANA_VPN_LOG_MAX_BYTES must be an integer from 1 to 999999999.\n' >&2 + exit 2 +fi CONNECT_WAIT_SECONDS="${LEMANA_VPN_CONNECT_WAIT_SECONDS:-20}" OC_RUNTIME_XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" MANUAL_XDG_CONFIG_HOME="" @@ -86,68 +81,139 @@ MANUAL_XDG_CONFIG_HOME="" DEBUG=false JSON_MODE=false STATUS_MODE=false +REPAIR_DNS_MODE=false +SESSION_ID="" +STOP_SESSION="" +RESTART_SESSION="" +RESTART_ATTEMPT="" CONFIGURE_LOGIN_MODE=false CONFIGURE_KEYCHAIN_MODE=false +IMPORT_YANDEX_KEY_MODE=false +SET_AUTH="" PATCH_ONLY_MODE=false CONNECT_MODE="${LEMANA_VPN_MODE:-}" +FULL_MANUAL_OVERRIDE=false + +_normalize_public_mode() { + if [[ -z "${MODE:-}" ]]; then + case "$CREDENTIAL_SOURCE:$AUTH_PROFILE" in + bitwarden:*|*:auto) + MODE="bitwarden-auto" + ;; + *) + MODE="manual-keychain" + ;; + esac + fi + + if [[ "$MODE" == "manual-keychain" && "$LOCAL_OTP" == "1" ]]; then + MODE="keychain-auto" + fi + case "$MODE" in + manual-keychain) + LOCAL_OTP="0" + AUTH_PROFILE="saved-login" + CREDENTIAL_SOURCE="keychain" + USE_BITWARDEN="0" + USE_TOUCHID="0" + ;; + keychain-auto) + AUTH_PROFILE="auto" + CREDENTIAL_SOURCE="keychain" + LOCAL_OTP="1" + AUTO_SOURCE="keychain" + USE_BITWARDEN="0" + USE_TOUCHID="0" + ;; + bitwarden-auto) + LOCAL_OTP="0" + AUTO_SOURCE="bitwarden" + AUTH_PROFILE="auto" + CREDENTIAL_SOURCE="bitwarden" + USE_BITWARDEN="1" + USE_TOUCHID="${LEMANA_VPN_USE_TOUCHID:-1}" + ;; + *) + printf 'Unknown mode: %s. Use manual-keychain, keychain-auto or bitwarden-auto.\n' "$MODE" >&2 + exit 2 + ;; + esac + case "$AUTO_SOURCE" in + keychain|bitwarden) ;; + *) printf 'LEMANA_VPN_AUTO_SOURCE must be keychain or bitwarden.\n' >&2; exit 2 ;; + esac +} for arg in "$@"; do case "$arg" in --debug) DEBUG=true ;; --json) JSON_MODE=true ;; --status) STATUS_MODE=true ;; + --repair-dns) REPAIR_DNS_MODE=true ;; + --session-id=*) SESSION_ID="${arg#--session-id=}"; [[ "$SESSION_ID" =~ ^[a-f0-9-]{36}$ ]] || exit 2 ;; + --stop-session=*) STOP_SESSION="${arg#--stop-session=}"; [[ "$STOP_SESSION" =~ ^[a-f0-9-]{36}$ ]] || exit 2 ;; + --restart-session=*) RESTART_SESSION="${arg#--restart-session=}"; [[ "$RESTART_SESSION" =~ ^[a-f0-9-]{36}$ ]] || exit 2 ;; + --restart-attempt=*) RESTART_ATTEMPT="${arg#--restart-attempt=}"; [[ "$RESTART_ATTEMPT" =~ ^[a-f0-9-]{36}$ ]] || exit 2 ;; --configure-login) CONFIGURE_LOGIN_MODE=true ;; --configure-keychain) CONFIGURE_KEYCHAIN_MODE=true ;; + --import-yandex-key) IMPORT_YANDEX_KEY_MODE=true ;; + --set-auth=*) SET_AUTH="${arg#--set-auth=}"; case "$SET_AUTH" in manual|auto|keychain|bitwarden) ;; *) printf 'Unknown auth setting. Use manual, auto, keychain or bitwarden.\n' >&2; exit 2 ;; esac ;; --patch-only) PATCH_ONLY_MODE=true ;; - --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" ;; + --mode=manual-keychain|manual-keychain) MODE="manual-keychain"; CONNECT_MODE="manual-keychain"; LOCAL_OTP="0" ;; + --mode=keychain-auto|keychain-auto) MODE="keychain-auto"; CONNECT_MODE="keychain-auto" ;; + --mode=bitwarden-auto|bitwarden-auto) MODE="bitwarden-auto"; CONNECT_MODE="bitwarden-auto" ;; + --auto|auto) MODE="bitwarden-auto"; CONNECT_MODE="bitwarden-auto" ;; + --manual|manual|--manual-sso|--saved-login|saved-login|--login-only) MODE="manual-keychain"; CONNECT_MODE="manual-keychain"; LOCAL_OTP="0" ;; + --manual-full|manual-full|--manual-no-autofill) MODE="manual-keychain"; CONNECT_MODE="manual-keychain"; LOCAL_OTP="0"; FULL_MANUAL_OVERRIDE=true ;; --help|-h) cat <<'HELP' -Usage: vpn-lemanapro.sh [--auto|--manual|--saved-login|--manual-full] [--debug] [--json] [--status] [--configure-login] [--configure-keychain] [--patch-only] +Usage: vpn-lemanapro.sh [manual-keychain|keychain-auto|bitwarden-auto] [--manual-full] [--debug] [--json] [--status] [--configure-login] [--configure-keychain] [--import-yandex-key] [--set-auth=VALUE] [--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 - --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) + --repair-dns Restore recorded orphaned VPN DNS without connecting + manual-keychain Visible browser, saved username/password, OTP entered manually + keychain-auto Automatic login using the key saved on this Mac + bitwarden-auto Automatic login using Bitwarden + optional Touch ID + --auto Compatibility alias for bitwarden-auto + --manual Compatibility alias for manual-keychain + --saved-login Compatibility alias for manual-keychain + --manual-full Diagnostic fallback: visible browser, no auto-fill, no submit --manual-no-autofill Compatibility alias for --manual-full - --manual-sso Compatibility alias for --manual + --manual-sso Compatibility alias for manual-keychain --debug Passthrough debug logs; also shows browser in auto mode --json Emit JSON Lines events for UI wrappers - --configure-login Save LDAP username/password in macOS Keychain; OTP remains manual + --configure-login Save LDAP username/password in macOS Keychain --configure-keychain Save LDAP password plus permanent 2FA setup code or otpauth:// URI + --import-yandex-key Import and verify a local Yandex Key backup; enable automatic login + --set-auth=VALUE Save manual, auto, keychain or bitwarden for future connections --patch-only Apply openconnect-lite runtime patches and exit HELP exit 0 ;; + *) + printf 'Unknown argument: %s. Use --help.\n' "$arg" >&2 + exit 2 + ;; esac done +if [[ -n "$RESTART_ATTEMPT" && -z "$RESTART_SESSION" ]]; then exit 2; fi + case "$CONNECT_MODE" in "") ;; - manual-full) - AUTH_PROFILE="manual" + manual-keychain|keychain-auto|bitwarden-auto) + MODE="$CONNECT_MODE" ;; - 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 auth profile: %s. Use manual, saved-login, saved-totp, or auto.\n' "$AUTH_PROFILE" >&2 + printf 'Unknown mode: %s. Use manual-keychain, keychain-auto or bitwarden-auto.\n' "$CONNECT_MODE" >&2 exit 2 ;; esac +_normalize_public_mode + _emit() { local json="$1" human="$2" if $JSON_MODE; then @@ -212,16 +278,44 @@ PY } _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\n' "$OC_BIN" - } >> "$CONNECT_LOG_FILE" - chmod 600 "$CONNECT_LOG_FILE" 2>/dev/null || true + _log_connection_line "==== Lemana VPN openconnect-lite session; command: $OC_BIN ====" +} + +_connection_log_failed=false +_connection_log_ready=false + +_append_connection_log() { + local entry="$1" size archive_size=0 + local LC_ALL=C + if ! ${_connection_log_ready:-false}; then + (umask 077; mkdir -p "$(dirname "$CONNECT_LOG_FILE")" && touch "$CONNECT_LOG_FILE") || return 1 + chmod 600 "$CONNECT_LOG_FILE" || return 1 + if [[ -f "$CONNECT_LOG_FILE.1" ]]; then + chmod 600 "$CONNECT_LOG_FILE.1" || return 1 + archive_size="$(wc -c < "$CONNECT_LOG_FILE.1")" || return 1 + fi + _connection_log_ready=true + fi + size="$(wc -c < "$CONNECT_LOG_FILE")" || return 1 + if (( size + ${#entry} > LOG_MAX_BYTES || archive_size > LOG_MAX_BYTES )); then + # Copy only the bounded tail, including when upgrading a multi-GB legacy log. + (umask 077; tail -c "$LOG_MAX_BYTES" "$CONNECT_LOG_FILE" > "$CONNECT_LOG_FILE.1") || return 1 + : > "$CONNECT_LOG_FILE" || return 1 + fi + if (( ${#entry} > LOG_MAX_BYTES )); then + entry="${entry: -LOG_MAX_BYTES}" + fi + printf '%s' "$entry" >> "$CONNECT_LOG_FILE" } _log_connection_line() { - printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" >> "$CONNECT_LOG_FILE" + $_connection_log_failed && return 0 + local entry + printf -v entry '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" + if ! _append_connection_log "$entry" 2>/dev/null; then + printf 'Cannot write VPN log: %s; continuing without file logging.\n' "$CONNECT_LOG_FILE" >&2 + _connection_log_failed=true + fi } _show_connection_log_tail() { @@ -279,6 +373,28 @@ _module_bool() { fi } +_dns_recovery_supported() { + # Never probe an old helper by executing it: old versions ignored arguments. + [[ -x "$DNS_CLEANUP" ]] && grep -qx '# LEMANA_DNS_PROTOCOL=2' "$DNS_CLEANUP" +} + +_session_stop_supported() { + [[ -x "$DNS_CLEANUP" ]] && grep -qx '# LEMANA_SESSION_PROTOCOL=1' "$DNS_CLEANUP" +} + +_stop_session() { + local result=error code=1 + if _session_stop_supported && [[ "$STOP_SESSION" =~ ^[a-f0-9-]{36}$ ]]; then + if result="$(sudo -n "$DNS_CLEANUP" --stop "$STOP_SESSION")" && [[ "$result" == stopping ]]; then + result=stopping; code=0 + else + result=error + fi + fi + _emit "{\"event\":\"session_stop\",\"result\":\"$result\"}" "Session stop: $result" + return "$code" +} + _patches_active() { local wep authp wep="$(_find_webengine_process)" @@ -289,6 +405,7 @@ _patches_active() { && grep -q 'new RegExp' "$wep" \ && grep -q 'LEMANA_VPN_AUTOFILL_DISABLE' "$wep" \ && grep -q 'LEMANA_VPN_AUTOFILL_CLICK' "$wep" \ + && grep -q 'class LemanaVPNPage(QWebEnginePage)' "$wep" \ && ! grep -Eq '__lemanaVpnClicked|valueSetter|ScriptWorldId.MainWorld' "$wep" \ && grep -q 'self.session.get(self.host.vpn_url, allow_redirects=False)' "$authp" \ && grep -q 'response.headers.get("Location")' "$authp" \ @@ -299,51 +416,85 @@ _keychain_has() { security find-generic-password -s "$1" -a "$2" >/dev/null 2>&1 } +_set_auth() { + CONFIG_FILE="$CONFIG_FILE" AUTH_CHOICE="$SET_AUTH" AUTO_SOURCE="$AUTO_SOURCE" python3 - <<'PY' +import os +from pathlib import Path +import re +import tempfile + +choice = os.environ["AUTH_CHOICE"] +source = os.environ["AUTO_SOURCE"] if choice in ("manual", "auto") else choice +mode = "manual-keychain" if choice == "manual" else source + "-auto" +settings = { + "LEMANA_VPN_MODE": mode, + "LEMANA_VPN_LOCAL_OTP": "1" if mode == "keychain-auto" else "0", + "LEMANA_VPN_AUTO_SOURCE": source, + "LEMANA_VPN_AUTH_PROFILE": "saved-login" if choice == "manual" else "auto", + "LEMANA_VPN_CREDENTIAL_SOURCE": "bitwarden" if mode == "bitwarden-auto" else "keychain", + "LEMANA_VPN_USE_BITWARDEN": "1" if mode == "bitwarden-auto" else "0", +} +path = Path(os.environ["CONFIG_FILE"]) +staging = None +try: + text = path.read_text() if path.exists() else "" + for key in settings: + text = re.sub(r"(?m)^(?:export[ \t]+)?" + key + r"=[^\n]*(?:\n|$)", "", text) + text = text.rstrip("\n") + "\n" + "".join(f'{key}="{value}"\n' for key, value in settings.items()) + path.parent.mkdir(parents=True, exist_ok=True) + fd, staging = tempfile.mkstemp(prefix=".auth-env-", dir=path.parent) + with os.fdopen(fd, "w") as stream: + stream.write(text) + os.replace(staging, path) +except OSError: + raise SystemExit("Не удалось сохранить способ входа. Проверьте доступ к настройкам Lemana VPN.") from None +finally: + if staging and os.path.exists(staging): + os.unlink(staging) +print('Способ входа сохранён. Настройка применяется при следующем подключении.') +PY +} + _app_menu_json() { local login_ready="$1" keychain_ready="$2" local install_label configured_mode available_modes setup_hint_json - configured_mode="$AUTH_PROFILE" + configured_mode="$MODE" setup_hint_json="null" - case "$AUTH_PROFILE" in - manual) - install_label="Режим: полностью вручную" - available_modes='"configured"' - ;; - saved-login) + case "$MODE" in + manual-keychain) if [[ "$login_ready" == "true" ]]; then - install_label="Режим: логин и пароль сохранены" - available_modes='"configured","manual-full"' + install_label="Режим: ручной вход, логин и пароль сохранены" + available_modes='"configured","bitwarden-auto"' else - install_label="Режим: логин и пароль не настроены" - available_modes='"manual-full"' + install_label="Режим: ручной вход, логин и пароль не настроены" + available_modes='"configured","bitwarden-auto"' 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"' + keychain-auto) + install_label="Автоматически · ключ на этом Mac" + available_modes='"configured","manual-keychain"' + if [[ "$login_ready" != "true" ]]; then + setup_hint_json='"Настройте логин и пароль: vpn --configure-login"' + elif [[ "$keychain_ready" != "true" ]]; then + setup_hint_json='"Импортируйте код: vpn --import-yandex-key"' fi ;; - auto) + bitwarden-auto) if [[ "$keychain_ready" == "true" ]]; then - install_label="Режим: автоматический вход" - available_modes='"configured","saved-totp","saved-login","manual-full"' + install_label="Автоматически · Bitwarden" + available_modes='"configured","manual-keychain"' else - install_label="Режим: автоматический вход не настроен" - available_modes='"manual-full"' - setup_hint_json='"Настройте в Terminal: vpn --configure-keychain"' + install_label="Автоматически · Bitwarden, данные будут синхронизированы" + available_modes='"configured","manual-keychain"' + setup_hint_json='"Bitwarden синхронизирует данные при подключении; если не сработает, проверьте bw login"' fi ;; *) install_label="Режим: неизвестен" - configured_mode="manual" + configured_mode="manual-keychain" available_modes='"configured"' setup_hint_json='"Обновите Lemana VPN через install.sh"' ;; @@ -358,6 +509,7 @@ _module_status_json() { 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 credential_login_ready app_menu_json + local mode_label mode_ready mode_hint_json auth_kind auth_detail openconnect_installed="$(_module_bool command -v openconnect)" openconnect_lite_installed="$(_module_bool test -x "$OC_BIN")" bitwarden_installed="$(_module_bool command -v bw)" @@ -375,11 +527,51 @@ _module_status_json() { 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","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}}' \ + case "$MODE" in + manual-keychain) + mode_label="Вручную" + auth_kind="manual" + auth_detail="Логин и пароль с этого Mac, код вводится вручную" + mode_ready="$credential_login_ready" + mode_hint_json="$([[ "$credential_login_ready" == "true" ]] && printf null || printf '"Настройте логин и пароль: vpn --configure-login"')" + ;; + keychain-auto) + mode_label="Автоматически · ключ на этом Mac" + auth_kind="auto" + auth_detail="Логин, пароль и код с этого Mac; вход без ручного ввода" + mode_ready="$credential_keychain_ready" + mode_hint_json=null + if [[ "$credential_login_ready" != "true" ]]; then + mode_hint_json='"Настройте логин и пароль: vpn --configure-login"' + elif [[ "$keychain_totp_seed" != "true" ]]; then + mode_hint_json='"Импортируйте код: vpn --import-yandex-key"' + fi + ;; + bitwarden-auto) + mode_label="Автоматически · Bitwarden" + auth_kind="auto" + auth_detail="Логин, пароль и код из Bitwarden; вход без ручного ввода" + mode_ready="$([[ "$bitwarden_installed" == "true" ]] && printf true || printf false)" + mode_hint_json="$([[ "$bitwarden_installed" == "true" ]] && printf null || printf '"Установите Bitwarden CLI или переустановите ручной режим"')" + ;; + *) + auth_kind="manual" + auth_detail="Обновите установку Lemana VPN" + mode_label="Неизвестный режим" + mode_ready=false + mode_hint_json='"Обновите Lemana VPN через install.sh"' + ;; + esac + + printf '{"core":{"openconnect":%s,"openconnect_lite":%s,"config":%s,"openconnect_lite_config":%s},"mode":{"id":"%s","label":"%s","ready":%s,"setup_hint":%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,"recovery_supported":%s,"stop_supported":%s},"patches":{"active":%s,"backup":%s},"app":{"installed":%s,"autostart":%s},"logging":{"max_bytes":%s},"local_otp":%s,"auth":{"mode":"%s","source":"%s","auto_source":"%s","label":"%s","detail":"%s","ready":%s,"setup_hint":%s}}' \ "$openconnect_installed" \ "$openconnect_lite_installed" \ "$config_present" \ "$oc_config_present" \ + "$MODE" \ + "$mode_label" \ + "$mode_ready" \ + "$mode_hint_json" \ "$CREDENTIAL_SOURCE" \ "$AUTH_PROFILE" \ "$credential_login_ready" \ @@ -387,16 +579,21 @@ _module_status_json() { "$app_menu_json" \ "$([[ "$USE_BITWARDEN" == "1" ]] && printf true || printf false)" \ "$bitwarden_installed" \ - "$BW_ITEM_NAME" \ + "$(printf '%s' "$BW_ITEM_NAME" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')" \ "$([[ "$USE_TOUCHID" == "1" ]] && printf true || printf false)" \ "$touchid_installed" \ "$keychain_password" \ "$keychain_totp_seed" \ "$dns_cleanup_installed" \ + "$(_module_bool _dns_recovery_supported)" \ + "$(_module_bool _session_stop_supported)" \ "$patches_active" \ "$patch_backup_present" \ "$app_installed" \ - "$app_autostart" + "$app_autostart" \ + "$LOG_MAX_BYTES" \ + "$([[ "$MODE" == "keychain-auto" ]] && printf true || printf false)" \ + "$auth_kind" "$CREDENTIAL_SOURCE" "$AUTO_SOURCE" "$mode_label" "$auth_detail" "$mode_ready" "$mode_hint_json" } _module_human_part() { @@ -428,7 +625,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, 🔐 auth_profile=%s, 🔐 credential_source=%s, ' "$([[ "$core" == "core=ok" ]] && printf '✅' || printf '⚠️')" "$core" "$AUTH_PROFILE" "$CREDENTIAL_SOURCE" + printf 'Modules: %s %s, 🔐 mode=%s, ' "$([[ "$core" == "core=ok" ]] && printf '✅' || printf '⚠️')" "$core" "$MODE" _module_human_part "bitwarden" "$USE_BITWARDEN" "$bitwarden_installed" printf ', ' _module_human_part "touchid" "$USE_TOUCHID" "$touchid_installed" @@ -441,9 +638,12 @@ _module_status_human() { "$([[ "$app_autostart" == "true" ]] && printf on || printf off)" \ "$([[ "$patches_active" == "true" ]] && printf '✅' || printf '⚠️')" \ "$([[ "$patches_active" == "true" ]] && printf active || printf pending)" \ - "$([[ "$AUTH_PROFILE" == "manual" || "$keychain_password" == "true" && "$AUTH_PROFILE" == "saved-login" || "$keychain_password" == "true" && "$keychain_totp_seed" == "true" ]] && printf '✅' || printf '⚠️')" \ + "$([[ "$keychain_password" == "true" && ( "$MODE" == "manual-keychain" && "$LOCAL_OTP" == "0" || "$keychain_totp_seed" == "true" ) ]] && printf '✅' || printf '⚠️')" \ "$([[ "$keychain_password" == "true" ]] && printf yes || printf no)" \ "$([[ "$keychain_totp_seed" == "true" ]] && printf yes || printf no)" + if [[ "$MODE" == "keychain-auto" ]]; then + printf 'Local OTP: %s\n' "$([[ "$keychain_totp_seed" == "true" ]] && printf ready || printf 'missing; run vpn --import-yandex-key')" + fi } _check_status() { @@ -460,12 +660,13 @@ _check_status() { return 0 fi - local status_json pid state ip expires dns_target + local status_json pid state ip expires started_at dns_target status_json="$(cat "$STATUS_FILE")" pid="$(printf '%s' "$status_json" | _json_get pid)" state="$(printf '%s' "$status_json" | _json_get state)" ip="$(printf '%s' "$status_json" | _json_get ip)" expires="$(printf '%s' "$status_json" | _json_get expires)" + started_at="$(printf '%s' "$status_json" | _json_get started_at)" dns_target="$(printf '%s' "$status_json" | _json_get dns_target)" local process_alive=false @@ -474,6 +675,11 @@ _check_status() { fi if [[ "$state" != "connected" ]] || ! $process_alive; then + if $process_alive && [[ "$state" == connecting || "$state" == reconnecting || "$state" == disconnecting ]]; then + if $JSON_MODE; then printf '{"state":"%s","pid":%s,"modules":%s}\n' "$state" "$pid" "$modules_json" + else printf 'VPN %s\n' "$state"; fi + return 0 + fi if $JSON_MODE; then printf '{"state":"disconnected","reason":"%s","modules":%s}\n' "$(! $process_alive && printf 'process dead (pid=%s)' "$pid" || printf '%s' "$state")" "$modules_json" else @@ -488,25 +694,27 @@ _check_status() { healthy=true fi - local remaining_sec=0 hours=0 mins=0 + local remaining_sec=0 hours=0 mins=0 session_label="срок неизвестен" if [[ -n "$expires" ]]; then local exp_ts now_ts - exp_ts="$(date -jf "%Y-%m-%dT%H:%M:%SZ" "$expires" "+%s" 2>/dev/null || true)" + exp_ts="$(date -u -jf "%Y-%m-%dT%H:%M:%SZ" "$expires" "+%s" 2>/dev/null || true)" now_ts="$(date "+%s")" if [[ -n "$exp_ts" ]]; then remaining_sec=$((exp_ts - now_ts)) + (( remaining_sec >= 0 )) || remaining_sec=0 hours=$((remaining_sec / 3600)) mins=$(((remaining_sec % 3600) / 60)) + session_label="${hours}h ${mins}m" fi fi if $JSON_MODE; then - printf '{"state":"connected","ip":"%s","healthy":%s,"remaining_sec":%s,"expires":"%s","pid":%s,"dns_target":"%s","modules":%s}\n' \ - "$ip" "$healthy" "$remaining_sec" "$expires" "$pid" "$dns_target" "$modules_json" + printf '{"state":"connected","ip":"%s","healthy":%s,"remaining_sec":%s,"expires":"%s","started_at":"%s","pid":%s,"dns_target":"%s","modules":%s}\n' \ + "$ip" "$healthy" "$remaining_sec" "$expires" "$started_at" "$pid" "$dns_target" "$modules_json" else _module_status_human - printf 'VPN connected - %s (session: %sh %sm, tunnel: %s)\n' \ - "$ip" "$hours" "$mins" "$($healthy && printf healthy || printf unhealthy)" + printf 'VPN connected - %s (session: %s, tunnel: %s)\n' \ + "$ip" "$session_label" "$($healthy && printf healthy || printf unhealthy)" fi } @@ -621,13 +829,39 @@ canonical_script = ''' regex_str = "^" + url_pattern.replace(".", var _afUrlPattern = new RegExp({js_regex_str}); var _afRun = 0; +var _afSubmitted = ""; +var _afRejectedOnce = false; +var _afFields = []; +var _afAttempt = {json.dumps(os.environ.get("LEMANA_VPN_AUTH_ATTEMPT", ""))}; +function _afRejected() {{ + if (!_afRejectedOnce) console.warn("LEMANA_VPN_AUTH_REJECTED"); + _afRejectedOnce = true; +}} +function _afMaySubmit() {{ + if (_afRejectedOnce || _afFields.length === 0) return false; + var stage = _afFields.includes("totp") ? "totp" : (_afFields.includes("password") ? "password" : "username"); + if (_afSubmitted === stage) return false; + try {{ + if (sessionStorage.getItem("lemana:" + _afAttempt + ":" + stage)) {{ + _afRejected(); return false; + }} + sessionStorage.setItem("lemana:" + _afAttempt + ":" + stage, "1"); + }} catch (e) {{ _afRejected(); return false; }} + _afSubmitted = stage; + return true; +}} function autoFill() {{ if (!_afUrlPattern.test(location.href.split('?')[0]) && !_afUrlPattern.test(location.href)) {{ _afRun++; return; }} _afRun++; - + if (_afRejectedOnce) return; + var error = document.querySelector("#input-error, #kc-error-message, .alert-error"); + if ({json.dumps(os.environ.get("LEMANA_VPN_AUTOFILL_CLICK", "1") != "0")} && error && error.getClientRects().length && error.textContent.trim()) {{ + _afRejected(); return; + }} + _afFields = []; {get_selectors(rules, credentials)} setTimeout(autoFill, 1000); }} @@ -652,6 +886,9 @@ elif old_script in src: else: fail("URL guard") +if "page = LemanaVPNPage(profile, self)" not in src: + replace_once("page = QWebEnginePage(profile, self)", "page = LemanaVPNPage(profile, self)", "terminal auth rejection", required=True) + selectors_marker = "def get_selectors(rules, credentials):\n" canonical_selectors = '''def get_selectors(rules, credentials): statements = [] @@ -667,7 +904,7 @@ canonical_selectors = '''def get_selectors(rules, credentials): value = json.dumps(getattr(credentials, rule.fill, None)) if value: statements.append( - f"""var elem = document.querySelector({selector}); if (elem) {{ elem.dispatchEvent(new Event("focus")); elem.value = {value}; elem.dispatchEvent(new Event("input", {{bubbles: true}})); elem.dispatchEvent(new Event("change", {{bubbles: true}})); elem.dispatchEvent(new Event("blur")); }}""" + f"""var elem = document.querySelector({selector}); if (elem && !elem.disabled && elem.type !== "hidden" && elem.getClientRects().length) {{ _afFields.push({json.dumps(rule.fill)}); elem.dispatchEvent(new Event("focus")); elem.value = {value}; elem.dispatchEvent(new Event("input", {{bubbles: true}})); elem.dispatchEvent(new Event("change", {{bubbles: true}})); elem.dispatchEvent(new Event("blur")); }}""" ) else: logger.warning( @@ -678,9 +915,18 @@ canonical_selectors = '''def get_selectors(rules, credentials): elif rule.action == "click": if os.environ.get("LEMANA_VPN_AUTOFILL_CLICK", "1") != "0": statements.append( - f"""var elem = document.querySelector({selector}); if (elem) {{ elem.dispatchEvent(new Event("focus")); elem.click(); }}""" + f"""var elem = document.querySelector({selector}); if (elem && _afMaySubmit()) {{ elem.dispatchEvent(new Event("focus")); elem.click(); }}""" ) return "\\n".join(statements) + + +class LemanaVPNPage(QWebEnginePage): + def javaScriptConsoleMessage(self, level, message, line, source): + if message == "LEMANA_VPN_AUTH_REJECTED" and os.environ.get("LEMANA_VPN_AUTOFILL_CLICK", "1") != "0": + logger.warning("LEMANA_VPN_AUTH_REJECTED") + QApplication.instance().exit(77) + return + super().javaScriptConsoleMessage(level, message, line, source) ''' if selectors_marker not in src: fail("auto-fill selectors") @@ -743,6 +989,10 @@ PY _store_keychain() { local password="$1" totp_secret="$2" + if [[ -z "${KC_USERNAME:-}" ]]; then + printf 'Cannot store VPN credentials: LDAP username is missing.\n' >&2 + return 1 + fi KC_USERNAME="$KC_USERNAME" _VPN_PASS="$password" _VPN_TOTP_SECRET="$totp_secret" "$OC_PYTHON" - <<'PY' import keyring import os @@ -786,27 +1036,36 @@ print(value) PY } +_tty_available() { + [[ -r /dev/tty && -w /dev/tty ]] || return 1 + { : <> /dev/tty; } 2>/dev/null +} + _can_prompt() { - [[ -r /dev/tty && -w /dev/tty ]] || [[ -t 0 ]] + _tty_available || [[ -t 0 ]] } _prompt_read() { - local var_name="$1" prompt="$2" secret="${3:-0}" value - if [[ -r /dev/tty && -w /dev/tty ]]; then + local var_name="$1" prompt="$2" secret="${3:-0}" value read_status=0 + if _tty_available; then if [[ "$secret" == "1" ]]; then - IFS= read -rsp "$prompt" value < /dev/tty || value="" + IFS= read -rsp "$prompt" value < /dev/tty || read_status=$? printf '\n' > /dev/tty else - IFS= read -rp "$prompt" value < /dev/tty || value="" + IFS= read -rp "$prompt" value < /dev/tty || read_status=$? fi else if [[ "$secret" == "1" ]]; then - IFS= read -rsp "$prompt" value || value="" + IFS= read -rsp "$prompt" value || read_status=$? printf '\n' else - IFS= read -rp "$prompt" value || value="" + IFS= read -rp "$prompt" value || read_status=$? fi fi + if (( read_status != 0 )); then + printf 'Input closed; credential setup cancelled.\n' >&2 + return 1 + fi printf -v "$var_name" '%s' "$value" } @@ -869,7 +1128,7 @@ _ensure_username() { fi while [[ -z "${KC_USERNAME:-}" ]]; do - _prompt_read KC_USERNAME "Corporate LDAP username: " + _prompt_read KC_USERNAME "Corporate LDAP username: " || return 1 KC_USERNAME="${KC_USERNAME#"${KC_USERNAME%%[![:space:]]*}"}" KC_USERNAME="${KC_USERNAME%"${KC_USERNAME##*[![:space:]]}"}" if [[ -z "$KC_USERNAME" ]]; then @@ -895,14 +1154,14 @@ _configure_keychain() { printf 'Saved values go to macOS Keychain service openconnect-lite.\n\n' if $password_present; then - _prompt_read password "Corporate LDAP password for $KC_USERNAME [leave empty to keep saved password]: " 1 + _prompt_read password "Corporate LDAP password for $KC_USERNAME [leave empty to keep saved password]: " 1 || return 1 else - _prompt_read password "Corporate LDAP password for $KC_USERNAME: " 1 + _prompt_read password "Corporate LDAP password for $KC_USERNAME: " 1 || return 1 fi if $totp_present; then - _prompt_read totp_secret "2FA setup secret BASE32 [leave empty to keep saved secret]: " 1 + _prompt_read totp_secret "2FA setup secret BASE32 [leave empty to keep saved secret]: " 1 || return 1 else - _prompt_read totp_secret "2FA setup secret BASE32: " 1 + _prompt_read totp_secret "2FA setup secret BASE32: " 1 || return 1 fi if [[ -z "$password" && "$password_present" != "true" ]]; then @@ -930,12 +1189,16 @@ _configure_login() { 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 [[ "$LOCAL_OTP" == "1" ]]; then + printf 'One-time codes use the separately imported Keychain secret.\n\n' + else + printf 'One-time 2FA codes will still be entered manually during VPN login.\n\n' + fi if $password_present; then - _prompt_read password "Corporate LDAP password for $KC_USERNAME [leave empty to keep saved password]: " 1 + _prompt_read password "Corporate LDAP password for $KC_USERNAME [leave empty to keep saved password]: " 1 || return 1 else - _prompt_read password "Corporate LDAP password for $KC_USERNAME: " 1 + _prompt_read password "Corporate LDAP password for $KC_USERNAME: " 1 || return 1 fi if [[ -z "$password" && "$password_present" != "true" ]]; then @@ -985,19 +1248,41 @@ _ensure_keychain_credentials() { if [[ "$CREDENTIAL_SOURCE" == "bitwarden" ]]; then _emit '{"event":"keychain_required","source":"bitwarden"}' "Bitwarden source did not produce complete Keychain credentials." + _emit '{"event":"error","message":"Bitwarden did not provide complete LDAP username/password/TOTP. Check bw login and the LM LDAP item."}' \ + "Bitwarden did not provide complete LDAP username/password/TOTP. Check: bw login and item '$BW_ITEM_NAME'." + return 1 else _emit '{"event":"keychain_required","source":"keychain"}' "Keychain source is selected and saved LDAP credentials are incomplete." fi if ! _can_prompt; then - _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" + _emit '{"event":"error","message":"Bitwarden automatic sign-in is not configured. Check bw login/LM LDAP, or switch to manual-keychain."}' \ + "Bitwarden automatic sign-in is not configured. Check bw login/LM LDAP, or switch to manual-keychain." return 1 fi _configure_keychain } +_import_yandex_key() { + if ! _can_prompt; then + printf 'Импорт требует Terminal. Запустите: vpn --import-yandex-key\n' >&2 + return 1 + fi + if [[ -z "$KC_USERNAME" ]] || ! _keychain_has openconnect-lite "$KC_USERNAME"; then + printf 'Сначала настройте логин и пароль: vpn --configure-login\n' >&2 + return 1 + fi + local helper saved_username + saved_username="$(unset LEMANA_VPN_USERNAME; source "$CONFIG_FILE"; printf '%s' "${LEMANA_VPN_USERNAME:-}")" + if [[ "$saved_username" != "$KC_USERNAME" ]]; then + printf 'LDAP username отличается от сохранённой настройки. Запустите vpn --configure-login без переопределения username.\n' >&2 + return 1 + fi + helper="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lemana-vpn-import-yandex.py" + XDG_CONFIG_HOME="$OC_RUNTIME_XDG_CONFIG_HOME" "$OC_PYTHON" "$helper" "--username=$KC_USERNAME" --config "$CONFIG_FILE" +} + _bw_cache_session() { [[ "$CACHE_BW_SESSION" == "1" ]] || return 0 local session="$1" @@ -1045,7 +1330,7 @@ _bw_unlock() { fi _emit '{"event":"bw_manual"}' "Bitwarden vault is locked. Enter Bitwarden master password to sync LDAP credentials." - _prompt_read manual_pw "Bitwarden master password (not LDAP password): " 1 + _prompt_read manual_pw "Bitwarden master password (not LDAP password): " 1 || return 1 if [[ -z "$manual_pw" ]]; then printf 'Empty Bitwarden password. Using existing Keychain credentials.\n' >&2 return 1 @@ -1061,7 +1346,7 @@ _bw_unlock() { if [[ "$USE_TOUCHID" == "1" && -x "$KC_FP" ]]; then local save_choice - _prompt_read save_choice "Save Bitwarden master password behind Touch ID for next VPN unlock? [Y/n] " + _prompt_read save_choice "Save Bitwarden master password behind Touch ID for next VPN unlock? [Y/n] " || { unset manual_pw; return 1; } if [[ "${save_choice:-y}" =~ ^[Yy]?$ ]]; then printf '%s' "$manual_pw" | "$KC_FP" set "$BW_KC_SERVICE" "$BW_KC_ACCOUNT_MASTER" >/dev/null 2>&1 \ && printf 'Saved. Next unlock can use Touch ID.\n' \ @@ -1078,9 +1363,19 @@ _sync_bitwarden() { return 0 fi - local bw_password bw_item bw_totp_secret + local bw_password bw_item bw_username bw_totp_secret bw_password="$(bw get password "$BW_ITEM_NAME" --session "$BW_SESSION" 2>/dev/null || true)" bw_item="$(bw get item "$BW_ITEM_NAME" --session "$BW_SESSION" 2>/dev/null || true)" + bw_username="$(printf '%s' "$bw_item" | python3 -c ' +import json +import sys + +try: + data = json.load(sys.stdin) + print((data.get("login", {}).get("username", "") or "").strip()) +except Exception: + print("") +')" bw_totp_secret="$(printf '%s' "$bw_item" | python3 -c ' import json import re @@ -1098,7 +1393,18 @@ except Exception: print("") ')" + if [[ -n "$bw_username" && "$bw_username" != "${KC_USERNAME:-}" ]]; then + KC_USERNAME="$bw_username" + export LEMANA_VPN_USERNAME="$KC_USERNAME" + _persist_username + _emit '{"event":"username_saved","source":"bitwarden"}' "LDAP username synced from Bitwarden." + fi + if [[ -n "$bw_password" ]]; then + if [[ -z "${KC_USERNAME:-}" ]]; then + printf 'Could not fetch LDAP username from Bitwarden item "%s". Using existing Keychain credentials.\n' "$BW_ITEM_NAME" >&2 + return 0 + fi if [[ -n "$bw_totp_secret" ]]; then bw_totp_secret="$(_normalize_totp_secret "$bw_totp_secret")" fi @@ -1125,79 +1431,247 @@ _sync_credentials() { _dns_cleanup() { _emit '{"event":"dns_cleanup"}' "Cleaning up VPN DNS..." - if [[ -x "$DNS_CLEANUP" ]]; then - sudo "$DNS_CLEANUP" || true - return 0 + if _dns_recovery_supported; then + # Replace the finished CLI so the process guard sees that this session ended. + # Keep the client's exit code; DNS recovery must not change reconnect policy. + # OpenConnect and its disconnect hook can outlive openconnect-lite on SIGINT. + exec /bin/sh -c ' + result=active + attempt=0 + while [ "$attempt" -lt 30 ]; do + attempt=$((attempt + 1)) + if ! result=$(sudo -n "$1" --recover); then result=error; break; fi + case "$result" in + restored|unchanged|no_snapshot) break ;; + active) [ "$attempt" -ge 30 ] || sleep 1 ;; + *) result=error; break ;; + esac + done + if [ "$3" = true ]; then + printf "{\"event\":\"dns_recovery\",\"result\":\"%s\"}\n" "$result" + fi + case "$result" in + error) printf "DNS recovery failed; retry vpn --repair-dns.\n" >&2 ;; + active) printf "VPN processes are still active; DNS recovery deferred. Retry vpn --repair-dns after they exit.\n" >&2 ;; + esac + exit "$2" + ' dns-recovery "$DNS_CLEANUP" "$1" "$JSON_MODE" fi +} - for svc in "Wi-Fi" "USB 10/100/1000 LAN" "Ethernet"; do - local dns - dns="$(networksetup -getdnsservers "$svc" 2>/dev/null || true)" - if printf '%s\n' "$dns" | grep -q '^10\.'; then - sudo networksetup -setdnsservers "$svc" empty >/dev/null 2>&1 || true +_repair_dns() { + local result=error message="Не удалось восстановить DNS. Обновите установку: sh install.sh" code=1 + if _dns_recovery_supported; then + if result="$(sudo -n "$DNS_CLEANUP" --recover)"; then + case "$result" in + restored) message="Прежние настройки DNS восстановлены"; code=0 ;; + unchanged) message="Восстановление DNS не требуется"; code=0 ;; + no_snapshot) message="Сохранённых изменений VPN DNS нет"; code=0 ;; + active) message="VPN уже запущен; DNS оставлен без изменений"; code=0 ;; + *) result=error ;; + esac + else + result=error + message="Не удалось проверить DNS. Обновите установку: sh install.sh; затем повторите vpn --repair-dns" fi + fi + _emit "{\"event\":\"dns_recovery\",\"result\":\"$result\",\"message\":\"$message\"}" "$message" + return "$code" +} + +_parse_expiry() { + python3 - "$1" <<'PYDATE' +import datetime as dt +import locale +import re +import sys +locale.setlocale(locale.LC_TIME, 'C') +raw = ' '.join(sys.argv[1].split()) +zone = None +numeric = re.search(r' ([+-])(\d{2})(?::?(\d{2}))?$', raw) +if numeric: + hours, minutes = int(numeric[2]), int(numeric[3] or 0) + if hours > 14 or minutes > 59: + sys.exit(1) + zone = dt.timezone(dt.timedelta(minutes=(hours * 60 + minutes) * (-1 if numeric[1] == '-' else 1))) + raw = raw[:numeric.start()] +elif re.search(r' [A-Za-z]+$', raw): + # OpenConnect renders named zones from this machine's local time. + raw = raw.rsplit(' ', 1)[0] +for pattern in ('%a, %d %b %Y %H:%M:%S', '%a %b %d %H:%M:%S %Y'): + try: + value = dt.datetime.strptime(raw, pattern) + if zone is not None: + value = value.replace(tzinfo=zone) + print(int(value.timestamp())) + break + except ValueError: + pass +else: + sys.exit(1) +PYDATE +} + +_valid_ipv4() { + local octet + local IFS=. + for octet in $1; do + [[ "$octet" =~ ^[0-9]{1,3}$ ]] && ((10#$octet <= 255)) || return 1 done - sudo dscacheutil -flushcache >/dev/null 2>&1 || true - sudo killall -HUP mDNSResponder >/dev/null 2>&1 || true +} + +_publish_connection() { + local remaining=0 dns_target + if [[ -n "$expiry_ts" ]]; then + remaining=$((expiry_ts - $(date +%s))) + (( remaining >= 0 )) || remaining=0 + fi + _emit "{\"event\":\"$1\",\"ip\":\"$vpn_ip\",\"expires\":\"$expiry_iso\",\"started_at\":\"$session_started_at\",\"remaining_sec\":$remaining,\"attempt_id\":\"${auth_attempt:-}\"}" "VPN connected - $vpn_ip" + dns_target="$(scutil --dns 2>/dev/null | awk '/Supplemental/{found=1} found && /nameserver\[0\]/{split($0,a," : "); if(a[2] ~ /^10\./) {print a[2]; exit}}' || true)" + _write_status "{\"pid\":$$,\"state\":\"connected\",\"ip\":\"$vpn_ip\",\"expires\":\"$expiry_iso\",\"started_at\":\"$session_started_at\",\"dns_target\":\"$dns_target\",\"updated_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" } _filter_output() { - local vpn_ip="" + local vpn_ip="" session_started_at="" expiry_iso="" expiry_ts="" parsed_ts="" + local connected=false rejected=false while IFS= read -r line; do _log_connection_line "$line" - if $DEBUG; then printf '%s\n' "$line" elif ! $JSON_MODE; then case "$line" in *ERROR*|*Error*|*error*|*Failed*|*failed*|*Traceback*|*SAML*|*saml*|*Keycloak*|*keycloak*|*Cisco*|*auth*|*Auth*) - printf '[openconnect-lite] %s\n' "$line" - ;; + printf '[openconnect-lite] %s\n' "$line" ;; esac fi - - if [[ "$line" =~ Configured\ as\ ([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+) ]]; then - vpn_ip="${BASH_REMATCH[1]}" + if [[ "$line" == *LEMANA_VPN_AUTH_REJECTED* ]] && [[ "${MODE:-manual-keychain}" != manual-keychain ]] && ! $connected && ! $rejected; then + rejected=true + [[ -z "${ATTEMPT_DIR:-}" ]] || : > "$ATTEMPT_DIR/auth-rejected" + _emit '{"event":"auth_rejected","message":"Автоматический вход остановлен: форма отклонила или повторно запросила данные. Проверьте пароль и одноразовый код."}' 'Автоматический вход остановлен. Проверьте пароль и одноразовый код.' fi - - if [[ "$line" =~ Session\ authentication\ will\ expire\ at\ (.+) ]]; then - _stop_connect_progress - local expiry_str="${BASH_REMATCH[1]}" - local expiry_ts expiry_local expiry_iso now_ts remaining hours mins - expiry_ts="$(date -jf "%a %b %d %H:%M:%S %Y" "$expiry_str" "+%s" 2>/dev/null || true)" - expiry_iso="" - if [[ -n "$expiry_ts" ]]; then - expiry_local="$(date -jf "%a %b %d %H:%M:%S %Y" "$expiry_str" "+%H:%M" 2>/dev/null || true)" - expiry_iso="$(date -r "$expiry_ts" -u "+%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || true)" - now_ts="$(date "+%s")" - remaining=$((expiry_ts - now_ts)) - hours=$((remaining / 3600)) - mins=$(((remaining % 3600) / 60)) + $rejected && continue + if [[ "$line" =~ Configured\ as\ ([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)($|[[:space:],]) ]] && _valid_ipv4 "${BASH_REMATCH[1]}"; then + # _valid_ipv4 also uses regex matching, so capture the IP independently. + vpn_ip="${line#*Configured as }"; vpn_ip="${vpn_ip%%[!0-9.]*}" + if ! $connected; then + connected=true + session_started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + [[ -z "${ATTEMPT_DIR:-}" ]] || : > "$ATTEMPT_DIR/connected" + _stop_connect_progress + _publish_connection connected fi - - if [[ -n "$vpn_ip" ]]; then - _emit "{\"event\":\"connected\",\"ip\":\"$vpn_ip\",\"expires\":\"$expiry_iso\"}" "VPN connected - $vpn_ip" - else - _emit '{"event":"connected"}' "VPN connected" + elif [[ "$line" == *'Session authentication will expire at '* ]]; then + if parsed_ts="$(_parse_expiry "${line#*Session authentication will expire at }")"; then + expiry_ts=$parsed_ts + expiry_iso="$(date -u -r "$expiry_ts" +%Y-%m-%dT%H:%M:%SZ)" + if $connected; then _publish_connection session_info; fi fi - if [[ -n "${expiry_ts:-}" ]]; then - _emit "{\"event\":\"session_info\",\"ip\":\"${vpn_ip:-}\",\"expires\":\"$expiry_iso\",\"remaining_sec\":$remaining}" "Session until $expiry_local (${hours}h ${mins}m)" - fi - - local dns_target - dns_target="$(scutil --dns 2>/dev/null | awk '/Supplemental/{found=1} found && /nameserver\[0\]/{split($0,a," : "); if(a[2] ~ /^10\./) {print a[2]; exit}}' || true)" - _write_status "{\"pid\":$$,\"state\":\"connected\",\"ip\":\"${vpn_ip:-}\",\"expires\":\"${expiry_iso:-}\",\"started_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"dns_target\":\"${dns_target:-}\",\"updated_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" - - $DEBUG || $JSON_MODE || printf '%s\n' "Ctrl+C to disconnect" fi done } +_recover_for_retry() { + local result=active attempt + for ((attempt=1; attempt<=30; attempt++)); do + if ! result="$(sudo -n "$DNS_CLEANUP" --recover-session "$SESSION_ID")"; then return 1; fi + case "$result" in + restored|unchanged|no_snapshot) return 0 ;; + cancelled) return 2 ;; + active) (( attempt >= 30 )) || sleep 1 ;; + *) return 1 ;; + esac + done + return 1 +} + +_run_connections() { + local failures=0 delay=0 exit_code recovery_code auth_attempt + local -a pipeline_status + while true; do + rm -f "$ATTEMPT_DIR/connected" "$ATTEMPT_DIR/auth-rejected" + auth_attempt="$(uuidgen | tr '[:upper:]' '[:lower:]')" + _emit '{"event":"connecting"}' 'Connecting to VPN (lemanapro)...' + _write_status "{\"pid\":$$,\"state\":\"connecting\"}" + _start_connect_progress + set +e + QTWEBENGINE_CHROMIUM_FLAGS="--disable-gpu" \ + XDG_CONFIG_HOME="$OC_RUNTIME_XDG_CONFIG_HOME" \ + LEMANA_VPN_AUTH_ATTEMPT="$auth_attempt" \ + 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 -- --script "$dns_hook $auth_attempt" 2>&1 | _filter_output + pipeline_status=("${PIPESTATUS[@]}") + set -e + exit_code=${pipeline_status[0]} + _stop_connect_progress + if [[ -f "$ATTEMPT_DIR/auth-rejected" ]]; then return 77; fi + if [[ "${pipeline_status[1]}" != 0 ]]; then + _emit '{"event":"error","message":"Не удалось прочитать состояние VPN. Автоматический повтор остановлен."}' 'VPN output processing failed.' + return 1 + fi + if [[ "$MODE" == manual-keychain ]] || { [[ ! -f "$ATTEMPT_DIR/connected" ]] && [[ "$exit_code" == 2 || "$exit_code" == 130 || "$exit_code" == 143 ]]; }; then + _emit '{"event":"disconnected","reason":"ended"}' 'VPN disconnected' + return "$exit_code" + fi + _emit '{"event":"reconnecting","attempt":1,"delay":0}' 'Проверяю завершение туннеля и DNS перед повтором…' + _write_status "{\"pid\":$$,\"state\":\"reconnecting\"}" + recovery_code=0 + _recover_for_retry || recovery_code=$? + case "$recovery_code" in + 2) _emit '{"event":"disconnected","reason":"user"}' 'VPN disconnected'; return 0 ;; + 0) ;; + *) _emit '{"event":"error","message":"Повторное подключение остановлено: предыдущий туннель или DNS ещё не восстановлены."}' 'VPN/DNS recovery incomplete; reconnect stopped.'; return 1 ;; + esac + if [[ -f "$ATTEMPT_DIR/connected" ]]; then + failures=0; delay=5 + else + if (( failures >= 5 )); then + _emit '{"event":"error","message":"Не удалось подключиться после пяти повторов. Проверьте сеть и настройки входа."}' 'Reconnect limit reached.' + return 1 + fi + delay=$((5 << failures)); (( delay <= 60 )) || delay=60 + failures=$((failures + 1)) + fi + _emit "{\"event\":\"reconnecting\",\"attempt\":$((failures > 0 ? failures : 1)),\"delay\":$delay}" "Повторное подключение через $delay с…" + sleep "$delay" + done +} + +if [[ -n "$RESTART_SESSION" ]]; then + _session_stop_supported && grep -qx '# LEMANA_RECONNECT_PROTOCOL=1' "$DNS_CLEANUP" || exit 1 + [[ -n "$RESTART_ATTEMPT" ]] || exit 2 + restart_result="$(sudo -n "$DNS_CLEANUP" --restart "$RESTART_SESSION" "$RESTART_ATTEMPT")" + case "$restart_result" in stopping|stale|cancelled) printf '%s\n' "$restart_result" ;; *) exit 1 ;; esac + exit 0 +fi + +if [[ -n "$STOP_SESSION" ]]; then + _stop_session + exit 0 +fi + +if $REPAIR_DNS_MODE; then + _repair_dns + exit 0 +fi + +if [[ -n "$SET_AUTH" ]]; then + _set_auth + exit 0 +fi + if $STATUS_MODE; then _check_status exit 0 fi +if $IMPORT_YANDEX_KEY_MODE; then + _import_yandex_key + exit 0 +fi + if $CONFIGURE_LOGIN_MODE; then _ensure_username _configure_login @@ -1215,35 +1689,59 @@ if $PATCH_ONLY_MODE; then exit 0 fi +if [[ "$MODE" == keychain-auto ]]; then + if [[ -z "$KC_USERNAME" ]] || ! _keychain_has openconnect-lite "$KC_USERNAME"; then + _emit '{"event":"error","message":"Настройте логин и пароль: vpn --configure-login"}' "Настройте логин и пароль: vpn --configure-login" + exit 1 + fi + if ! _keychain_has openconnect-lite "totp/$KC_USERNAME"; then + _emit '{"event":"error","message":"Импортируйте код: vpn --import-yandex-key"}' "Импортируйте код: vpn --import-yandex-key" + exit 1 + fi +fi + +if ! _dns_recovery_supported || ! _session_stop_supported || ! grep -qx '# LEMANA_RECONNECT_PROTOCOL=1' "$DNS_CLEANUP"; then + _emit '{"event":"error","message":"Обновите helper перед подключением: sh install.sh"}' "Обновите helper перед подключением: sh install.sh" + exit 1 +fi +[[ -n "$SESSION_ID" ]] || SESSION_ID="$(uuidgen | tr '[:upper:]' '[:lower:]')" +session_sudo=() +if $JSON_MODE || [[ ! -t 0 ]]; then session_sudo=(-n); fi +if [[ ! "$SESSION_ID" =~ ^[a-f0-9-]{36}$ ]] || ! begin_result="$(sudo "${session_sudo[@]}" "$DNS_CLEANUP" --begin "$SESSION_ID" "$$")" || [[ "$begin_result" != ready ]]; then + _emit '{"event":"error","message":"Не удалось зарегистрировать VPN-сессию. Проверьте установку и текущий статус."}' "Не удалось зарегистрировать VPN-сессию." + exit 1 +fi +ATTEMPT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/lemana-vpn-attempt.XXXXXX")" +trap 'trap_exit_code=$?; rm -rf "$ATTEMPT_DIR"; _stop_connect_progress; _cleanup_manual_openconnect_config || true; _clear_status || true; _dns_cleanup "$trap_exit_code"' EXIT +trap 'exit 143' TERM +_emit '{"event":"session_ready"}' "VPN session registered" + if ! $JSON_MODE; then _module_status_human else printf '{"event":"modules","modules":%s}\n' "$(_module_status_json)" 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." +case "$MODE" in + manual-keychain) + if $FULL_MANUAL_OVERRIDE; then + _emit '{"event":"mode","id":"manual-keychain","diagnostic":true,"message":"Diagnostic manual fallback skips saved credential checks"}' \ + "Diagnostic manual fallback: open the visible browser without auto-fill." + else + _ensure_username + _sync_credentials + _ensure_keychain_login + fi ;; - saved-login) - _ensure_username - _sync_credentials - _ensure_keychain_login - ;; - saved-totp|auto) - _ensure_username + bitwarden-auto) _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; _cleanup_manual_openconnect_config; _clear_status' EXIT display_mode="shown" log_level="" @@ -1251,32 +1749,25 @@ autofill_debug="${LEMANA_VPN_AUTOFILL_DEBUG:-0}" 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) +case "$MODE" in + manual-keychain) 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." + if $FULL_MANUAL_OVERRIDE; then + _prepare_manual_openconnect_config + _emit '{"event":"manual_sso","mode":"manual-keychain","diagnostic":true,"autofill":false,"totp":false,"submit":false}' "Manual fallback: browser is visible, auto-fill is disabled, submit is not pressed." + else + autofill_disable="0" + _emit '{"event":"manual_sso","mode":"manual-keychain","autofill":true,"totp":false,"submit":false}' "Manual mode: browser is visible, login/password are filled, one-time code and submit are manual." + fi ;; - 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) + keychain-auto|bitwarden-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." + _emit "{\"event\":\"auto_sso\",\"mode\":\"$MODE\",\"autofill\":true,\"totp\":true,\"submit\":true}" "Automatic mode: hidden browser, auto-fill and auto-submit." ;; esac if $DEBUG; then @@ -1285,28 +1776,6 @@ if $DEBUG; then autofill_debug="1" fi -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]} - _stop_connect_progress - - if [[ $exit_code -eq 0 ]]; then - _emit '{"event":"disconnected","reason":"user"}' "VPN disconnected" - break - fi - - _show_connection_log_tail - reconnect_count=$((reconnect_count + 1)) - _emit "{\"event\":\"reconnecting\",\"attempt\":$reconnect_count,\"delay\":5}" "VPN exited with $exit_code. Reconnecting in 5s..." - sleep 5 - _emit '{"event":"connecting"}' "Reconnecting..." -done +# openconnect executes --script through a shell; quote the configured path as data. +dns_hook="$(python3 -c 'import shlex,sys; print(shlex.quote(sys.argv[1]) + " --vpnc " + shlex.quote(sys.argv[2]))' "$DNS_CLEANUP" "$SESSION_ID")" +_run_connections diff --git a/docs/goals/compact-menu/GOAL.md b/docs/goals/compact-menu/GOAL.md new file mode 100644 index 0000000..563d5b6 --- /dev/null +++ b/docs/goals/compact-menu/GOAL.md @@ -0,0 +1,3 @@ +# Goal: Compact Lemana VPN menu + +Execute `docs/goals/compact-menu/PLAN.md` with Krypton Execution. Implement the approved mock while preserving all existing auth and connection actions. Use real CLI timestamps, retain older-CLI fallback, keep credentials in the CLI, and report that native visual/live acceptance is untested. Preserve all prior dirty changes. diff --git a/docs/goals/compact-menu/PLAN.md b/docs/goals/compact-menu/PLAN.md new file mode 100644 index 0000000..0acd421 --- /dev/null +++ b/docs/goals/compact-menu/PLAN.md @@ -0,0 +1,41 @@ +# Compact Menu and Session Indicator Implementation Plan + +**Intent:** Implement the approved compact macOS menu mockup with Yandex Key import and a small configurable session indicator. +**Current Behavior:** MenuBarExtra uses a plain menu, shield plus H:MM, dense module diagnostics and a login Terminal action. CLI stores started_at but does not expose it to events/status and resets it on repeated expiry lines. +**Expected Outcome:** A native 298-point popover matching the approved layout; import/login actions, preserved connection actions, a persisted ring/clock/time preference (16-point ring default), exact remaining time in menu, elapsed fill and green/amber/red urgency. +**Target-Perspective Output:** Synthetic CLI transcript produces stable started_at plus real expiry; compiled presentation checks demonstrate progress/time/color boundaries, reset/legacy handling and correctly quoted Terminal commands. Release app compiles. Native visual acceptance remains explicitly untested because no GUI testing was requested. +**Truth Owner:** CLI owns connection start/expiry metadata; Swift session model computes derived presentation. UserDefaults owns the indicator preference. CLI/Keychain still own import and credentials. +**Contract Boundary:** Add optional started_at to existing events and connected status; preserve all old fields and auth modes. Swift falls back to the first observed connection time with older CLI, never to an assumed duration. +**Cutover:** Replace the existing plain menu and shield+time connected presentation in place with the approved popover and selected indicator. Reuse the Terminal launcher for import; no second credential UI. +**Displaced Path:** Unconditional shield+time becomes a selected style; dense healthy module details leave the default surface, actionable setup/tunnel warnings remain visible. No new VPN connection/reconnect logic. +**Value Density:** Native SwiftUI window menu, AppStorage and AppKit image drawing; no dependencies, app installation or platform rewrite. +**Acceptance Evidence:** Automated event/status transcript, pure Foundation session checks, generated Zsh script execution against a fake CLI, existing regression suites and Swift build. +**Evidence Lane:** Automated only. No app/Terminal launch, GUI, screenshot, live VPN, real Keychain or installation. +**Kill Criteria:** No fixed 8h duration, repeated timing events cannot reset fill, disabled/disconnected states cannot show stale time, imported secret never enters Swift, new sources ship in installer, existing auth/cancel/copy/log/quit actions remain reachable. +**Architecture Slice:** `bin/vpn-lemanapro.sh` -> VPNEvent -> `VPNManager.swift` -> new Foundation `SessionTiming.swift` -> `LemanaVPNApp.swift`; installer explicitly downloads the new source; focused tests and README describe behavior. Existing dirty work is preserved. +**Plan Review Gate:** Requires PRE review before execution. + +## Tasks + +1. Root: persist one start timestamp per `_filter_output` connection, include it in connected/session_info/status output, test repeated expiry lines and status round-trip with fake tools. No SSO/DNS/reconnect changes. +2. Root: add session timing model, optional event timestamp, reset on lifecycle transitions, update presentation once per second, preserve legacy timestamp fallback; fix JSON line buffering if required to deliver the new contract through real pipe chunks. Test timing independently and via decoded event fixtures. +3. Root: approved native popover, 16px ring/clock and compact time option, persisted preference, localized accessible status, visible setup/tunnel failures, all existing actions. Reuse Terminal script builder for import and login, fixing the existing read-only Zsh `status` variable and using per-action success text; setup launch failure must not replace an active VPN state. +4. Root: update source packaging, README and narrow smoke wiring expectations; run automated checks and POST correctness/maintainability review. Review agents are read-only; implementation stays with root. + +## Behavior details + +- Fill = clamp((now - started_at) / (expires - started_at), 0...1) for valid positive duration. Unknown/invalid expiry uses a normal connection symbol and “Срок сессии неизвестен”. +- Remaining >60min green; <=60min amber; <=15min red, including expiry. Exact menu text and accessibility labels accompany color. No continuous decorative spinner. +- Expiry alone does not start another session or pretend the process ended; keep disconnect while the process is still connected. Disconnected/reconnecting/error use distinct system symbols and no stale countdown. +- Start/end reset on an actual new connection attempt and terminal states; repeated connected/session_info data in one attempt retains its origin. RefreshStatus remains a module refresh, without attaching to external VPN processes. +- Approved reference: `/Users/dokril/.codex/visualizations/2026/10/07/01a117e8-cae9-7f02-9dc7-c177c368a223/vpn-menu.html`. Mock-specific demonstration controls are not shipped. + +## Completed implementation and evidence + +- Native `.window` menu, 298 pt layout, AppStorage ring/clock/time preference, 16 pt AppKit ring/clock image, exact remaining-time labels, import/login Terminal actions and all connection/cancel/copy/log/quit paths implemented. Healthy raw module summaries removed; actionable failures remain visible. +- CLI event, status file and status response share a stable start timestamp. Foundation timing logic handles observed legacy starts, real duration, clamping, unknown/invalid intervals and 60/15-minute thresholds. Lifecycle transitions reset timing, and the UI updates once per second. +- `tests/menu.py`: PASS. Real shell filter/status transcript with synthetic dates, repeated expiry and reconnect; compiled Swift model, every byte boundary in UTF-8/JSON framing, old CLI sole manual-full action; generated Terminal scripts executed in Zsh against a fake quoted-path CLI for success and exit 7. No Terminal application launched. +- `tests/smoke.sh`, `tests/logging.py`, `tests/configuration.py` (10 checks), `tests/yandex_import.py`, shell syntax and `git diff --check`: PASS. +- Final `swift build -c release --package-path app`: PASS (3.07 s). One restricted attempt was blocked by the compiler cache sandbox; an approved build escalation succeeded. +- PRE and POST correctness/maintainability reviews completed. Fixed the sole legacy-action regression and made invalid timing consistently unknown; removed newly unused statusBarTitle/moduleSummary/summary paths. +- Native UI is implemented but unproven visually: no GUI, screenshots, real Terminal/import, installation or live VPN acceptance was run. Existing unrelated dirty changes remain intact. diff --git a/docs/goals/configured-auth-ui/GOAL.md b/docs/goals/configured-auth-ui/GOAL.md new file mode 100644 index 0000000..bc37843 --- /dev/null +++ b/docs/goals/configured-auth-ui/GOAL.md @@ -0,0 +1,3 @@ +# Goal: One configured login action + +Use Krypton Execution on `docs/goals/configured-auth-ui/PLAN.md`. The user approved equal full automatic login for Bitwarden and a local imported key. Keep authentication truth in CLI/env, update all runtime/installer/import paths, preserve prior dirty work and validate through isolated automated fixtures. Native visual/live acceptance is not authorized. diff --git a/docs/goals/configured-auth-ui/PLAN.md b/docs/goals/configured-auth-ui/PLAN.md new file mode 100644 index 0000000..a143f54 --- /dev/null +++ b/docs/goals/configured-auth-ui/PLAN.md @@ -0,0 +1,44 @@ +# Configured Authentication and Clear Menu Implementation Plan + +**Intent:** Make the saved login approach explicit and make Connect always use it; fix faint/uneven status icons shown in the supplied screenshot. +**Current Behavior:** The connect arrow chooses a one-off override, the saved approach is unclear while connected, Bitwarden uses hidden auto-submit while local imported OTP uses visible/manual submit. Ring track is faint and a round tiny arc looks like an isolated dot. +**Expected Outcome:** A persistent Manual/Automatic setting, with Automatic source Key on this Mac/Bitwarden. Both automatic sources use hidden autofill/OTP/submit (explicitly confirmed by the user). One Connect button without a launch-mode menu. All status symbols fit a consistent 16pt box; neutral visible track and non-rounded progress cap. +**Target-Perspective Output:** Synthetic save/status/launch checks prove that each selection survives restart/update, Connect uses stored choice and both auto sources produce the same launch flags. Swift build and existing regressions pass. Screenshot is user-provided evidence; no new screenshots or GUI checks. +**Truth Owner:** CLI/env own saved auth choice and last automatic source; Swift only sends a setter command and renders returned status. Existing Keychain and importer still own secrets. +**Contract Boundary:** Add canonical `keychain-auto`; retain `manual-keychain`, `bitwarden-auto`, legacy CLI aliases and additive status metadata. New `--set-auth=manual|auto|keychain|bitwarden` saves configuration only. The UI clears inherited auth-selector env overrides and passes no auth override when connecting. +**Cutover:** Existing `manual-keychain + LOCAL_OTP=1` resolves to keychain-auto under the newly approved behavior; explicit manual aliases force LOCAL_OTP=0. Installer preserves and renders the canonical profile; successful import activates keychain-auto. +**Displaced Path:** Remove the connect dropdown and one-off choices from the app UI. Replace old manual-local submit behavior with the user-approved automatic behavior. No parallel Swift preferences for auth. +**Value Density:** Keep existing CLI, Keychain, PyOTP and runtime patches. Use native pickers and an atomic env setter, not another auth service. +**Acceptance Evidence:** Automated temporary-home selection/save/status tests, fake client launch matrix, importer regressions, settings preservation, native build, icon source/geometry review and POST correctness review. +**Evidence Lane:** Automated checks and supplied screenshot inspection only. No installed config changes, real Keychain, live VPN, app/GUI launch or screenshots. +**Kill Criteria:** Connect never picks the first available override; the active setting remains visible in every state; both auto sources have identical browser/submit flags; failed save cannot falsely update the UI; changing settings cannot reconnect an active session; no unsupported claim that a local secret necessarily came from Yandex. +**Architecture Slice:** CLI normalization/status/setter/preflight/launch -> Swift saved settings UI; importer/installer template migration; status label drawing. Existing unrelated dirty work, DNS, SSO selectors and reconnect policy are excluded. +**Plan Review Gate:** Requires PRE review before execution. + +## Tasks + +1. Root adds keychain-auto, atomic setter and auth status metadata. `LEMANA_VPN_AUTO_SOURCE` records the preferred automatic source so toggling manual/automatic preserves the last source; it is not a separate active mode. Writes update owned MODE/LOCAL_OTP/AUTH_PROFILE/CREDENTIAL_SOURCE/USE_BITWARDEN/AUTO_SOURCE together and preserve other settings. Setter runs before connection or patch side effects, and never reads secret values. +2. Root updates installer defaults/templates/migration and importer activation/copy. Local source is labeled “Ключ на этом Mac” because Bitwarden and import share the existing Keychain entry. Preflight gives source-specific setup guidance; missing credentials stop auto launch instead of prompting invisibly. +3. Root replaces the app dropdown with one configured connect action and native saved-mode/source controls. Save requests disable concurrent save/connect/import; only successful CLI status updates the selection. An active connection keeps running; settings apply next time. Normalize icon boxes/contrast/caps from supplied evidence. +4. Root adds focused fixture tests, updates earlier expectations intentionally changed by full auto, runs regression ladder and records native visual acceptance as untested. Read-only agents perform PRE/POST and correctness/maintainability review. + +## Approved behavior + +| UI choice | Canonical mode | Password/code source | Browser / submit | +| --- | --- | --- | --- | +| Вручную | manual-keychain | Saved password + manually entered OTP | shown / off | +| Автоматически, Ключ на этом Mac | keychain-auto | Existing Keychain username/password/TOTP | hidden / on | +| Автоматически, Bitwarden | bitwarden-auto | Existing Bitwarden sync to Keychain | hidden / on | + +`--manual-full` stays a diagnostic no-autofill command. `--debug` still makes the browser visible. The app will explain a missing/incompatible CLI and disable mode editing until refreshed; there is no automatic fallback to a different mode. + +## Completed evidence + +- UI now has two persisted modes and a separate automatic-source selector. Connect has no dropdown and sends no mode override. Status refresh is the only settings reader in Swift; connection events cannot overwrite saved settings. Saving, incomplete setup and in-progress authentication are guarded appropriately, with visible save errors. +- Canonical keychain-auto, approved legacy-local migration, importer activation, installer template/wizard and remembered auto source are integrated. Both automatic sources share the hidden/OTP/submit launch branch. The setter changes only the env file and preserves unrelated settings. +- Supplied screenshot/code review identified a faint track plus rounded short arc, rather than a demonstrable off-center circle. Track is now adaptive neutral at 0.45 alpha, progress cap is butt, and system/menu symbols use consistent boxes. No new native visual inspection was run. +- `tests/auth_settings.py`: PASS for all saved choices, last-auto-source restoration, failure/no side effects, local readiness and explicit manual override. +- `tests/smoke.sh`: PASS, including actual setter → ordinary no-override fake-client launch for manual, local automatic and Bitwarden automatic; both automatic sources produce hidden browser and submit enabled. +- `tests/configuration.py`: 11 checks PASS, including profile migration/update and explicit manual override. Importer, menu/timing/transport and logging regression checks PASS. Shell syntax and `git diff --check` PASS. +- Final `swift build -c release --package-path app`: PASS (3.75 s). PRE and POST correctness/maintainability reviews passed after fixing stale connection-module snapshots and hidden save errors. +- No real installation, live VPN, secret-store operation or GUI testing was performed. Native visual quality and real automatic SSO remain unverified in this turn; prior unrelated dirty work is preserved. diff --git a/docs/goals/logging-and-config/GOAL.md b/docs/goals/logging-and-config/GOAL.md new file mode 100644 index 0000000..6d24c7d --- /dev/null +++ b/docs/goals/logging-and-config/GOAL.md @@ -0,0 +1,3 @@ +# Goal: Bounded logs and reliable settings + +Execute `docs/goals/logging-and-config/PLAN.md`, preserving existing dirty authentication work. Keep one log writer per owner, CLI-owned settings, and backward-compatible events. Capture deterministic CLI/filesystem acceptance evidence; no GUI, live VPN or real install is authorized. Report any unproved behavior explicitly. diff --git a/docs/goals/logging-and-config/PLAN.md b/docs/goals/logging-and-config/PLAN.md new file mode 100644 index 0000000..d4e0dc7 --- /dev/null +++ b/docs/goals/logging-and-config/PLAN.md @@ -0,0 +1,51 @@ +# Bounded Logs and Reliable Settings Implementation Plan + +**Intent:** Stop unbounded disk usage and make existing settings predictable without changing authentication or tunnel behavior. +**Current Behavior:** Both log writers append forever. Installer interpolation corrupts special characters and replaces local settings; unknown runtime flags can fall through to connection. +**Expected Outcome:** Each log has a bounded current file and one bounded previous file (5 MiB each by default); custom settings survive updates; settings round-trip literally; mistyped commands fail before connecting. +**Target-Perspective Output:** Isolated CLI/filesystem examples show capped current/archive sizes and recent records, valid JSON with quoted settings, preserved configuration after two renders, and a rejected unknown flag. +**Truth Owner:** CLI env for runtime settings and the existing TOML for SSO. CLI owns the log limit reported to Swift; each process owns its log writer. +**Contract Boundary:** Keep current status/events/auth modes, adding optional logging metadata. Installer preserves unowned env lines and existing TOML profile/rules while updating the selected mode and username. +**Cutover:** Replace append-only writers in place; retain existing log names and add only `.1`. Do not mutate the installed setup or real logs during development. +**Displaced Path:** Raw sed template interpolation and unbounded writes are removed, with no parallel logging backend or new dependency. +**Value Density:** Bounded storage, correct literal settings, and update preservation address observable defects; avoid a general settings framework and wholesale shell/Swift rewrite. +**Acceptance Evidence:** Before/after automated regression checks using temporary files/homes and fake clients; shell smoke and Swift release build. Include long-running writes, oversized existing files/records, log I/O failure, quoted settings, update preservation, invalid flags. +**Evidence Lane:** Automated local CLI/filesystem only. No GUI, live connection, installation, Keychain writes, or deletion of real logs. +**Kill Criteria:** No append-only production writer; no raw sed settings interpolation; no unknown-flag path into connect; tests exercise the changed behavior. +**Architecture Slice:** `bin/vpn-lemanapro.sh` -> raw client stream/log + status JSON -> `VPNManager.swift` -> bounded app log. `install.sh` -> env templates/TOML -> same CLI. Authentication patches, DNS cleanup, health/reconnect semantics and unrelated dirty work are excluded. +**Plan Review Gate:** Requires PRE review before execution. + +## Tasks and ownership + +1. Root: bounded shell writer in `bin/vpn-lemanapro.sh`, Foundation-only `BoundedLog.swift` used by `VPNManager.swift`, log-limit setting and optional JSON metadata. Test both writers with actual temporary files, including existing oversized logs, repeated rollover and write failures; preserve event parsing. Unknown runtime flags fail with status 2. Literal JSON encoding for BW item. Fix `_prompt_read` EOF propagation and explicitly stop every caller before subsequent persistence/credential actions; empty Enter keeps existing behavior. Test username, secret and save-choice EOF with fake input and a timeout. Do not change auth modes/sources, health or reconnect semantics. +2. Configuration agent (parallel, disjoint): `install.sh`, env templates, new isolated configuration regression check. Precedence: flags > invocation env > saved env > defaults for supported installer settings; preserve arbitrary runtime settings and TOML custom profile/rules. Replace raw sed interpolation with stdlib literal escaping, reusing existing quoting conventions. Verify a second render preserves custom settings and special characters; installer environment mode takes effect. Do not edit runtime, Swift, README or smoke test. +3. Root integration: wire small regression checks into existing automated workflow as appropriate, document editable settings, precedence and rotation in README. Fix only evidence-backed issues in changed paths. Record remaining audit findings instead of opportunistic auth/refactoring work. +4. Read-only reviewer: POST contract/correctness/maintainability review. Root resolves blockers and runs syntax checks, focused regressions, `tests/smoke.sh`, `swift build -c release --package-path app`, `git diff --check`. + +## Risks and evidence + +`LEMANA_VPN_LOG_MAX_BYTES` defaults to 5242880; accept decimal integers 1..999999999, reject empty explicit values, zero, negative and malformed values before side effects. Swift defaults to 5242880 before status or with an old CLI and uses the validated optional CLI value thereafter. Verify saved env -> status JSON -> Swift decoding/writer setting. PRE review findings above are incorporated; proceed to execution. + +| Contract | Risk | Independent oracle | +| --- | --- | --- | +| Log size and recent history | Disk growth, copying giant legacy file | Current and `.1` each <= configured byte limit, newest record retained, only two files | +| Log failures | VPN output consumer exits | Fake output still produces expected JSON event with unwritable log destination | +| Settings round-trip/update | Corruption, command substitution, silent resets | Exact recovered values, sentinel command never executed, custom TOML values preserved | +| CLI validation | Typo initiates SSO | Exit 2 before client/status file creation | +| Compatibility | Existing dirty auth work regresses | Baseline vs final smoke results and Swift build, live acceptance explicitly unrun | + +## Deferred audit findings + +- Arbitrary `OPENCONNECT_LITE_CONFIG_DIR` and XDG runtime path need a separate end-to-end contract; do not mask this with status-only changes. +- The pre-existing CLI `_persist_username` writer selects the first username-like TOML line; unlike the revised installer it is not scoped to `[credentials]`. Custom profiles containing another username need a follow-up shared configuration-write contract and regression before broadening editable SSO settings. +- Broader extraction of embedded SSO patches and reconnect/state-machine changes require their own evidence and are outside this slice. + +## Execution evidence + +- Baseline: shell syntax, existing smoke, release Swift build and diff whitespace checks passed before edits. +- Historical app log was measured read-only at 2,552,898,484 bytes. Repeated closed-terminal credential prompts dominated the sampled tail. Current baseline still reproduced a closed-input loop; the new regression times out on that baseline and passes with explicit EOF propagation. +- `python3 tests/configuration.py`: 10 checks pass, covering literal round-trips, saved settings, precedence, legacy inputs, TOML variants and fresh help without Python. The first six regression cases all failed against the saved baseline installer. +- `python3 tests/logging.py`: passes actual CLI/Swift file writes, bounded legacy/repeated/oversized records, reduced limits, private permissions, closed username/password/TOTP/Bitwarden/consent input, preserved empty Enter, JSON output on log failure, settings decoding and rejected flags/limits. +- Final shell syntax, `tests/smoke.sh` (`smoke ok`), `swift build -c release --package-path app` and `git diff --check`: PASS. +- PRE/POST correctness and maintainability reviews completed; findings in the changed slice resolved. CLI ordinary-write microbenchmark improved from the first implementation's 4.800 s to 1.806 s per 300 records (old append-only baseline 0.831 s); the remaining overhead checks file size before writing. +- Real logs and installed files were not changed. No GUI, live VPN, installation or credential-store operation was performed. Live connection acceptance remains untested. diff --git a/docs/goals/manual-first-auth-flow/GOAL.md b/docs/goals/manual-first-auth-flow/GOAL.md index d94690e..af4bacb 100644 --- a/docs/goals/manual-first-auth-flow/GOAL.md +++ b/docs/goals/manual-first-auth-flow/GOAL.md @@ -1,13 +1,5 @@ -# Goal: Manual-First Auth Flow +# Goal Superseded -Use Krypton Execution to execute `docs/goals/manual-first-auth-flow/PLAN.md`. +Use `docs/goals/two-auth-modes/GOAL.md` instead. -Core rules: -- Treat PLAN.md as the source plan. -- Preserve intent, ownership, contract, cutover, evidence, and kill criteria. -- Make fresh install and default `vpn` / app connect manual-first. -- Keep the installer understandable for a non-IT user: plain wording, manual mode on Enter, optional conveniences explained as choices. -- Keep Bitwarden, Touch ID, saved TOTP, autosubmit, and headless mode as explicit opt-in features. -- Do not add a new dominant path without deleting, redirecting, demoting, or shimming the displaced Bitwarden/auto-first path. -- Capture acceptance evidence from a clean temporary HOME and the menu-bar app contract. -- Say "implemented but unproven" if that evidence cannot be captured. +The older manual-first goal is intentionally replaced by the two-mode contract. diff --git a/docs/goals/manual-first-auth-flow/PLAN.md b/docs/goals/manual-first-auth-flow/PLAN.md index f11773e..06a48d8 100644 --- a/docs/goals/manual-first-auth-flow/PLAN.md +++ b/docs/goals/manual-first-auth-flow/PLAN.md @@ -1,440 +1,10 @@ -# Manual-First Auth Flow Implementation Plan +# Manual-First Auth Flow Plan Superseded -**Intent:** Перевернуть Lemana VPN из auto/Bitwarden-first в manual-first: новая установка и действие по умолчанию открывают видимый SSO-браузер без сохранённых секретов, а удобства включаются отдельными функциями. -**Current Behavior:** Fresh install по умолчанию выбирает `credential_source=bitwarden`, ставит Bitwarden/Touch ID, `vpn` запускает hidden browser с autofill+submit, а `manual-full` существует как отдельный диагностический режим. -**Expected Outcome:** Базовый путь не требует Bitwarden, Keychain, TOTP seed или автоотправки. Пользователь может поэтапно включить сохранение LDAP login/password в macOS Keychain, затем TOTP seed/autofill, затем Bitwarden sync, Touch ID, autosubmit/headless. -**Target-Perspective Output:** Новый пользователь запускает installer, ничего специально не выбирает, нажимает `vpn` или основную кнопку в `LemanaVPN.app`, видит Keycloak форму и вводит LDAP, пароль и одноразовый код руками. Если он включил сохранение LDAP в Keychain, username/password подставляются, а одноразовый код он вводит руками. -**Truth Owner:** `bin/vpn-lemanapro.sh` владеет runtime-решением режима; `~/.config/lemana-vpn/env` хранит выбранные capabilities; macOS Keychain хранит только секреты; Swift app только вызывает CLI и читает JSON. -**Contract Boundary:** Installer пишет env/config, CLI читает env/flags/Keychain и отдаёт JSON Lines/status JSON, Swift app потребляет только этот CLI-контракт. `openconnect-lite` остаётся SSO/VPN engine. -**Cutover:** Fresh installs become manual-first. Existing configs with `LEMANA_VPN_CREDENTIAL_SOURCE=bitwarden` keep working, but docs/UI stop presenting Bitwarden as default. Legacy aliases `vpn-auto`, `vpn-manual`, `vpn-manual-full` stay as compatibility shims. -**Displaced Path:** The displaced dominant path is `Bitwarden + Touch ID + hidden autofill + autosubmit by default`. It becomes an explicit opt-in feature set, not the default route. -**Value Density:** First slice removes the biggest onboarding failure: users without Bitwarden can connect manually immediately, then add only the conveniences they need. -**Acceptance Evidence:** In a clean temporary HOME, `sh install.sh --dry-run --non-interactive` reports manual/default auth with Bitwarden and Touch ID off; the interactive installer copy reads like a step-by-step setup for a non-IT user; `bash bin/vpn-lemanapro.sh --json` without credentials reaches a visible browser launch without `username_required` or `keychain_required`; a Keychain-login-only fixture fills username/password but leaves OTP manual and submit disabled; the Swift app primary action maps to the manual-first path. -**Evidence Lane:** Non-live smoke ladder first: `sh -n`, `bash -n`, `tests/smoke.sh`, `swift build`, `git diff --check`. Live VPN is optional after status check and should start with visible manual mode only. -**Kill Criteria:** No fresh-install default may select Bitwarden, require a TOTP seed, autosubmit, or hide the browser. No docs may state Bitwarden is enabled by default. No default CLI/app path may require saved credentials before opening manual SSO. -**Architecture Slice:** Installer defaults + CLI auth profile/capability model + Swift launch mode labels + README/smoke coverage. -**Plan Review Gate:** Requires PRE review before execution. +This plan is superseded by `docs/goals/two-auth-modes/PLAN.md`. -## Короткий План +Do not execute this older plan. It described an intermediate four-profile model. The current approved contract keeps exactly two public modes: -🎯 Цель: fresh install, `vpn` и основная кнопка приложения стартуют с полностью ручного SSO; сохранение LDAP, TOTP, Bitwarden, autosubmit и headless включаются отдельно. +- `manual-keychain` +- `bitwarden-auto` -📦 Репозитории: только `/Users/dokril/git/home/lemana-vpn`. - -🧩 Модули: `install.sh`, `bin/vpn-lemanapro.sh`, `app/Sources/LemanaVPN`, `tests/smoke.sh`, `README.md`; `templates/openconnect-lite-config.toml` только если login-only autofill потребует правки правил. - -🛠 Основные изменения: -- Переключить fresh defaults с Bitwarden/auto-first на manual-first. -- Ввести явный auth profile/capability contract: manual, saved-login, saved-totp, auto. -- Разрешить Keychain хранить только LDAP username/password без обязательного TOTP seed. -- Оставить Bitwarden, Touch ID, autosubmit/headless как opt-in функции. -- Сделать Swift app CLI-wrapper поверх тех же режимов, с manual-first primary action. -- Зафиксировать всё в smoke-тестах и README. - -✅ Проверка: `sh -n install.sh`, `sh -n uninstall.sh`, `bash -n bin/vpn-lemanapro.sh`, `sh -n tests/smoke.sh`, `tests/smoke.sh`, `swift build -c release --package-path app`, `git diff --check`. - -⚠️ Риски: старые пользователи могут ожидать auto-connect на `vpn`; partial Keychain без TOTP надо проверить fake launch тестом и, при необходимости, видимым manual smoke. - -➡️ Следующий шаг: начать с `install.sh` и зафиксировать новый env/flag contract для manual-first defaults. - -## Architecture Slice - -Files to create: - -- None required for runtime. This goal package is the planning artifact. - -Files to modify: - -- `install.sh` - defaults, wizard wording, flags, env rendering, dry-run output. -- `bin/vpn-lemanapro.sh` - auth profile/capability model, default mode, partial Keychain support, JSON events. -- `app/Sources/LemanaVPN/VPNManager.swift` - launch mode enum/default, JSON handling if new events are added. -- `app/Sources/LemanaVPN/LemanaVPNApp.swift` - menu labels/order so manual-first is primary and auto is secondary. -- `tests/smoke.sh` - regression coverage for fresh defaults, manual launch without credentials, partial Keychain, app wiring. -- `README.md` - user-facing feature matrix and migration/update instructions. -- `templates/openconnect-lite-config.toml` only if login-only filling needs rule adjustments; otherwise avoid. - -Files to avoid: - -- `libexec/lemana-vpn-dns-cleanup` unless DNS behavior breaks. -- `uninstall.sh` except if new Keychain account names require cleanup. -- Direct edits inside installed `openconnect-lite`; use the existing runtime patch path from `bin/vpn-lemanapro.sh`. - -Source of truth: - -- Repo source for behavior. -- Installed env file for user-selected capabilities. -- Keychain for LDAP password and optional TOTP seed. - -Read path: - -- `install.sh` writes `~/.config/lemana-vpn/env`. -- `bin/vpn-lemanapro.sh` sources env, applies CLI flag overrides, checks Keychain capability presence, then launches `openconnect-lite`. -- `LemanaVPN.app` calls `~/bin/vpn-lemanapro.sh --json `. - -Write path: - -- `vpn --configure-login` or equivalent writes username/password only. -- `vpn --configure-keychain` remains the full saved-credentials path and writes password plus permanent TOTP seed. -- Bitwarden sync, when enabled, writes password plus optional TOTP seed into Keychain. - -Contract boundary: - -- CLI flags and JSON events are the public contract for the app and tests. -- Suggested new runtime contract: - - `auth_profile=manual`: visible browser, no preflight, no autofill, no submit. - - `auth_profile=saved-login`: visible browser, username/password autofill, OTP manual, no submit. - - `auth_profile=saved-totp`: visible browser, username/password/TOTP autofill, no submit. - - `auth_profile=auto`: hidden or visible automatic mode, full autofill and submit; explicit only. - - `credential_source=none|keychain|bitwarden`: where saved values come from, separate from whether submit/headless are enabled. - -Integration points: - -- macOS Keychain service `openconnect-lite`. -- Optional Bitwarden CLI item `LM LDAP`. -- `openconnect-lite` config autofill rules. -- Swift app menu and reconnect behavior. -- Existing aliases from shell install block. - -Migration/cutover: - -- Fresh installs write `credential_source=none` and `auth_profile=manual`. -- Existing `credential_source=bitwarden` installs continue as saved-totp/auto-capable, but `vpn` default should still prefer manual-visible unless the user explicitly chooses auto. -- `vpn-auto` remains the explicit old behavior. -- `--manual-full` remains compatibility alias for the new default manual profile. - -Displaced path: - -- Installer Enter=Bitwarden. -- CLI `CONNECT_MODE=auto`. -- Full password+TOTP seed as mandatory preflight for non-`manual-full`. -- App primary/default connect action pointing at `.auto`. - -Acceptance evidence gate: - -- A clean temp HOME proves fresh defaults. -- Captured interactive installer output proves that the default path is understandable without knowing Bitwarden, Keychain, TOTP seed, or `credential_source`. -- Fake `openconnect-lite` launch capture proves display/autofill/click env for each profile. -- App source/smoke proves primary UI wiring. -- Installed-bundle verification is required only when refreshing the local app after implementation. - -## Пользовательский Процесс Установки - -Цель этого сценария: человек, далёкий от IT, должен понимать, что сейчас произойдёт, что от него требуется, и что можно пропустить. Installer не должен начинать с технического выбора credential source. - -### Первый Экран - -Текст должен быть коротким: - -```text -Lemana VPN установит приложение для подключения к корпоративному VPN. - -По умолчанию ничего не сохраняется: при подключении откроется окно входа, -а вы введёте логин, пароль и одноразовый код сами. - -Дополнительные удобства можно включить сейчас или позже. -``` - -Основное действие по Enter: продолжить с ручным безопасным режимом. - -Запрещено на первом экране: - -- `credential_source` -- `auth_profile` -- `TOTP seed` -- `openconnect-lite` -- `Bitwarden item` -- `headless` - -### Шаг 1: Что Будет Установлено - -Installer показывает не список пакетов, а понятные пункты: - -```text -Будет установлено: -- приложение Lemana VPN в меню macOS; -- команда vpn для подключения из Terminal; -- VPN-компонент, который открывает корпоративную страницу входа; -- безопасная очистка DNS после отключения. -``` - -Технические зависимости (`openconnect`, `pipx`, `openconnect-lite`) можно показывать только в подробном логе ниже или в debug/dry-run. - -### Шаг 2: Базовый Режим Входа - -Вопрос: - -```text -Как подключаться по умолчанию? - -1) Вводить всё вручную (рекомендуется) - Откроется окно входа. Вы сами введёте логин, пароль и одноразовый код. - -2) Запомнить логин и пароль на этом Mac - При входе логин и пароль подставятся сами. Одноразовый код вы введёте вручную. - -3) Настроить автоматический вход - Нужно заранее сохранить пароль и постоянный код настройки 2FA. - -Выбор [1/2/3, Enter=1]: -``` - -Поведение: - -- `1` ставит `auth_profile=manual`, `credential_source=none`. -- `2` ставит `auth_profile=saved-login`, `credential_source=keychain`, после установки запускает настройку LDAP username/password. -- `3` открывает следующий уточняющий шаг и не должен быть default. - -### Шаг 3: Автоматический Вход Только Для Продвинутого Пути - -Если выбран пункт 3, installer объясняет риск простым языком: - -```text -Автоматический вход работает только если на Mac сохранены: -- корпоративный пароль; -- постоянный код настройки 2FA. - -Это не текущие 6 цифр из приложения-аутентификатора. -Если вы не знаете, что это такое, выберите ручной режим. -``` - -Варианты: - -```text -1) Вернуться к ручному режиму -2) Сохранить пароль и постоянный код 2FA в macOS Keychain -3) Взять пароль и код 2FA из Bitwarden -``` - -Поведение: - -- `1` возвращает к `auth_profile=manual`. -- `2` ставит `auth_profile=saved-totp`, `credential_source=keychain`, запускает full Keychain setup. -- `3` ставит `credential_source=bitwarden`; Touch ID спрашивается отдельным простым вопросом. - -### Шаг 4: Bitwarden Как Дополнительная Функция - -Bitwarden не должен выглядеть обязательным. Вопрос появляется только после явного выбора Bitwarden: - -```text -Использовать Bitwarden для хранения VPN-пароля? - -Подойдёт, если у вас уже есть запись с корпоративным VPN-паролем -и постоянным кодом настройки 2FA. -``` - -Если Bitwarden CLI не найден: - -```text -Bitwarden не найден на этом Mac. -Можно установить его сейчас или вернуться к ручному режиму. -``` - -Default: вернуться к ручному режиму, если пользователь не уверен. - -### Шаг 5: Touch ID Только После Bitwarden - -Вопрос: - -```text -Разрешить Touch ID для открытия Bitwarden? - -Это не VPN-пароль. Touch ID только помогает достать данные из Bitwarden. -``` - -Default: нет, если пользователь не выбирал Bitwarden; да/нет можно спрашивать только в Bitwarden path. - -### Шаг 6: Понятный Прогресс Установки - -Progress log должен говорить человеческим языком: - -```text -Проверяю компоненты для VPN... -Устанавливаю приложение Lemana VPN... -Добавляю команду vpn... -Настраиваю безопасное отключение VPN... -Готово. -``` - -Если macOS просит пароль администратора, installer заранее объясняет: - -```text -macOS может запросить пароль от компьютера. -Он нужен, чтобы разрешить VPN подключаться и корректно очищать DNS после отключения. -``` - -### Финальный Экран - -Для ручного режима: - -```text -Готово. Как подключиться: -1. Откройте Lemana VPN из Applications или меню macOS. -2. Нажмите "Подключить". -3. В открывшемся окне введите корпоративный логин, пароль и одноразовый код. - -Проверить состояние: vpn --status -``` - -Для режима с сохранённым логином и паролем: - -```text -Готово. При подключении логин и пароль будут подставлены автоматически. -Одноразовый код нужно ввести вручную. -``` - -Для автоматического режима: - -```text -Готово. Автоматический вход включён. -Если он не сработает, используйте ручной режим: vpn --manual-full -``` - -### Ошибки И Подсказки - -Ошибки должны говорить, что делать дальше: - -- Нет сохранённого пароля: `Запустите vpn --configure-login или подключитесь вручную.` -- Нет постоянного кода 2FA для auto mode: `Автоматический вход не настроен. Выберите ручной режим или добавьте постоянный код настройки 2FA.` -- Bitwarden заблокирован в приложении без Terminal: `Откройте Terminal и выполните vpn, либо подключитесь вручную.` -- Пользователь выбрал непонятный вариант: `Введите 1, 2 или 3. Если не уверены, нажмите Enter.` - -### Acceptance Evidence Для Installer UX - -- Interactive dry-run через `expect` должен захватить первый экран, выбор режима и финальную подсказку. -- Smoke должен проверять, что Enter выбирает ручной режим. -- README должен показывать тот же сценарий, что и installer. -- В обычном wizard не должно быть слов `credential_source`, `auth_profile`, `TOTP seed`; они допустимы только в help/debug/docs for advanced. - -## Дополнительные Функции - -1. **Core manual SSO (default)** - - Что делает: открывает видимый браузер Keycloak, не читает Bitwarden/Keychain, не заполняет поля, не нажимает submit. - - Когда включается: всегда на fresh install и при `vpn` без дополнительных настроек. - - Когда не включается: если пользователь явно запускает `vpn-auto` или выбрал auto profile. - -2. **Saved LDAP login/password in macOS Keychain** - - Что делает: сохраняет LDAP username/password в Keychain и подставляет их в видимую форму. - - Когда включается: пользователь явно запускает настройку сохранения LDAP или выбирает этот пункт в installer. - - OTP: текущий одноразовый код вводится руками; TOTP seed не требуется. - -3. **Saved TOTP seed** - - Что делает: сохраняет постоянный TOTP seed и позволяет `openconnect-lite` генерировать текущий код для autofill. - - Когда включается: пользователь явно добавил seed через full Keychain setup или Bitwarden sync. - - Ограничение: текущий 6-значный код нельзя сохранять как настройку. - -4. **Autofill** - - Что делает: заполняет те поля, для которых есть capability: username/password, а затем optional TOTP. - - Когда включается: при `saved-login` или `saved-totp`. - - Submit: по умолчанию выключен; пользователь нажимает вход сам. - -5. **Autosubmit/headless** - - Что делает: запускает старый удобный путь с hidden browser, полным autofill и нажатием submit. - - Когда включается: только явным `vpn-auto`, `--auto`, profile `auto` или отдельным opt-in флагом. - - Предусловие: saved login/password и TOTP seed должны быть доступны; без TOTP seed этот режим должен fail fast с понятной ошибкой. - -6. **Bitwarden sync** - - Что делает: читает LDAP password и TOTP seed из Bitwarden item, нормализует TOTP seed и перекладывает в Keychain. - - Когда включается: только если пользователь выбрал Bitwarden provider. - - Fallback: если `bw` отсутствует или locked в GUI/no-tty, использовать уже сохранённый Keychain либо manual path, не блокировать default. - -7. **Touch ID helper** - - Что делает: локально защищает чтение Bitwarden master password. - - Когда включается: только вместе с Bitwarden provider и явным согласием пользователя. - - Не делает: не заменяет LDAP password и не является хранением VPN credentials. - -## Tasks - -### Task 1: Rewrite user-facing contract and docs - -- Scope: `README.md`. -- Change: describe manual-first default, feature matrix, migration from old Bitwarden-first install, and the non-IT installation scenario from this plan. -- Expected output: docs no longer say Bitwarden/auto are default; examples show `vpn` as manual and `vpn-auto` as opt-in; installation instructions use the same wording as the installer. -- Verification: `rg -n "по умолчанию|Bitwarden|vpn-auto|manual-full|одноразовый код|ручной режим" README.md`. -- Acceptance evidence: a new user can read the first install section and understand what will be installed, what they will type during first connect, and that Bitwarden is optional. -- Parallel: yes, but must be reconciled with installer flag names from Task 2. - -### Task 2: Change installer defaults and capability selection - -- Scope: `install.sh`. -- Change: default credential source becomes none/manual; Bitwarden and Touch ID default off; wizard Enter keeps manual; add or reuse flags for saved LDAP, full Keychain, Bitwarden provider, and auto profile; rewrite interactive copy using the "Пользовательский Процесс Установки" section. -- Expected output: dry-run shows manual/default auth and no Bitwarden/Touch ID install unless selected; interactive prompts say "вводить всё вручную", "запомнить логин и пароль", and "автоматический вход" instead of exposing low-level config names. -- Verification: `sh -n install.sh`; `sh install.sh --dry-run --non-interactive`; interactive dry-run through `expect` if available. -- Acceptance evidence: clean install dry-run does not try to install Bitwarden and does not schedule Keychain credential prompts by default; captured interactive output is understandable without IT vocabulary and Enter chooses manual mode. -- Parallel: no, because it defines env contract consumed by CLI/tests. - -### Task 3: Refactor CLI auth profiles and partial Keychain support - -- Scope: `bin/vpn-lemanapro.sh`. -- Change: replace auto-first/default credential preflight with an auth-profile resolver; add partial Keychain support for saved login without TOTP; keep full Keychain and Bitwarden sync as opt-ins. -- Expected output: no-credential default reaches visible manual browser; saved-login fills username/password and leaves OTP manual; saved-totp can fill OTP but still does not submit by default. -- Verification: `bash -n bin/vpn-lemanapro.sh`; fake `openconnect-lite` capture in `tests/smoke.sh`. -- Acceptance evidence: JSON events clearly state active auth profile and missing optional capabilities without treating them as fatal in manual profiles. -- Parallel: no, central truth owner. - -### Task 4: Preserve automatic compatibility without keeping it dominant - -- Scope: `bin/vpn-lemanapro.sh`, `install.sh`, shell aliases. -- Change: keep `vpn-auto`/`--auto` as explicit old behavior; guard autosubmit/headless behind full saved credentials; keep `--manual-full` compatibility alias for the default manual profile. -- Expected output: current power users can still run the old path intentionally. -- Verification: `tests/smoke.sh` assertions for `vpn-auto`, `--auto`, `--manual-full`, launch env. -- Acceptance evidence: default path and auto path are both test-covered and visibly separate. -- Parallel: can run after Task 3. - -### Task 5: Update Swift menu-bar app surface - -- Scope: `app/Sources/LemanaVPN/VPNManager.swift`, `app/Sources/LemanaVPN/LemanaVPNApp.swift`. -- Change: make the primary menu action manual-first; demote automatic connect to an explicit secondary action; keep app as a CLI wrapper; surface missing saved credentials as setup guidance, not hidden prompts. -- Expected output: app default action does not trigger Bitwarden or autosubmit unless the user chooses an explicit auto/saved mode. -- Verification: `swift build -c release --package-path app`; smoke greps for the new labels/mode wiring. -- Acceptance evidence: app source and built binary contain the manual-first label and map it to the manual profile. -- Parallel: yes after CLI mode names are stable. - -### Task 6: Expand smoke coverage - -- Scope: `tests/smoke.sh`. -- Change: update old assertions and add cases for fresh manual default, saved-login-only, saved-totp, Bitwarden opt-in, and app wiring. -- Expected output: smoke becomes the executable spec for the feature matrix and installer UX. -- Verification: `sh -n tests/smoke.sh`; `tests/smoke.sh`. -- Acceptance evidence: smoke fails if Bitwarden or auto become fresh defaults again, or if the basic wizard no longer defaults to manual mode with non-technical wording. -- Parallel: yes after Task 2 and Task 3 contracts are known. - -### Task 7: Final verification and local update notes - -- Scope: whole repo; installed app only if doing a local refresh. -- Change: run non-live ladder, then optionally install/update local checkout with `sh install.sh` after user approval for real local changes. -- Expected output: plan implemented with evidence and a clear "live VPN not run" or live manual evidence. -- Verification: - - `sh -n install.sh` - - `sh -n uninstall.sh` - - `bash -n bin/vpn-lemanapro.sh` - - `sh -n tests/smoke.sh` - - `tests/smoke.sh` - - `swift build -c release --package-path app` - - `git diff --check` -- Acceptance evidence: final answer includes how to run, how to check, how to update installed setup, and what live checks were intentionally skipped. -- Parallel: no, final integration gate. - -## Non-Goals - -- Do not replace `openconnect-lite` or implement a separate VPN client in Swift. -- Do not store current 6-digit OTP codes. -- Do not make the GUI collect secrets directly. -- Do not remove existing automatic mode; demote it to explicit opt-in. -- Do not change DNS cleanup behavior as part of this refactor. - -## Risks - -- Existing users may expect `vpn` to auto-connect. Mitigation: keep `vpn-auto` and document the cutover clearly. -- Partial Keychain support may need careful `openconnect-lite` behavior verification when TOTP is absent. Mitigation: fake launch tests first, then one visible manual smoke if needed. -- Swift app could lag behind installed source again. Mitigation: after implementation refresh installed bundle via `sh install.sh` and verify with app source/build or `strings` if doing local install. -- Credential language can confuse Bitwarden master password, LDAP password, TOTP seed, and current OTP. Mitigation: keep labels explicit in CLI and README. - -## Execution Order - -1. Task 2 defines the installer/env contract. -2. Task 3 implements the CLI truth owner. -3. Task 4 preserves explicit auto compatibility. -4. Task 5 updates the app wrapper. -5. Task 6 turns the matrix into smoke coverage. -6. Task 1 updates docs with final flag/profile names. -7. Task 7 verifies and prepares local update instructions. +Use `docs/goals/two-auth-modes/GOAL.md` for execution. diff --git a/docs/goals/startup-dns-and-icons/GOAL.md b/docs/goals/startup-dns-and-icons/GOAL.md new file mode 100644 index 0000000..d5cc206 --- /dev/null +++ b/docs/goals/startup-dns-and-icons/GOAL.md @@ -0,0 +1,19 @@ +# Goal: Recover stale VPN DNS and ship the approved icons + +Use Krypton Execution on `docs/goals/startup-dns-and-icons/PLAN.md`. Preserve user work and auth behavior. Root owns privileged DNS logic and integration; the icon agent has an explicitly disjoint write scope. Prove recovery using synthetic journal/process/DNS fixtures, never real network changes. Report unrun native/reboot acceptance and snapshotless legacy limits. + +## Result — 2026-10-08 + +Implemented in source. Normal installation updates the root helper and ships the native icon renderer. The app gates connection on a capability-checked startup recovery, displays failures/external sessions, and retries through Refresh. CLI exit replaces its finished process before recovery, retaining the client's exit code. DNS snapshots are data-only, versioned, private, durable and serialized with native lockf; no arbitrary private-address reset remains. + +PRE review passed after adding unresolved-journal retention, shared locking and verified owner ancestry. Post-review fixes cover Bash pipeline/progress siblings and custom DNS on newly enabled network services. Final integration review: no outstanding blockers. + +Automated evidence: + +- `python3 tests/dns_recovery.py`: all 16 cases passed; subsequent focused competing-engine regression under the same CLI also passed. Original helper replay fails the new reboot recovery contract as expected. +- `python3 tests/dns_cli.py`: noninteractive command, legacy helper rejection, fixed JSON, path quoting and preserved client exit codes passed. +- `python3 tests/menu.py` and `python3 tests/icons.py`: startup lifecycle, fake CLI, real session timing, both icon themes and offscreen pixel checks passed. +- `tests/smoke.sh`, `python3 tests/configuration.py`, `python3 tests/auth_settings.py`, `python3 tests/logging.py`, and the installed openconnect-lite Python running `tests/yandex_import.py`: passed. +- Shell syntax, `git diff --check` and `swift build -c release --package-path app`: passed. + +Real installation, app/GUI launch, live VPN/DNS changes and actual reboot acceptance were not run. Snapshotless pre-upgrade DNS must first be restored to the user's normal settings; no software can infer an unrecorded custom value. Other network locations, deleted services or invalid journals fail visibly and preserve the record. diff --git a/docs/goals/startup-dns-and-icons/PLAN.md b/docs/goals/startup-dns-and-icons/PLAN.md new file mode 100644 index 0000000..072874f --- /dev/null +++ b/docs/goals/startup-dns-and-icons/PLAN.md @@ -0,0 +1,40 @@ +# Startup DNS Recovery and Approved Icons Implementation Plan + +**Intent:** Install the approved shield icon family with gradual interior fill, and restore normal DNS at app startup after an interrupted VPN session. +**Current Behavior:** App init only refreshes status. vpnc-script persistently changes network-service DNS; abrupt shutdown skips its cleanup and /var/run backups disappear at boot. The existing helper resets any 10.x DNS on three service names and is skipped on ordinary reinstall. Runtime icons still differ from the approved set. +**Expected Outcome:** App startup runs a noninteractive guarded recovery before allowing Connect. A root-owned vpnc wrapper records original DNS and exact VPN DNS before changes, persists the journal across reboot, and restores matching leftovers only when no VPN/SSO is active. Approved vector geometry and real session progress are used by runtime icons. +**Target-Perspective Output:** Isolated reboot/crash fixtures recover recorded DHCP or custom DNS, preserve unrelated/changed DNS, skip active tunnels/SSO, survive reused PIDs and surface failed recovery. Icon bitmap/geometry checks and Swift build prove the native implementation without launching the GUI. +**Truth Owner:** Root-owned DNS wrapper/journal owns before/expected DNS evidence and mutations. CLI owns invocation/events/capability reporting. Swift only requests recovery and reports results. SessionTiming still owns derived icon progress. +**Contract Boundary:** Add `--repair-dns` to CLI; root helper protocol 2 has `--recover`, `--vpnc` and capability inspection. Existing JSON fields remain; DNS capability is additive. App calls recovery only after the CLI advertises support. OpenConnect receives the root wrapper through --script; the wrapper delegates normal routes/DNS setup to installed vpnc-script. +**Cutover:** One root helper serves hook, exit and startup recovery. Remove broad automatic 10.x reset and the CLI's unowned fallback. Installer always updates the root helper. Root journal lives under /var/db, not /var/run. Default runtime icon becomes shield; previously saved ring/clock/time selections remain. +**Displaced Path:** Ad hoc unconditional DNS resets and independent Swift icon shapes. No new daemon, VPN client, credential path, root Python from user-controlled locations or general network-settings API. +**Value Density:** Reuse native networksetup, vpnc-script and existing sudoers wrapper boundary. New files are limited to native icon drawing and focused regression checks; no dependencies. +**Acceptance Evidence:** Real helper source executed with substituted system tools/temp journal, synthetic OpenConnect hook environment and recorded command log; CLI recovery/fake client argument checks; startup wiring/build; headless icon rendering checks and existing suites. +**Evidence Lane:** Automated fixtures only. No actual reboot, live DNS mutation, real VPN, Keychain, app/GUI launch or screenshots. +**Kill Criteria:** Recovery cannot clear arbitrary non-default DNS; current values must match recorded VPN values. Live or indeterminate process checks cannot authorize cleanup. No connection begins during startup recovery. No stale-PID-only guard. New helper and Swift source must ship on normal update. +**Architecture Slice:** `libexec/lemana-vpn-dns-cleanup` + `bin/vpn-lemanapro.sh` + `VPNManager.swift` + installer/template/tests; independent `VPNIcons.swift` + `LemanaVPNApp.swift` + `SessionTiming.swift`. All prior dirty work is preserved. +**Plan Review Gate:** PRE review passed; implementation and post-review checks completed (see GOAL.md). + +## DNS contract + +- The helper uses fixed system commands and root-owned persistent files. Snapshot stores location, service names, original DNS (empty means DHCP), exact INTERNAL_IP4_DNS, boot identity and owning OpenConnect PID/start identity. It never sources state as code. +- All hook and recovery transactions share native `/usr/bin/lockf -k` in the protected root directory. The helper runs its embedded shell body under the lock, with no public unlocked entry point. The lock releases on process death and survives reboot as an unlocked file. Validate versioned journal data and reject symlink/untrusted state paths. +- Never overwrite an unresolved journal: recover a proven orphan under the same lock, otherwise fail the new hook visibly and retain the journal. A hook may exclude only its verified OpenConnect owner and actual ancestors from the live-process guard; startup excludes nobody. Owner identity is boot plus PID plus process start time. +- Hook connect stores a durable journal before invoking vpnc-script. Observe all enabled network services so interface changes during setup do not defeat capture. Capture only valid DNS output; no heuristic range inference. +- Startup recovery checks actual OpenConnect, openconnect-lite and non-status Lemana connect processes before restoring, and checks again before writes. Missing/failed process evidence blocks mutation. Reused non-VPN PIDs do not block a legitimate old-boot recovery. +- Restore only recorded services in the same network location when current DNS exactly matches the expected VPN list; no-op if already original. If the user changed DNS since the session, preserve it. Keep failed restores for retry. +- Normal hook disconnect preserves custom DNS too: capture the desired value before upstream's unconditional DHCP reset, then restore it only if upstream left empty/expected DNS and no competing tunnel is active. Owner mismatch never consumes another session's journal. +- The disconnect exception requires exact owner and network-location match before upstream invocation. A failed upstream call retains the journal. Tests must exercise custom DNS through upstream's unconditional Empty, partial recovery followed by new connect, location mismatch, and concurrent hook/recovery. +- Snapshotless pre-upgrade leftovers cannot be proven foreign from a 10.x address alone. Automatic repair must skip them and document the limitation, not invent the prior setting. +- CLI startup uses sudo -n and fixed JSON result states. Old/missing helper is detected by a read-only protocol marker, never by executing an old helper with an unknown flag. The app surfaces upgrade/permission/recovery failures and blocks Connect while checking. Recovery never creates a VPN session. + +## Task board and parallel scopes + +1. Root: DNS wrapper/journal/hook implementation, CLI repair/capability/hook args, installer helper update/template, DNS fixture tests. No icon files edited by root during parallel work. +2. Icon agent (explicitly parallel): ONLY `app/Sources/LemanaVPN/VPNIcons.swift`, `LemanaVPNApp.swift`, `SessionTiming.swift`, `tests/icons.py`. Exact approved family from `artifacts/icon-family-v1/draw_icons.py`: 24-grid, 1.75 stroke, neutral 14% body, 60% bottom-up progress fill and colored perimeter; no demo 8h. No VPNManager/install/DNS/README edits. Return packaging instructions to root. +3. Root after icon merge: startup recovery lifecycle/UI messages in manager and menu, installer icon download, docs and full focused regressions. Credentials/auth/reconnect policy remain unchanged. +4. Read-only correctness/safety/maintainability reviewers; fix blockers; run shell checks, new DNS/icons tests, smoke, auth/config/import/menu/logging checks, Swift release build and diff whitespace check. + +## Known limits + +No software can restore a pre-upgrade custom DNS value that was never recorded. Unrecognised root journal or network-location changes are preserved for inspection. Native visual quality and actual reboot/network acceptance remain unproven without a separate live-test request. diff --git a/docs/goals/two-auth-modes/GOAL.md b/docs/goals/two-auth-modes/GOAL.md new file mode 100644 index 0000000..45c8b34 --- /dev/null +++ b/docs/goals/two-auth-modes/GOAL.md @@ -0,0 +1,13 @@ +# Goal: Two Auth Modes + +Use Krypton Execution to execute `docs/goals/two-auth-modes/PLAN.md`. + +Core rules: +- Treat PLAN.md as the source plan. +- Preserve intent, ownership, contract, cutover, evidence, and kill criteria. +- Keep exactly two public modes: `manual-keychain` and `bitwarden-auto`. +- Do not introduce a third first-class user mode; old flags/env may exist only as compatibility shims mapped to one of the two modes. +- Manual-keychain stores only LDAP username/password in macOS Keychain and always leaves OTP manual. +- Bitwarden-auto preserves the old personal Bitwarden + Touch ID + full autofill/submit path. +- Capture acceptance evidence from both checkout source and installed local paths. +- Say "implemented but unproven" if installed-path or launch evidence cannot be captured. diff --git a/docs/goals/two-auth-modes/PLAN.md b/docs/goals/two-auth-modes/PLAN.md new file mode 100644 index 0000000..636d3c7 --- /dev/null +++ b/docs/goals/two-auth-modes/PLAN.md @@ -0,0 +1,201 @@ +# Two Auth Modes Implementation Plan + +**Intent:** Схлопнуть Lemana VPN до двух понятных пользовательских режимов: ручной вход с сохранёнными LDAP login/password в macOS Keychain и старый личный automatic path через Bitwarden + Touch ID + полный autofill/submit. +**Current Behavior:** Checkout уже manual-first, но публичный контракт всё ещё размазан по `manual`, `saved-login`, `saved-totp`, `auto`, совместимым alias-командам и status/menu `available_modes`. Локально установленный `~/bin/vpn-lemanapro.sh` отстаёт от checkout, поэтому старые сборки и приложение могут видеть другую схему. +**Expected Outcome:** Installer, CLI, app menu, README и smoke tests показывают ровно два режима: `manual-keychain` и `bitwarden-auto`. Первый хранит только LDAP username/password в Keychain и всегда оставляет OTP ручным. Второй сохраняет старую рабочую конфигурацию с Bitwarden, Touch ID, permanent 2FA secret, hidden browser, autofill и submit. +**Target-Perspective Output:** Пользователь переустанавливает пакет, выбирает один из двух режимов, видит тот же выбор в `vpn --status` и в LemanaVPN.app. В ручном режиме он может подключиться с подстановкой логина/пароля, каждый раз вводит OTP руками и может обновить логин/пароль из меню. В Bitwarden-режиме работает прежний автоматический путь. +**Truth Owner:** Два repo profile-template файла владеют режимами; `install.sh` выбирает один профиль и пишет активный `~/.config/lemana-vpn/env`; `bin/vpn-lemanapro.sh` исполняет режим; Swift app только читает CLI JSON и запускает CLI. +**Contract Boundary:** Public contract: mode id `manual-keychain|bitwarden-auto`, CLI flags/status JSON/app menu labels. Internal compatibility may still map old flags to a mode, but UI/docs/installer no longer expose four profiles as first-class choices. +**Cutover:** Fresh install defaults to `manual-keychain` and offers `bitwarden-auto` as the old personal advanced mode. Existing env files with `manual`, `saved-login`, `saved-totp`, or `auto` are migrated or normalized to one of the two public modes during install/status. +**Displaced Path:** Displace public `manual`, `saved-login`, `saved-totp`, and `auto` profile matrix. Keep only minimal compatibility aliases where needed, and make them resolve to one of the two modes. +**Value Density:** The smallest high-value slice removes mode ambiguity and stale install drift while preserving the two flows the user actually needs. +**Acceptance Evidence:** Clean temp HOME install output shows only two modes. Source and installed `vpn --status --json` agree on mode/status JSON. Fake launch captures prove manual-keychain uses visible browser, login/password autofill, no TOTP autofill, no submit; bitwarden-auto uses Bitwarden sync, Touch ID when enabled, hidden browser, TOTP autofill, submit. Installed CLI shasum matches checkout and installed app binary contains the updated menu strings. +**Evidence Lane:** Non-live first: shell syntax, smoke tests, Swift build, `git diff --check`, source/installed status JSON and installed binary string checks. Live VPN check only after `vpn --status`, starting with manual-keychain visible flow; skip if connected/connecting unless explicitly approved. +**Kill Criteria:** No installer prompt, README section, app menu, or `available_modes` JSON may present more than two public modes. No fresh install may require Bitwarden. No manual-keychain path may save or require TOTP seed. No bitwarden-auto path may silently degrade into half-configured Keychain-only auto. +**Architecture Slice:** `install.sh`, `bin/vpn-lemanapro.sh`, two new profile-template files, Swift app menu contract, README, smoke tests, installed-path refresh checks. +**Plan Review Gate:** PRE self-review done against Krypton reviewer checklist; external subagent review not run because subagents require an explicit user request in this environment. + +## Короткий План + +🎯 Цель: оставить ровно два рабочих режима установки и подключения: ручной с Keychain login/password + ручной OTP, и старый Bitwarden/Touch ID automatic flow. + +📦 Репозитории: только `/Users/dokril/git/home/lemana-vpn`. + +🧩 Модули: `install.sh`, `bin/vpn-lemanapro.sh`, `app/Sources/LemanaVPN`, `templates/`, `tests/smoke.sh`, `README.md`; installed refresh checks для `~/bin/vpn-lemanapro.sh` и `~/Applications/LemanaVPN.app`. + +🛠 Основные изменения: +- Создать два profile-template файла в `templates/`: `manual-keychain.env` и `bitwarden-auto.env`. +- Перевести installer на выбор только этих двух режимов; убрать публичные вопросы про `saved-login/saved-totp/auto`. +- В CLI ввести public mode `LEMANA_VPN_MODE=manual-keychain|bitwarden-auto` и нормализовать старые env/flags в один из двух режимов. +- Сделать app menu и status JSON двухрежимными: `Подключить`, `Подключить старым автоматическим способом`, `Изменить логин и пароль`. +- Оставить старые flags/aliases только как compatibility shims, без отдельного третьего режима в UI/docs. +- Переписать smoke tests под два режима и добавить installed-path evidence: `shasum`, `strings`, `vpn --status --json`. +- Сжать README до двух сценариев установки и восстановления. + +✅ Проверка: `sh -n install.sh`, `sh -n uninstall.sh`, `bash -n bin/vpn-lemanapro.sh`, `sh -n tests/smoke.sh`, `tests/smoke.sh`, `swift build -c release --package-path app`, `git diff --check`, затем installed checks после `sh install.sh`. + +⚠️ Риски: старые env с `saved-totp`/`auto` могут мигрировать не туда; installed CLI/app могут снова отставать; live auto-flow зависит от Bitwarden/Touch ID и permanent 2FA secret. + +➡️ Следующий шаг: начать с mode contract и двух template-файлов, затем подключить installer/runtime normalization. + +## Architecture Slice + +Files to create: + +- `templates/manual-keychain.env` - source profile for the normal manual mode. +- `templates/bitwarden-auto.env` - source profile for the old personal automatic mode. + +Files to modify: + +- `install.sh` - two-mode wizard, env rendering from templates, migration from old flags/env, final next steps. +- `bin/vpn-lemanapro.sh` - public mode parser, compatibility mapping, status JSON, app menu JSON, runtime env for autofill/click/TOTP. +- `app/Sources/LemanaVPN/VPNManager.swift` - two launch modes, menu labels, status decoding if JSON shape changes. +- `app/Sources/LemanaVPN/LemanaVPNApp.swift` - remove multi-mode rendering that exposes old profiles; keep credential edit action. +- `tests/smoke.sh` - replace four-profile assertions with two-mode assertions plus compatibility tests. +- `README.md` - rewrite mode docs around two supported configurations. +- `docs/goals/manual-first-auth-flow/*` - mark superseded or point to this plan to avoid executing the older four-profile plan. + +Files to avoid: + +- `libexec/lemana-vpn-dns-cleanup` - unrelated to auth mode split. +- `templates/openconnect-lite-config.toml` unless fake launch evidence shows separate openconnect-lite configs are necessary. +- Direct installed `openconnect-lite` source edits; keep using the existing runtime patch path. + +Source of truth: + +- Repo templates define the two modes. +- Active install writes exactly one selected mode into `~/.config/lemana-vpn/env`. +- Keychain stores LDAP password for `manual-keychain`; Bitwarden remains the upstream source for `bitwarden-auto` and syncs into Keychain at runtime. + +Read path: + +- `install.sh` reads selected mode and profile template, writes env. +- `bin/vpn-lemanapro.sh` reads env, normalizes legacy fields, checks required credentials for the selected mode, emits JSON/status, launches `openconnect-lite`. +- Swift app reads only `vpn --status --json` and passes a CLI argument for the chosen mode. + +Write path: + +- `vpn --configure-login` writes or updates username/password only for `manual-keychain`. +- Bitwarden path reads item `LM LDAP`, unlocks through Touch ID/manual master password, then writes password and normalized permanent TOTP secret into Keychain for `bitwarden-auto`. +- Installer never writes the current 6-digit OTP. + +Contract boundary: + +- Public mode ids: `manual-keychain`, `bitwarden-auto`. +- Public status JSON should expose `mode.id`, `mode.label`, `mode.ready`, `mode.setup_hint`, and a maximum of two user actions. +- Compatibility inputs allowed: `--saved-login`, `--manual-full`, `--manual`, `--auto`, old `LEMANA_VPN_AUTH_PROFILE`, old `LEMANA_VPN_CREDENTIAL_SOURCE`. + +Integration points: + +- macOS Keychain service `openconnect-lite`. +- Optional Bitwarden CLI item `LM LDAP`. +- Optional Touch ID helper `~/bin/keychain-fingerprint`. +- `templates/openconnect-lite-config.toml` and runtime autofill patches. +- Swift app JSON contract and installed app binary refresh. + +Migration/cutover: + +- `saved-login` -> `manual-keychain`. +- `manual`/`manual-full` -> `manual-keychain`, but if no saved password exists, status shows setup hint and connection can still offer full manual fallback as diagnostic. +- `saved-totp`/`auto` + `credential_source=bitwarden` -> `bitwarden-auto`. +- `saved-totp`/`auto` + `credential_source=keychain` is no longer a public mode; installer should ask to switch to either `manual-keychain` or `bitwarden-auto`, or keep a hidden compatibility shim for one release with a warning. + +Displaced path: + +- The old four-profile matrix. +- App `available_modes` rendering multiple profile buttons. +- README/installer wording that teaches `saved-login`, `saved-totp`, and `auto` as equal modes. + +Acceptance evidence gate: + +- Fresh install dry-run contains only the two human mode names. +- `tests/smoke.sh` proves both launch profiles and legacy compatibility. +- `~/bin/vpn-lemanapro.sh --status --json` matches source behavior after `sh install.sh`. +- `strings ~/Applications/LemanaVPN.app/Contents/MacOS/LemanaVPN` proves updated menu labels are installed. + +## Tasks + +### Task 1 - Define Two Mode Templates + +Scope: create `templates/manual-keychain.env` and `templates/bitwarden-auto.env`; add a tiny renderer/loader in `install.sh`. + +Expected output: the selected profile writes one active public mode and its derived legacy compatibility vars. + +Verification: `sh install.sh --dry-run --non-interactive --mode manual-keychain` and `--mode bitwarden-auto` show the correct env writes without exposing old profile names. + +Acceptance evidence: dry-run output and rendered temp env contents. + +Parallel: no; this is the source-of-truth base. + +### Task 2 - Normalize Runtime Modes + +Scope: update `bin/vpn-lemanapro.sh` to parse `LEMANA_VPN_MODE`, map old env/flags to the two public modes, and derive autofill/TOTP/submit settings. + +Expected output: manual-keychain uses visible browser, password autofill, TOTP off, submit off; bitwarden-auto uses Bitwarden sync, TOTP on, hidden browser, submit on. + +Verification: fake `openconnect-lite` capture in `tests/smoke.sh`; `bash -n bin/vpn-lemanapro.sh`. + +Acceptance evidence: JSON events and capture files for both modes. + +Parallel: after Task 1 only. + +### Task 3 - Simplify Installer And CLI UX + +Scope: installer questions, flags, usage, aliases, final next steps, and legacy migration warning. + +Expected output: installer asks one question with two choices, configures login/password for manual-keychain, and reserves Bitwarden/Touch ID setup for bitwarden-auto. + +Verification: expect-based interactive smoke and dry-run smoke. + +Acceptance evidence: captured installer transcript with no public `saved-login/saved-totp/auto` wording. + +Parallel: can run after Task 1 while Task 2 is in progress, but integration proof must wait. + +### Task 4 - Update Swift Menu Contract + +Scope: `VPNManager.swift` and `LemanaVPNApp.swift`. + +Expected output: disconnected menu is two-mode aware and does not render old profile buttons. Credential edit remains a Terminal launcher for `vpn --configure-login`. + +Verification: Swift source smoke, `swift build -c release --package-path app`, installed binary `strings` after install. + +Acceptance evidence: menu string checks and build output. + +Parallel: can run after Task 2 JSON shape is decided. + +### Task 5 - Rewrite Docs And Supersede Old Plan + +Scope: `README.md`, `docs/goals/manual-first-auth-flow/PLAN.md`, `docs/goals/manual-first-auth-flow/GOAL.md`. + +Expected output: docs teach only two modes and point the older four-profile plan to this plan. + +Verification: `rg -n "saved-totp|auth_profile=|credential_source=|four-profile|manual-full" README.md docs/goals`. + +Acceptance evidence: only compatibility sections mention old names. + +Parallel: can run after Tasks 1-3 settle final names. + +### Task 6 - Final Verification And Installed Refresh + +Scope: run non-live ladder, install locally, compare installed CLI/app, and optionally perform a live manual check if status is safe. + +Expected output: checkout and installed paths agree; user can run the chosen mode from Terminal and app. + +Verification: `sh -n install.sh`, `sh -n uninstall.sh`, `bash -n bin/vpn-lemanapro.sh`, `sh -n tests/smoke.sh`, `tests/smoke.sh`, `swift build -c release --package-path app`, `git diff --check`, `sh install.sh`, `vpn --status --json`, `shasum bin/vpn-lemanapro.sh ~/bin/vpn-lemanapro.sh`, installed app `strings`. + +Acceptance evidence: command outputs plus a note if live VPN was skipped because status was connected/connecting. + +Parallel: no; final integration gate. + +## PRE Self-Review + +Mode: PRE + +Verdict: aligned + +Findings: + +- minor: "two files" could mean runtime shell modules rather than env templates. Smallest correction: execution should confirm with the user before choosing a different file split, but the current plan makes templates the truth owner because the stated pain is reinstall/config drift. +- minor: legacy `saved-totp` + keychain-only users are not part of the requested two modes. Smallest correction: keep one-release compatibility warning or migration prompt rather than deleting the path blindly. + +Recommended next gate: execute Task 1 and Task 2 together as the contract slice, then run smoke before touching Swift/docs. diff --git a/docs/goals/yandex-key-import/GOAL.md b/docs/goals/yandex-key-import/GOAL.md new file mode 100644 index 0000000..f1fb1fe --- /dev/null +++ b/docs/goals/yandex-key-import/GOAL.md @@ -0,0 +1,3 @@ +# Goal: Import Yandex Key OTP locally + +Use Krypton Execution to execute `docs/goals/yandex-key-import/PLAN.md`. Preserve the two auth modes and previous dirty work. Reuse Keychain/PyOTP, verify the selected record before writes, and keep secrets out of logs. Capture synthetic end-to-end wizard/launch evidence; no real backup, Keychain, GUI or VPN tests are authorized. diff --git a/docs/goals/yandex-key-import/PLAN.md b/docs/goals/yandex-key-import/PLAN.md new file mode 100644 index 0000000..858d33c --- /dev/null +++ b/docs/goals/yandex-key-import/PLAN.md @@ -0,0 +1,45 @@ +# Local Yandex Key OTP Import Implementation Plan + +**Intent:** Import the user's selected VPN OTP from an official local Yandex Key export and generate it on this Mac without Bitwarden. +**Current Behavior:** Keychain/PyOTP and OTP autofill already exist, but manual-keychain disables OTP and the auto mode uses Bitwarden; there is no backup importer. +**Expected Outcome:** Installer and CLI expose a local wizard: choose file and entry, verify the current phone code, save only the selected secret in Keychain and enable local OTP. Existing manual setup remains the default. +**Target-Perspective Output:** A synthetic export imported through the real wizard yields one verified Keychain write and the persisted local OTP flag; a fake client receives shown browser, OTP on, submit off. Cancellation/invalid input yields no writes or launch. +**Truth Owner:** `openconnect-lite` Keychain account `totp/` owns the secret; `env` owns `LEMANA_VPN_LOCAL_OTP`; CLI owns status and launch flags. Python helper owns backup parsing and verified import only. +**Contract Boundary:** New CLI `--import-yandex-key`, installer option/question, optional status metadata. No secret in shell arguments, env, output, errors or logs. Only the helper accesses the selected secret in memory. +**Cutover:** Opt-in `LEMANA_VPN_LOCAL_OTP=1` changes manual-keychain OTP from off to on after verification and persistence. Browser stays visible, submit stays off. Existing bitwarden-auto and full-manual behavior remain. +**Displaced Path:** The unconditional manual-mode OTP-off branch becomes a validated optional setting; no new generator, cloud extractor, third auth mode or alternate Keychain store. +**Value Density:** Reuse existing keyring/PyOTP dependencies and existing runtime patches. One helper next to CLI, installed/uninstalled with it. +**Acceptance Evidence:** Deterministic tests of parsing, real PyOTP RFC fixture verification, fake Keychain writes/rollback, CLI dispatch and fake-client capture; status readiness and installer preservation; full automated regression ladder. +**Evidence Lane:** Temporary homes, synthetic public test secrets and stub Keychain only. No actual user export, Keychain, GUI, live VPN, install or uninstall. +**Kill Criteria:** No unsupported OTP silently accepted; no writes before verification; failed activation restores prior Keychain entry; no OTP enabled by mere presence of a saved seed; full-manual never fills OTP. +**Architecture Slice:** `install.sh` -> installed CLI -> sibling `lemana-vpn-import-yandex.py` via OC Python -> existing Keychain + atomic env update -> CLI status/launch -> Swift status. `uninstall.sh` removes the helper. DNS/reconnect/runtime SSO patches and unrelated dirty work are excluded. +**Plan Review Gate:** Requires PRE review before execution. + +## Input and lifecycle contract + +- Verified plaintext format: JSON array with `name`, `secret`, `techInfo` (OTP URI); primary implementations: [GNOME Authenticator](https://world.pages.gitlab.gnome.org/Authenticator/src/authenticator/backup/yandex.rs.html), [Yandex export help](https://yandex.ru/support/id/ru/authorization/twofa-copy). +- Accept only `otpauth://totp`, SHA1, 6 digits, 30 seconds, valid Base32. Reject duplicate critical parameters, secret mismatch, HOTP/YAOTP and unsupported parameters. Never fetch metadata URLs. Mixed backups may contain unsupported records; validate the selected entry. +- Bound file reading to 4 MiB and entries to 1000; sanitize/cap display names. Require a real terminal at production entry, use hidden input for current phone code, allow EOF/interrupt to cancel without repeated prompts. +- Require saved LDAP username/password first (`vpn --configure-login`). Show destination account and the visible/no-submit behavior. Verify current/adjacent 30-second code locally, then write selected secret and enable manual-keychain/local OTP. Prepare the mode-0600 env replacement before changing Keychain; roll back the old secret if activation fails. Preserve unrelated env settings, no backup copy retained or removed automatically. +- `LEMANA_VPN_LOCAL_OTP` is 0 by default, only 0/1 accepted; invocation environment overrides env file. Reset to 0 in env to return to manual codes while retaining the saved seed. Bitwarden ignores this flag; full-manual ignores it for launch/preflight. +- Status must distinguish local OTP readiness and missing-seed guidance without reading/displaying secrets. Existing older CLI/Swift decoding remains compatible. + +## Ordered tasks + +1. Root implements Python helper and focused tests (`bin/lemana-vpn-import-yandex.py`, `tests/yandex_import.py`). Read-only explorer/review agents validate format/security. Test synthetic success, wrong code/selection, malformed/unsupported input, EOF, secret-safe output, storage/config failures and rollback. +2. Root integrates CLI flag/config, local preflight, status and launch (`bin/vpn-lemanapro.sh`) plus manual readiness decoding (`VPNManager.swift`). Preserve old command/event shapes where unchanged. Add fake-launch/status/no-TTY tests to `tests/smoke.sh` and focused wizard tests. +3. Root integrates installer question/flag and helper packaging/removal (`install.sh`, `uninstall.sh`), documents workflow and limits (`README.md`). Existing unowned-env preservation retains local OTP across updates; add evidence to `tests/configuration.py`. No extra GUI controls. +4. Read-only correctness/maintainability/POST plan review, fix blockers, then final syntax, wizard/config/logging tests, smoke and Swift release build. Main agent owns edits and integration; independent read-only work can run in parallel. + +## Risks + +Wrong OTP type/parameters produces silent login failures; unsupported settings must fail before persistence. Wrong selected account is addressed by explicit selection and phone-code verification. File/Keychain errors must not silently switch modes or replace usable credentials. Real macOS Keychain prompts and live SSO remain unverified without a separate current request. + +## Completed evidence + +- PRE and POST plan/maintainability reviews passed. Security review findings resolved: saved/effective/runtime LDAP binding, strict parameter allowlist, case/whitespace-safe secret masking before label truncation, Keychain readback, and a consistent commit point with interruption handling. +- `tests/yandex_import.py` passes with the existing OC Python. Synthetic RFC TOTP data exercise valid import, mixed exports, unsupported types/parameters, malformed/oversized files, EOF/wrong code, Keychain/config failures, rollback/commit and silent-backend failure. +- Automated PTY drives the actual CLI and sibling helper against a temporary fake Keychain: verified one-account write, unchanged LDAP password, no echoed phone code/secret, persisted setting and ready CLI JSON. Runtime account mismatches and explicit local-OTP override are checked. +- Shell syntax, `tests/smoke.sh`, `tests/configuration.py` (10 checks), `tests/logging.py`, `git diff --check`: PASS. Smoke captures local OTP on, full-manual off, missing seed blocking launch, unchanged Bitwarden flow, installer action/packaging and uninstall removal. +- `swift build -c release --package-path app`: PASS (3.29 s). Restricted run hit compiler cache and nested sandbox restrictions; approved build escalation completed without launching the app. +- No real export, real Keychain writes, real installation, GUI or live VPN acceptance was performed. Abrupt process death/power loss across separate Keychain/filesystem stores remains a documented limitation. diff --git a/install.sh b/install.sh index abb80b6..7e8e8a6 100755 --- a/install.sh +++ b/install.sh @@ -9,25 +9,82 @@ INSTALL_BIN_DIR="${LEMANA_VPN_BIN_DIR:-$HOME/bin}" CONFIG_DIR="${LEMANA_VPN_CONFIG_DIR:-$HOME/.config/lemana-vpn}" OC_CONFIG_DIR="${OPENCONNECT_LITE_CONFIG_DIR:-$HOME/.config/openconnect-lite}" DNS_CLEANUP="/usr/local/sbin/lemana-vpn-dns-cleanup" +# Read saved scalar settings without executing shell code. Explicit environment wins. +read_saved_settings() { + [ -f "$CONFIG_DIR/env" ] || [ -f "$OC_CONFIG_DIR/config.toml" ] || return 0 + CONFIG_FILE="$CONFIG_DIR/env" OC_CONFIG_FILE="$OC_CONFIG_DIR/config.toml" python3 - <<'PYCONFIG' +import json +import os +import re +import shlex +from pathlib import Path + +keys = ("MODE", "USERNAME", "BW_ITEM", "USE_TOUCHID", "AUTH_PROFILE", "CREDENTIAL_SOURCE", "USE_BITWARDEN", "LOCAL_OTP", "AUTO_SOURCE") +pattern = re.compile(r"(?m)^(?:export[ \t]+)?(LEMANA_VPN_(?:" + "|".join(keys) + r"))=[ \t]*(\"(?:\\[\s\S]|[^\"\\])*\"|'[^']*'|[^\r\n]*)[ \t]*(?:#.*)?$") +values = {} +path = Path(os.environ["CONFIG_FILE"]) +if path.exists(): + for match in pattern.finditer(path.read_text()): + key, raw = match.groups() + if raw.startswith('"'): + value = re.sub(r'\\([$`"\\\n])', lambda m: "" if m[1] == "\n" else m[1], raw[1:-1]) + else: + parts = shlex.split(raw, comments=True) + if len(parts) > 1: + continue + value = parts[0] if parts else "" + values[key] = value + +if not values.get("LEMANA_VPN_USERNAME") and "LEMANA_VPN_USERNAME" not in os.environ: + path = Path(os.environ["OC_CONFIG_FILE"]) + if path.exists(): + section = re.search(r"""(?ms)^[ \t]*\[[ \t]*(?:credentials|"credentials"|'credentials')[ \t]*\][ \t]*(?:#[^\n]*)?(?:\n|\Z)(.*?)(?=^[ \t]*\[|\Z)""", path.read_text()) + match = re.search(r"""(?m)^[ \t]*(?:username|"username"|'username')[ \t]*=[ \t]*("(?:\\.|[^"\\])*"|'[^']*')""", section[1] if section else "") + if match: + raw = match[1] + values["LEMANA_VPN_USERNAME"] = json.loads(raw) if raw.startswith('"') else raw[1:-1] + +# Select auth behavior from one priority layer; saved legacy fields cannot override it. +if "LEMANA_VPN_MODE" not in os.environ and any("LEMANA_VPN_" + key in os.environ for key in ("AUTH_PROFILE", "CREDENTIAL_SOURCE", "USE_BITWARDEN")): + for key in ("MODE", "AUTH_PROFILE", "CREDENTIAL_SOURCE", "USE_BITWARDEN", "LOCAL_OTP"): + values.pop("LEMANA_VPN_" + key, None) +if "LEMANA_VPN_MODE" in os.environ: + values.pop("LEMANA_VPN_LOCAL_OTP", None) +for key, value in values.items(): + if key not in os.environ: + print(key + "=" + shlex.quote(value)) + if key == "LEMANA_VPN_USERNAME": + print("SAVED_USERNAME=" + shlex.quote(value)) +PYCONFIG +} +saved_settings="$(read_saved_settings)" +eval "$saved_settings" USERNAME="${LEMANA_VPN_USERNAME:-}" BW_ITEM="${LEMANA_VPN_BW_ITEM:-LM LDAP}" -CREDENTIAL_SOURCE="${LEMANA_VPN_CREDENTIAL_SOURCE:-none}" +MODE="${LEMANA_VPN_MODE:-}" +LOCAL_OTP="${LEMANA_VPN_LOCAL_OTP:-0}" +AUTO_SOURCE="${LEMANA_VPN_AUTO_SOURCE:-keychain}" +MANUAL_FORCED=0 +CREDENTIAL_SOURCE="${LEMANA_VPN_CREDENTIAL_SOURCE:-}" AUTH_PROFILE="${LEMANA_VPN_AUTH_PROFILE:-manual}" -USE_BITWARDEN=0 -USE_TOUCHID=0 +USE_BITWARDEN="${LEMANA_VPN_USE_BITWARDEN:-0}" +USE_TOUCHID="${LEMANA_VPN_USE_TOUCHID:-0}" INSTALL_SUDOERS=1 INSTALL_ALIASES=1 INSTALL_APP=1 INSTALL_AUTOSTART=1 CONFIGURE_LOGIN=0 CONFIGURE_KEYCHAIN=0 +IMPORT_YANDEX_KEY=0 DRY_RUN=0 FORCE=0 INTERACTIVE=auto BITWARDEN_FORCED=0 +MODE_FORCED=0 CREDENTIAL_SOURCE_FORCED=0 AUTH_PROFILE_FORCED=0 TOUCHID_FORCED=0 +[ "${LEMANA_VPN_USE_TOUCHID+x}" != x ] || TOUCHID_FORCED=1 SUDOERS_FORCED=0 SHELL_FORCED=0 APP_FORCED=0 @@ -81,12 +138,14 @@ Usage: Options: --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 + --mode VALUE Profile: manual-keychain, keychain-auto or bitwarden-auto + --credential-source VALUE Legacy credential source: none, keychain, or bitwarden + --auth-profile VALUE Legacy compatibility profile --with-touchid Install/use keychain-fingerprint Touch ID helper with Bitwarden --without-touchid Do not install/use Touch ID helper --configure-login Prompt for LDAP username/password after install --configure-keychain Prompt for LDAP password and permanent 2FA setup code after install + --import-yandex-key Import a local Yandex Key backup after login setup (Terminal required) --username VALUE Corporate LDAP username (otherwise asked on first Terminal connection) --bw-item VALUE Bitwarden item name (default: LM LDAP) --raw-base-url URL Raw file base URL for curl installs @@ -105,50 +164,107 @@ Options: Examples: sh install.sh + sh install.sh --mode manual-keychain --configure-login + sh install.sh --mode bitwarden-auto sh install.sh --configure-login - sh install.sh --credential-source keychain --auth-profile saved-totp --configure-keychain sh install.sh --without-touchid USAGE } +apply_mode_defaults() { + case "$MODE" in + manual-keychain) + LOCAL_OTP=0 + AUTH_PROFILE="saved-login" + CREDENTIAL_SOURCE="keychain" + USE_BITWARDEN=0 + USE_TOUCHID=0 + ;; + keychain-auto) + AUTH_PROFILE="auto" + CREDENTIAL_SOURCE="keychain" + USE_BITWARDEN=0 + USE_TOUCHID=0 + LOCAL_OTP=1 + AUTO_SOURCE=keychain + ;; + bitwarden-auto) + LOCAL_OTP=0 + AUTO_SOURCE=bitwarden + AUTH_PROFILE="auto" + CREDENTIAL_SOURCE="bitwarden" + USE_BITWARDEN=1 + if [ "$TOUCHID_FORCED" -eq 0 ]; then + USE_TOUCHID=1 + fi + ;; + *) + echo "--mode requires manual-keychain, keychain-auto or bitwarden-auto" >&2 + exit 1 + ;; + esac +} + +normalize_mode_from_legacy() { + if [ -z "$MODE" ]; then + [ -n "$CREDENTIAL_SOURCE" ] || { [ "$USE_BITWARDEN" -ne 1 ] || CREDENTIAL_SOURCE=bitwarden; } + case "$CREDENTIAL_SOURCE:$AUTH_PROFILE" in + bitwarden:*|*:auto) + MODE="bitwarden-auto" + ;; + *) + MODE="manual-keychain" + ;; + esac + fi + if [ "$MODE" = manual-keychain ] && [ "$LOCAL_OTP" = 1 ] && [ "$MANUAL_FORCED" -eq 0 ]; then + MODE=keychain-auto + fi + apply_mode_defaults +} + while [ "$#" -gt 0 ]; do case "$1" in --with-bitwarden) - CREDENTIAL_SOURCE="bitwarden" - USE_BITWARDEN=1 + MODE="bitwarden-auto" + MODE_FORCED=1 + apply_mode_defaults BITWARDEN_FORCED=1 CREDENTIAL_SOURCE_FORCED=1 - if [ "$AUTH_PROFILE_FORCED" -eq 0 ] && [ "$AUTH_PROFILE" = "manual" ]; then - AUTH_PROFILE="saved-totp" - fi ;; --without-bitwarden) - if [ "$CREDENTIAL_SOURCE" = "bitwarden" ]; then - CREDENTIAL_SOURCE="none" - fi + MANUAL_FORCED=1 + MODE="manual-keychain" + MODE_FORCED=1 + apply_mode_defaults USE_BITWARDEN=0 USE_TOUCHID=0 BITWARDEN_FORCED=1 ;; + --mode) + shift + [ "$#" -gt 0 ] || { echo "--mode requires manual-keychain, keychain-auto or bitwarden-auto" >&2; exit 1; } + MODE="$1" + [ "$MODE" != manual-keychain ] || MANUAL_FORCED=1 + MODE_FORCED=1 + apply_mode_defaults + ;; --credential-source) shift [ "$#" -gt 0 ] || { echo "--credential-source requires none, keychain, or bitwarden" >&2; exit 1; } CREDENTIAL_SOURCE="$1" CREDENTIAL_SOURCE_FORCED=1 case "$CREDENTIAL_SOURCE" in - none) - USE_BITWARDEN=0 - USE_TOUCHID=0 - ;; - bitwarden) - USE_BITWARDEN=1 - if [ "$AUTH_PROFILE_FORCED" -eq 0 ] && [ "$AUTH_PROFILE" = "manual" ]; then - AUTH_PROFILE="saved-totp" + none|keychain) + if [ "$MODE_FORCED" -eq 0 ]; then + MODE="manual-keychain" + MANUAL_FORCED=1 fi ;; - keychain) - USE_BITWARDEN=0 - USE_TOUCHID=0 + bitwarden) + if [ "$MODE_FORCED" -eq 0 ]; then + MODE="bitwarden-auto" + fi ;; *) echo "--credential-source requires none, keychain, or bitwarden" >&2 @@ -162,7 +278,17 @@ while [ "$#" -gt 0 ]; do AUTH_PROFILE="$1" AUTH_PROFILE_FORCED=1 case "$AUTH_PROFILE" in - manual|saved-login|saved-totp|auto) ;; + manual|saved-login) + if [ "$MODE_FORCED" -eq 0 ]; then + MODE="manual-keychain" + MANUAL_FORCED=1 + fi + ;; + saved-totp|auto) + if [ "$MODE_FORCED" -eq 0 ]; then + MODE="bitwarden-auto" + fi + ;; *) echo "--auth-profile requires manual, saved-login, saved-totp, or auto" >&2 exit 1 @@ -178,30 +304,28 @@ while [ "$#" -gt 0 ]; do TOUCHID_FORCED=1 ;; --configure-login) + MANUAL_FORCED=1 + if [ "$MODE_FORCED" -eq 0 ]; then + MODE="manual-keychain" + fi CONFIGURE_LOGIN=1 CONFIGURE_LOGIN_FORCED=1 CONFIGURE_KEYCHAIN=0 - 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 + ;; + --import-yandex-key) + IMPORT_YANDEX_KEY=1 + if [ "$MODE_FORCED" -eq 0 ]; then + MODE="keychain-auto" fi + MODE_FORCED=1 ;; --configure-keychain) + if [ "$MODE_FORCED" -eq 0 ]; then + MODE="bitwarden-auto" + fi CONFIGURE_KEYCHAIN=1 CONFIGURE_LOGIN=0 CONFIGURE_KEYCHAIN_FORCED=1 - 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 @@ -247,10 +371,10 @@ while [ "$#" -gt 0 ]; do --interactive) INTERACTIVE=1 ;; --non-interactive) INTERACTIVE=0 ;; --minimal) - CREDENTIAL_SOURCE="none" - AUTH_PROFILE="manual" - USE_BITWARDEN=0 - USE_TOUCHID=0 + MANUAL_FORCED=1 + MODE="manual-keychain" + MODE_FORCED=1 + apply_mode_defaults BITWARDEN_FORCED=1 CREDENTIAL_SOURCE_FORCED=1 AUTH_PROFILE_FORCED=1 @@ -271,6 +395,9 @@ while [ "$#" -gt 0 ]; do shift done +[ -z "$MODE" ] || MODE_FORCED=1 +normalize_mode_from_legacy + log() { printf '%s\n' "$*" } @@ -326,8 +453,13 @@ need_cmd() { command -v "$1" >/dev/null 2>&1 || die "Command not found: $1" } +tty_available() { + { [ -r /dev/tty ] && [ -w /dev/tty ]; } || return 1 + { : <> /dev/tty; } 2>/dev/null +} + has_tty() { - { [ -r /dev/tty ] && [ -w /dev/tty ]; } || [ -t 0 ] + tty_available || [ -t 0 ] } interactive_enabled() { @@ -340,7 +472,7 @@ interactive_enabled() { } prompt_printf() { - if [ -w /dev/tty ]; then + if tty_available; then printf "$@" > /dev/tty else printf "$@" @@ -348,13 +480,19 @@ prompt_printf() { } prompt_read_answer() { - if [ -r /dev/tty ]; then + if tty_available; then IFS= read -r answer < /dev/tty || answer="" else IFS= read -r answer || answer="" fi } +preserve_existing_username() { + if [ -n "${SAVED_USERNAME:-}" ] && [ "$USERNAME" = "$SAVED_USERNAME" ]; then + log_info "Preserving existing LDAP username from previous config" + fi +} + yes_no() { prompt="$1" default_answer="$2" @@ -385,119 +523,60 @@ yes_no() { print_install_intro() { log_step "Lemana VPN установит приложение для подключения к корпоративному VPN" - log_detail "По умолчанию ничего не сохраняется: при подключении откроется окно входа, а логин, пароль и одноразовый код вводятся вручную." - log_detail "Дополнительные удобства можно включить сейчас или позже." -} - -choose_advanced_signin_interactive() { - while :; do - prompt_printf '\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) - 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) - AUTH_PROFILE=saved-totp - CREDENTIAL_SOURCE=bitwarden - USE_BITWARDEN=1 - CONFIGURE_LOGIN=0 - CONFIGURE_KEYCHAIN=0 - ;; - *) - prompt_printf 'Введите 1, 2 или 3. Если не уверены, нажмите Enter.\n' - continue - ;; - esac - break - done + log_detail "Есть два режима: ручной ввод кода и автоматический вход. Для автоматического входа выберите ключ на Mac или Bitwarden." + log_detail "Ключ из Яндекс Ключа можно импортировать отдельно, без Bitwarden." } choose_auth_profile_interactive() { log_step "Выбираю режим подключения" - log_detail "Если не уверены, просто нажмите Enter: будет ручной режим без сохранения паролей." + 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_printf '%sКакой режим использовать?%s\n' "$C_BOLD" "$C_RESET" + prompt_printf ' 1) Ручной вход: запомнить логин и пароль на этом Mac (рекомендуется)\n' + prompt_printf ' Логин и пароль подставятся из macOS Keychain. Одноразовый код вводится руками каждый раз.\n\n' + prompt_printf ' 2) Автоматический вход\n' + prompt_printf ' Пароль и код подставляются автоматически, форма отправляется без ручного ввода.\n' + prompt_printf '%sВыбор [1/2, Enter=1]:%s ' "$C_BOLD" "$C_RESET" prompt_read_answer case "$answer" in ""|1) - 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 + MANUAL_FORCED=1 + MODE=manual-keychain + apply_mode_defaults CONFIGURE_LOGIN=1 CONFIGURE_KEYCHAIN=0 ;; - 3) - choose_advanced_signin_interactive + 2) + prompt_printf 'Источник данных: 1) Ключ на этом Mac, 2) Bitwarden [1/2, Enter=1]: ' + prompt_read_answer + case "$answer" in + ""|1) MODE=keychain-auto; CONFIGURE_LOGIN=1 ;; + 2) MODE=bitwarden-auto; CONFIGURE_LOGIN=0 ;; + *) prompt_printf 'Введите 1 или 2.\n'; continue ;; + esac + apply_mode_defaults + CONFIGURE_KEYCHAIN=0 ;; *) - prompt_printf 'Введите 1, 2 или 3. Если не уверены, нажмите Enter.\n' + prompt_printf 'Введите 1 или 2. Если не уверены, нажмите Enter.\n' continue ;; esac break done - case "$AUTH_PROFILE" in - manual) - log_info "Режим подключения: ручной ввод логина, пароля и одноразового кода" + case "$MODE" in + manual-keychain) + log_info "Режим подключения: логин и пароль в macOS Keychain, одноразовый код вручную" ;; - saved-login) - log_info "Режим подключения: логин и пароль будут сохранены в macOS Keychain, одноразовый код вводится вручную" + keychain-auto) + log_info "Режим подключения: автоматический, ключ на этом Mac" ;; - saved-totp) - log_info "Режим подключения: сохранённые данные будут подставляться в форму входа" - ;; - auto) - log_info "Режим подключения: автоматический вход включается только через явный auto-запуск" + bitwarden-auto) + log_info "Режим подключения: автоматический, Bitwarden" ;; esac } @@ -579,26 +658,25 @@ choose_modules() { log_info "Interactive prompts: on" log_detail "Установщик проведёт по понятному выбору режима входа; флаги командной строки имеют приоритет." - if [ "$AUTH_PROFILE_FORCED" -eq 0 ] && [ "$CREDENTIAL_SOURCE_FORCED" -eq 0 ]; then + if [ "$MODE_FORCED" -eq 0 ] && [ "$AUTH_PROFILE_FORCED" -eq 0 ] && [ "$CREDENTIAL_SOURCE_FORCED" -eq 0 ]; then choose_auth_profile_interactive fi if [ "$CREDENTIAL_SOURCE" = "bitwarden" ] && [ "$BITWARDEN_FORCED" -eq 0 ] && ! command -v bw >/dev/null 2>&1; then if yes_no "Bitwarden не найден на этом Mac. Установить его сейчас?" n; then - CREDENTIAL_SOURCE=bitwarden - USE_BITWARDEN=1 + MODE=bitwarden-auto + apply_mode_defaults else - AUTH_PROFILE=manual - CREDENTIAL_SOURCE=none - USE_BITWARDEN=0 - USE_TOUCHID=0 + MODE=manual-keychain + apply_mode_defaults + CONFIGURE_LOGIN=1 fi fi if [ "$TOUCHID_FORCED" -eq 0 ]; then if [ "$USE_BITWARDEN" -eq 1 ]; then if ! [ -x "$INSTALL_BIN_DIR/keychain-fingerprint" ]; then - if yes_no "Разрешить Touch ID для открытия Bitwarden?" n; then + if yes_no "Разрешить Touch ID для открытия Bitwarden?" y; then USE_TOUCHID=1 else USE_TOUCHID=0 @@ -644,7 +722,7 @@ choose_modules() { fi fi - if [ "$CONFIGURE_LOGIN_FORCED" -eq 0 ] && [ "$AUTH_PROFILE" = "saved-login" ] && [ "$CONFIGURE_LOGIN" -eq 0 ]; then + if [ "$CONFIGURE_LOGIN_FORCED" -eq 0 ] && [ "$MODE" != "bitwarden-auto" ] && [ "$CONFIGURE_LOGIN" -eq 0 ]; then if ! keychain_has openconnect-lite "$USERNAME"; then if yes_no "Запомнить логин и пароль в macOS Keychain после установки?" y; then CONFIGURE_LOGIN=1 @@ -652,13 +730,20 @@ choose_modules() { fi fi - if [ "$CONFIGURE_KEYCHAIN_FORCED" -eq 0 ] && [ "$AUTH_PROFILE" = "saved-totp" ] && [ "$CREDENTIAL_SOURCE" = "keychain" ] && [ "$CONFIGURE_KEYCHAIN" -eq 0 ]; then + if [ "$CONFIGURE_KEYCHAIN_FORCED" -eq 0 ] && [ "$MODE" = "bitwarden-auto" ] && [ "$CREDENTIAL_SOURCE" = "keychain" ] && [ "$CONFIGURE_KEYCHAIN" -eq 0 ]; then if ! keychain_has openconnect-lite "$USERNAME" || ! keychain_has openconnect-lite "totp/$USERNAME"; then if yes_no "Сохранить пароль и постоянный код настройки 2FA в macOS Keychain после установки?" y; then CONFIGURE_KEYCHAIN=1 fi fi fi + if [ "$MODE" != "bitwarden-auto" ] && [ "$IMPORT_YANDEX_KEY" -eq 0 ] && ! keychain_has openconnect-lite "totp/$USERNAME"; then + import_default=n + [ "$MODE" != keychain-auto ] || import_default=y + if yes_no "Импортировать код из Яндекс Ключа и включить автоматический вход?" "$import_default"; then + IMPORT_YANDEX_KEY=1 + fi + fi } script_dir() { @@ -703,6 +788,37 @@ write_file() { printf '%s\n' "$content" > "$dst" } +render_template_file() { + src="$1" + dst="$2" + if [ "$DRY_RUN" -eq 1 ]; then + printf '+ render %s -> %s\n' "$src" "$dst" + return 0 + fi + USERNAME="$USERNAME" BW_ITEM="$BW_ITEM" DNS_CLEANUP="$DNS_CLEANUP" USE_TOUCHID="$USE_TOUCHID" AUTO_SOURCE="$AUTO_SOURCE" \ + CONFIG_FILE="$CONFIG_DIR/env" python3 - "$src" "$dst" <<'PYCONFIG' +import os +import re +import sys +from pathlib import Path + + +def shell_value(value): + return value.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$").replace("`", "\\`") + + +rendered = Path(sys.argv[1]).read_text() +rendered = re.sub(r"\{\{(USERNAME|BW_ITEM|DNS_CLEANUP|USE_TOUCHID|AUTO_SOURCE)\}\}", lambda m: shell_value(os.environ[m[1]]), rendered) +keys = re.findall(r"(?m)^(LEMANA_VPN_\w+)=", rendered) +existing = Path(os.environ["CONFIG_FILE"]) +kept = "" +if existing.exists(): + pattern = r"(?m)^(?:export[ \t]+)?(?:" + "|".join(keys) + r")=[ \t]*(?:\"(?:\\[\s\S]|[^\"\\])*\"|'[^']*'|[^\r\n]*)[ \t]*\n?" + kept = re.sub(pattern, "", existing.read_text()).rstrip() +Path(sys.argv[2]).write_text((kept + "\n" if kept else "") + rendered) +PYCONFIG +} + install_homebrew_packages() { need_cmd brew @@ -761,6 +877,9 @@ install_cli() { run install -m 755 "$tmp/vpn-lemanapro.sh" "$INSTALL_BIN_DIR/vpn-lemanapro.sh" log_ok "CLI installed: $INSTALL_BIN_DIR/vpn-lemanapro.sh" + download_file "bin/lemana-vpn-import-yandex.py" "$tmp/lemana-vpn-import-yandex.py" + run install -m 644 "$tmp/lemana-vpn-import-yandex.py" "$INSTALL_BIN_DIR/lemana-vpn-import-yandex.py" + download_file "uninstall.sh" "$tmp/uninstall.sh" run install -m 755 "$tmp/uninstall.sh" "$INSTALL_BIN_DIR/uninstall-lemana-vpn.sh" log_ok "Uninstall helper installed: $INSTALL_BIN_DIR/uninstall-lemana-vpn.sh" @@ -768,6 +887,7 @@ install_cli() { install_config() { tmp="$1" + mode_template="$tmp/$MODE.env" log_step "Записываю конфигурацию" log_detail "Здесь сохраняются выбранный режим входа, LDAP username если он задан, и профиль openconnect-lite для Keycloak SSO." @@ -777,18 +897,35 @@ install_config() { if [ "$DRY_RUN" -eq 1 ]; then printf '+ render %s/config.toml\n' "$OC_CONFIG_DIR" else - sed "s/{{USERNAME}}/$USERNAME/g" "$tmp/openconnect-lite-config.toml" > "$OC_CONFIG_DIR/config.toml" - chmod 600 "$OC_CONFIG_DIR/config.toml" + USERNAME="$USERNAME" python3 - "$OC_CONFIG_DIR/config.toml" "$tmp/openconnect-lite-config.toml" "$tmp/config.toml" <<'PYCONFIG' +import json +import os +import re +import sys +from pathlib import Path + +existing, template, destination = map(Path, sys.argv[1:]) +text = (existing if existing.exists() else template).read_text() +username = "username = " + json.dumps(os.environ["USERNAME"], ensure_ascii=False) +section = re.search(r"""(?ms)^[ \t]*\[[ \t]*(?:credentials|"credentials"|'credentials')[ \t]*\][ \t]*(?:#[^\n]*)?(?:\n|\Z)(.*?)(?=^[ \t]*\[|\Z)""", text) +if section: + body = section[1] + username_key = r"""(?m)^[ \t]*(?:username|"username"|'username')[ \t]*=""" + if re.search(username_key, body): + body = re.sub(username_key + r".*$", lambda _: username, body) + else: + body = username + "\n" + body + prefix = text[:section.start(1)] + text = prefix + ("" if prefix.endswith("\n") else "\n") + body + text[section.end(1):] +else: + text = text.rstrip() + "\n\n[credentials]\n" + username + "\n" +destination.write_text(text) +PYCONFIG fi - 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\" -LEMANA_VPN_USE_TOUCHID=\"$USE_TOUCHID\" -LEMANA_VPN_DNS_CLEANUP=\"$DNS_CLEANUP\"" - write_file "$tmp/env" "$env_content" + download_file "templates/$MODE.env" "$mode_template" + render_template_file "$mode_template" "$tmp/env" + run install -m 600 "$tmp/config.toml" "$OC_CONFIG_DIR/config.toml" run install -m 600 "$tmp/env" "$CONFIG_DIR/env" log_ok "Config installed: $CONFIG_DIR/env" } @@ -798,7 +935,7 @@ install_dns_cleanup() { dns_cleanup_dir="$(dirname "$DNS_CLEANUP")" log_step "Устанавливаю DNS cleanup wrapper" - log_detail "Wrapper сбрасывает только похожие на корпоративные 10.x DNS после аварийного завершения VPN." + log_detail "Wrapper сохраняет прежние DNS и восстанавливает изменения VPN после отключения или перезагрузки." log_detail "macOS может запросить sudo-пароль для записи в $dns_cleanup_dir." download_file "libexec/lemana-vpn-dns-cleanup" "$tmp/lemana-vpn-dns-cleanup" @@ -814,6 +951,11 @@ install_sudoers() { return 0 fi + if [ -f /etc/sudoers.d/lemana-vpn-openconnect ] && [ -f /etc/sudoers.d/lemana-vpn-dns ] && [ "$FORCE" -eq 0 ]; then + log_ok "Sudoers rules already installed" + return 0 + fi + log_step "Настраиваю sudoers" log_detail "Это убирает повторный sudo-пароль при подключении, но разрешает только openconnect и DNS cleanup wrapper." @@ -881,6 +1023,9 @@ install_menu_bar_app() { download_file "app/Package.swift" "$app_src/Package.swift" download_file "app/Sources/LemanaVPN/LemanaVPNApp.swift" "$app_src/Sources/LemanaVPN/LemanaVPNApp.swift" download_file "app/Sources/LemanaVPN/VPNManager.swift" "$app_src/Sources/LemanaVPN/VPNManager.swift" + download_file "app/Sources/LemanaVPN/BoundedLog.swift" "$app_src/Sources/LemanaVPN/BoundedLog.swift" + download_file "app/Sources/LemanaVPN/SessionTiming.swift" "$app_src/Sources/LemanaVPN/SessionTiming.swift" + download_file "app/Sources/LemanaVPN/VPNIcons.swift" "$app_src/Sources/LemanaVPN/VPNIcons.swift" log_info "Building LemanaVPN.app" run swift build -c release --package-path "$app_src" @@ -994,7 +1139,8 @@ install_shell_aliases() { zshrc="$HOME/.zshrc" tmp="$1" log_step "Обновляю shell aliases" - log_detail "Алиасы vpn, vpn-auto, vpn-manual, vpn-manual-full, vpn-debug и vpn-fix-dns добавляются идемпотентным блоком в ~/.zshrc." + log_detail "Основные алиасы vpn, vpn-auto, vpn-debug и vpn-fix-dns добавляются идемпотентным блоком в ~/.zshrc." + log_detail "vpn-manual и vpn-manual-full остаются только как совместимые fallback-команды." [ -f "$zshrc" ] || run touch "$zshrc" @@ -1011,7 +1157,7 @@ vpn-auto() { "$INSTALL_BIN_DIR/vpn-lemanapro.sh" --auto "\$@"; } vpn-manual() { "$INSTALL_BIN_DIR/vpn-lemanapro.sh" --manual "\$@"; } vpn-manual-full() { "$INSTALL_BIN_DIR/vpn-lemanapro.sh" --manual-full "\$@"; } vpn-debug() { "$INSTALL_BIN_DIR/vpn-lemanapro.sh" --debug "\$@"; } -vpn-fix-dns() { sudo "$DNS_CLEANUP"; } +vpn-fix-dns() { "$INSTALL_BIN_DIR/vpn-lemanapro.sh" --repair-dns; } # <<< lemana-vpn EOF @@ -1036,6 +1182,10 @@ maybe_login_bitwarden() { log_skip "Bitwarden не выбран; пропускаю Bitwarden login." return 0 fi + if [ "$DRY_RUN" -eq 1 ]; then + printf '+ bw status # check Bitwarden login state\n' + return 0 + fi if ! command -v bw >/dev/null 2>&1; then log_warn "Bitwarden CLI enabled but bw not found." return 0 @@ -1055,12 +1205,11 @@ maybe_login_bitwarden() { } auth_profile_label() { - case "$AUTH_PROFILE" in - manual) printf 'ручной ввод без сохранения паролей' ;; - saved-login) printf 'сохранить логин и пароль, одноразовый код вводить вручную' ;; - saved-totp) printf 'подставлять сохранённые данные в окно входа' ;; - auto) printf 'автоматический вход только при явном запуске' ;; - *) printf '%s' "$AUTH_PROFILE" ;; + case "$MODE" in + manual-keychain) printf 'ручной вход: логин и пароль в Keychain, одноразовый код вручную' ;; + keychain-auto) printf 'автоматический вход: ключ на этом Mac' ;; + bitwarden-auto) printf 'автоматический вход: Bitwarden' ;; + *) printf '%s' "$MODE" ;; esac } @@ -1068,23 +1217,12 @@ 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 + if [ "$IMPORT_YANDEX_KEY" -eq 1 ] || [ "$MODE" != manual-keychain ]; then + log_info "Как подключиться: автоматический вход командой vpn или кнопкой \"Подключить\"." + else + log_info "Как подключиться: Lemana VPN подставит логин и пароль, одноразовый код введите вручную." + fi + log_detail "Способ входа можно изменить в меню приложения." log_info "Status: vpn --status" if [ "$INSTALL_APP" -eq 1 ]; then log_info "App: open '$APP_DIR'" @@ -1097,7 +1235,14 @@ main() { tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT INT TERM + preserve_existing_username choose_modules + if [ "$IMPORT_YANDEX_KEY" -eq 1 ]; then + [ "$MODE" != "bitwarden-auto" ] || die "Импорт Яндекс Ключа используется с локальным ключом, без Bitwarden." + if [ -z "$USERNAME" ] || ! keychain_has openconnect-lite "$USERNAME"; then + CONFIGURE_LOGIN=1 + fi + fi log_step "Начинаю установку Lemana VPN" log_detail "Повторный запуск безопасен: файлы обновляются идемпотентно, существующие credentials не перезаписываются." @@ -1105,7 +1250,7 @@ main() { 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" + log_info "Modules: mode=$MODE bitwarden=$USE_BITWARDEN touchid=$USE_TOUCHID sudoers=$INSTALL_SUDOERS shell=$INSTALL_ALIASES app=$INSTALL_APP autostart=$INSTALL_AUTOSTART" fi install_homebrew_packages @@ -1123,7 +1268,7 @@ main() { if [ "$CONFIGURE_LOGIN" -eq 1 ]; then log_step "Записываю логин и пароль в macOS Keychain" - log_detail "Будет запрошен корпоративный LDAP-пароль. Одноразовый код при подключении останется ручным." + log_detail "Будет запрошен корпоративный LDAP-пароль; настройка одноразового кода выполняется отдельно." run "$INSTALL_BIN_DIR/vpn-lemanapro.sh" --configure-login elif [ "$CONFIGURE_KEYCHAIN" -eq 1 ]; then log_step "Записываю credentials в macOS Keychain" @@ -1131,6 +1276,12 @@ main() { run "$INSTALL_BIN_DIR/vpn-lemanapro.sh" --configure-keychain fi + if [ "$IMPORT_YANDEX_KEY" -eq 1 ]; then + log_step "Импортирую код из Яндекс Ключа" + log_detail "Выберите локальный файл и запись VPN, затем подтвердите её текущим кодом с телефона." + run "$INSTALL_BIN_DIR/vpn-lemanapro.sh" --import-yandex-key + fi + print_final_next_steps } diff --git a/libexec/lemana-vpn-dns-cleanup b/libexec/lemana-vpn-dns-cleanup index fe1a7ff..d564e2f 100755 --- a/libexec/lemana-vpn-dns-cleanup +++ b/libexec/lemana-vpn-dns-cleanup @@ -1,23 +1,383 @@ #!/bin/sh -set -eu +# LEMANA_DNS_PROTOCOL=2 +# LEMANA_SESSION_PROTOCOL=1 +# LEMANA_RECONNECT_PROTOCOL=1 +set -efu +PATH=/usr/bin:/bin:/usr/sbin:/sbin +LC_ALL=C +export PATH LC_ALL +umask 077 -SERVICES="${LEMANA_VPN_DNS_SERVICES:-Wi-Fi -USB 10/100/1000 LAN -Ethernet}" +fail() { printf '%s\n' "$1" >&2; exit 1; } +case "${1:---recover}:$#" in --recover:0|--recover:1|--vpnc:1|--vpnc:2|--vpnc:3|--begin:3|--stop:2|--restart:3|--recover-session:2) ;; *) fail 'Unsupported helper arguments.' ;; esac +[ "$(/usr/bin/id -u)" = 0 ] || fail 'DNS helper requires root.' +STATE=/var/db/lemana-vpn-dns +[ ! -L "$STATE" ] || fail 'Unsafe DNS state directory.' +/bin/mkdir -p "$STATE" +[ "$(/usr/bin/stat -f '%u:%Lp' "$STATE")" = '0:700' ] || fail 'Unsafe DNS state permissions.' +[ ! -L "$STATE/lock" ] || fail 'Unsafe DNS lock.' +# A kernel lock releases after a crash; -k keeps all callers on the same inode. +exec /usr/bin/lockf -k -t 15 "$STATE/lock" /bin/sh -s -- "${1:---recover}" "${2:-}" "${3:-}" <<'LOCKED' +set -efu +PATH=/usr/bin:/bin:/usr/sbin:/sbin +LC_ALL=C +export PATH LC_ALL +umask 077 +STATE=/var/db/lemana-vpn-dns +JOURNAL="$STATE/journal" +NEXT="$STATE/next" +SESSION="$STATE/session" +SESSION_NEXT="$STATE/session-next" +TAB="$(printf '\t')" +mode=$1 +token=$2 +caller=$3 +exclude=' ' +changed=0 +fail() { printf '%s\n' "$1" >&2; exit 1; } +trap '/bin/rm -f "$NEXT" "$SESSION_NEXT"' EXIT -printf '%s\n' "$SERVICES" | while IFS= read -r service; do - [ -n "$service" ] || continue - - dns="$(/usr/sbin/networksetup -getdnsservers "$service" 2>/dev/null || true)" - printf '%s\n' "$dns" | grep -q '^10\.' || continue - - /usr/sbin/networksetup -setdnsservers "$service" empty >/dev/null 2>&1 || { - printf 'Failed to reset DNS for %s\n' "$service" >&2 - exit 1 - } - printf 'DNS reset for %s\n' "$service" -done - -/usr/bin/dscacheutil -flushcache >/dev/null 2>&1 || true -/usr/bin/killall -HUP mDNSResponder >/dev/null 2>&1 || true +# Never evaluate journal content or use it as a command/path. +normalize_dns() { + /usr/bin/awk ' + function valid(s, n,a,i) { + if (s ~ /:/) return s ~ /^[0-9a-fA-F:]+(%[a-zA-Z0-9_.-]+)?$/ + n=split(s,a,"."); if (n!=4) return 0 + for(i=1;i<=4;i++) if(a[i]!~/^[0-9]+$/ || a[i]>255) return 0 + return 1 + } + { for(i=1;i<=NF;i++) { if(!valid($i)) exit 1; out=out (out?" ":"") $i } } + END { if(out!="") print out } + ' +} +read_dns() { + value=$(/usr/sbin/networksetup -getdnsservers "$1" 2>/dev/null) || return 1 + case "$value" in "There aren't any DNS Servers set on "*) printf 'empty\n' ;; *) + [ -n "$value" ] || return 1 + printf '%s\n' "$value" | normalize_dns + esac +} +valid_dns() { + [ "$1" = empty ] || [ "$(printf '%s\n' "$1" | normalize_dns)" = "$1" ] +} +location() { + /usr/sbin/networksetup -getcurrentlocation 2>/dev/null +} +single_line() { + case "$1" in ''|*"$TAB"*|*' +'*) return 1 ;; esac +} +processes() { + table=$(/bin/ps -axww -o pid=,ppid=,lstart=,command= 2>/dev/null) || fail 'Cannot verify running VPN processes.' + printf '%s\n' "$table" | /usr/bin/awk 'NF<8 || $1!~/^[0-9]+$/ || $2!~/^[0-9]+$/ {exit 1} END {if(NR==0) exit 1}' || fail 'Cannot verify running VPN processes.' +} +process_field() { + printf '%s\n' "$table" | /usr/bin/awk -v pid="$1" -v field="$2" '$1==pid {if(field=="start") print $3,$4,$5,$6,$7; else print $2; exit}' +} +active() { + processes + if printf '%s\n' "$table" | /usr/bin/awk -v skip="$exclude" ' + { parent[$1]=$2; command=""; for(i=8;i<=NF;i++) command=command " " $i } + command ~ /(^|[ \/])openconnect(-lite)?([[:space:]]|$)/ {engine[$1]=1} + command ~ /(^|[ \/])vpn-lemanapro[.]sh([[:space:]]|$)/ && command !~ / --(status|help|repair-dns|patch-only|configure-login|configure-keychain|import-yandex-key)([[:space:]]|$)| --(set-auth|stop-session|restart-session)=/ {cli[$1]=1} + END { + for(pid in parent) { + if(index(skip," " pid " ")) continue + # Bash pipeline/progress children retain the parent CLI argv. + if(engine[pid] || (cli[pid] && !cli[parent[pid]])) found=1 + } + exit !found + }'; then return 0 + else + result_code=$? + [ "$result_code" = 1 ] || fail 'Cannot verify running VPN processes.' + return 1 + fi +} +process_uid() { + /bin/ps -p "$1" -o uid= | /usr/bin/awk 'NF==1 && $1~/^[0-9]+$/ {print $1}' +} +valid_token() { + [ "${#1}" = 36 ] || return 1 + case "$1" in *[!a-f0-9-]*) return 1 ;; esac +} +descendant() { + ancestor=$1; depth=0 + while [ "$ancestor" -gt 1 ] && [ "$depth" -lt 64 ]; do + [ "$ancestor" != "$2" ] || return 0 + ancestor=$(process_field "$ancestor" parent) + case "$ancestor" in ''|*[!0-9]*) return 1 ;; esac + depth=$((depth + 1)) + done + return 1 +} +load_session() { + [ ! -L "$SESSION" ] && [ -f "$SESSION" ] || fail 'Missing or unsafe VPN session record.' + [ "$(/usr/bin/stat -f '%u:%Lp' "$SESSION")" = '0:600' ] || fail 'Unsafe VPN session permissions.' + IFS="$TAB" read -r sv sb st su sc ss se ses stopped engine_attempt < "$SESSION" || fail 'Invalid VPN session record.' + case "$sv" in 1) engine_attempt=- ;; 2) [ "$engine_attempt" = - ] || valid_token "$engine_attempt" || fail 'Invalid engine attempt.' ;; *) fail 'Unsupported VPN session record.' ;; esac + valid_token "$st" && single_line "$sb" && single_line "$ss" && single_line "$ses" || fail 'Invalid VPN session identity.' + case "$su:$sc:$se:$stopped" in *[!0-9:]*|:*|*::*|*:) fail 'Invalid VPN session identity.' ;; esac + [ "$sc" -gt 1 ] && [ "$stopped" -le 1 ] || fail 'Invalid VPN session identity.' + /usr/bin/awk -F '\t' '($1==1 && NF!=9) || ($1==2 && NF!=10) {exit 1} END {if(NR!=1) exit 1}' "$SESSION" || fail 'Invalid VPN session record.' +} +save_session() { + [ ! -L "$SESSION_NEXT" ] || fail 'Unsafe VPN session temporary file.' + printf '2\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$sb" "$st" "$su" "$sc" "$ss" "$se" "$ses" "$stopped" "$engine_attempt" > "$SESSION_NEXT" + /bin/mv -f "$SESSION_NEXT" "$SESSION" +} +session_operation() { + valid_token "$token" || fail 'Invalid session token.' + case "${SUDO_UID:-}" in ''|*[!0-9]*) fail 'Missing requesting user.' ;; esac + processes + boot_now=$(/usr/sbin/sysctl -n kern.bootsessionuuid) || fail 'Cannot verify boot identity.' + if [ "$mode" = --begin ]; then + case "$caller" in ''|*[!0-9]*) fail 'Invalid session caller.' ;; esac + [ "$caller" -gt 1 ] && descendant "$$" "$caller" || fail 'Session caller is not an ancestor.' + [ "$(process_uid "$caller")" = "$SUDO_UID" ] || fail 'Session caller belongs to another user.' + if [ -e "$SESSION" ] || [ -L "$SESSION" ]; then + load_session + if [ "$sb" = "$boot_now" ] && { [ "$(process_field "$sc" start)" = "$ss" ] || [ "$(process_field "$se" start)" = "$ses" ]; }; then + fail 'A registered VPN session is still running.' + fi + fi + sb=$boot_now; st=$token; su=$SUDO_UID; sc=$caller; ss=$(process_field "$caller" start); se=0; ses=-; stopped=0; engine_attempt=- + single_line "$ss" || fail 'Cannot verify session start.' + save_session + printf 'ready\n' + return + fi + load_session + [ "$st:$su:$sb" = "$token:$SUDO_UID:$boot_now" ] || fail 'VPN session identity does not match.' + if [ "$mode" = --recover-session ]; then + [ "$(process_field "$sc" start)" = "$ss" ] && descendant "$$" "$sc" || fail 'Recovery caller is not the registered CLI.' + # Only the idle CLI is exempt; every VPN/SSO engine still blocks recovery. + exclude=" $sc " + recover + if [ "$stopped" = 1 ] && [ "$result" != active ]; then result=cancelled; fi + printf '%s\n' "$result" + return + fi + if [ "$mode" = --restart ]; then + valid_token "$caller" || fail 'Invalid expected engine attempt.' + if [ "$stopped" = 1 ]; then printf 'cancelled\n'; return; fi + # A delayed health request must not stop a replacement tunnel. + if [ "$engine_attempt" != "$caller" ]; then printf 'stale\n'; return; fi + fi + # Commit cancellation before signaling: a late pre-init hook must not start a tunnel. + if [ "$mode" = --stop ]; then stopped=1; save_session; fi + if [ "$(process_field "$se" start)" = "$ses" ]; then + [ "$(process_uid "$se")" = 0 ] || fail 'Recorded VPN owner changed.' + processes + if [ "$(process_field "$se" start)" = "$ses" ]; then + printf '%s\n' "$table" | /usr/bin/awk -v pid="$se" '$1==pid {n=split($8,a,"/"); if(a[n]=="openconnect") ok=1} END {exit !ok}' || fail 'Recorded VPN executable changed.' + /bin/kill -TERM "$se" || fail 'Could not stop the recorded VPN process.' + fi + elif [ "$mode" = --stop ] && [ "$(process_field "$sc" start)" = "$ss" ] && [ "$(process_uid "$sc")" = "$su" ]; then + # Before the tunnel exists, terminate only this user's authentication children. + candidates=$(printf '%s\n' "$table" | /usr/bin/awk '{print $1}') + targets='' + for candidate in $candidates; do + if descendant "$candidate" "$sc" && [ "$(process_uid "$candidate")" = "$su" ]; then + targets="$targets$candidate$TAB$(process_field "$candidate" start) +" + fi + done + printf '%s' "$targets" | while IFS="$TAB" read -r candidate started; do + processes + [ "$(process_field "$candidate" start)" = "$started" ] || continue + [ "$(process_uid "$candidate")" = "$su" ] || fail 'Session child owner changed.' + /bin/kill -TERM "$candidate" || fail 'Could not stop the authentication process.' + done + fi + # Do not wait under the DNS lock: the engine's disconnect hook needs it. + printf 'stopping\n' +} +owner() { + case "${VPNPID:-}" in ''|*[!0-9]*) fail 'Missing VPN process identity.' ;; esac + [ "$VPNPID" -gt 1 ] || fail 'Invalid VPN process identity.' + processes + printf '%s\n' "$table" | /usr/bin/awk -v pid="$VPNPID" '$1==pid {n=split($8,a,"/"); if(a[n]=="openconnect") ok=1} END {exit !ok}' || fail 'Cannot verify VPN process owner.' + current_start=$(process_field "$VPNPID" start) + current_boot=$(/usr/sbin/sysctl -n kern.bootsessionuuid 2>/dev/null) || fail 'Cannot verify boot identity.' + single_line "$current_boot" || fail 'Cannot verify boot identity.' + # Verify the supplied owner is really an ancestor of this hook. + ancestor=$$; depth=0; found=0 + while [ "$ancestor" -gt 1 ] && [ "$depth" -lt 64 ]; do + [ "$ancestor" != "$VPNPID" ] || found=1 + ancestor=$(process_field "$ancestor" parent) + case "$ancestor" in ''|*[!0-9]*) fail 'Cannot verify VPN process ancestry.' ;; esac + depth=$((depth + 1)) + done + [ "$found" = 1 ] || fail 'VPN owner is not the hook parent.' + exclude=' '; ancestor=$VPNPID; depth=0 + while [ "$ancestor" -gt 1 ] && [ "$depth" -lt 64 ]; do + exclude="$exclude$ancestor " + ancestor=$(process_field "$ancestor" parent) + case "$ancestor" in ''|*[!0-9]*) fail 'Cannot verify VPN process ancestry.' ;; esac + depth=$((depth + 1)) + done + [ "$ancestor" -le 1 ] || fail 'Cannot verify VPN process ancestry.' +} +load_journal() { + [ ! -L "$JOURNAL" ] && [ -f "$JOURNAL" ] || fail 'Unsafe DNS journal.' + [ "$(/usr/bin/stat -f '%u:%Lp' "$JOURNAL")" = '0:600' ] || fail 'Unsafe DNS journal permissions.' + IFS="$TAB" read -r version boot pid start place phase expected < "$JOURNAL" || fail 'Invalid DNS journal.' + [ "$version" = 2 ] || fail 'Unsupported DNS journal.' + single_line "$boot" && single_line "$start" && single_line "$place" || fail 'Invalid DNS journal identity.' + case "$pid" in ''|*[!0-9]*) fail 'Invalid DNS journal identity.' ;; esac + case "$phase" in connected|disconnecting) ;; *) fail 'Invalid DNS journal phase.' ;; esac + old_ifs=$IFS; IFS=';' + for list in $expected; do valid_dns "$list" && [ "$list" != empty ] || fail 'Invalid DNS journal addresses.'; done + IFS=$old_ifs + [ -n "$expected" ] || fail 'Invalid DNS journal addresses.' + /usr/bin/awk -F '\t' 'NR==1 {if(NF!=7) exit 1; next} NF!=4 || $1!="service" || $2=="" || seen[$2]++ {exit 1} END {if(NR<2) exit 1}' "$JOURNAL" || fail 'Invalid DNS journal records.' + while IFS="$TAB" read -r tag service before wanted; do + [ "$tag" = service ] || continue + valid_dns "$before" && valid_dns "$wanted" || fail 'Invalid DNS journal addresses.' + done < "$JOURNAL" +} +write_header() { + printf '2\t%s\t%s\t%s\t%s\t%s\t%s\n' "$boot" "$pid" "$start" "$place" "$phase" "$expected" +} +commit() { + /bin/mv -f "$NEXT" "$JOURNAL" + /bin/sync +} +expected_dns() { + case ";$expected;" in *";$1;"*) return 0 ;; *) return 1 ;; esac +} +set_dns() { + # The parser only permits address tokens; glob expansion is disabled. + old_ifs=$IFS; IFS=' '; set -- "$1" $2; IFS=$old_ifs + /usr/sbin/networksetup -setdnsservers "$@" >/dev/null 2>&1 || fail 'Could not restore recorded DNS.' + changed=1 +} +recover() { + if active; then result=active; return; fi + if [ ! -e "$JOURNAL" ] && [ ! -L "$JOURNAL" ]; then result=no_snapshot; return; fi + load_journal + current_place=$(location) || fail 'Cannot read network location.' + [ "$current_place" = "$place" ] || fail 'Saved DNS belongs to another network location.' + while IFS="$TAB" read -r tag service before wanted; do + [ "$tag" = service ] || continue + current=$(read_dns "$service") || fail 'Cannot read recorded network service DNS.' + target=$before + [ "$phase" != disconnecting ] || target=$wanted + [ "$current" != "$target" ] || continue + if expected_dns "$current" || { [ "$phase" = disconnecting ] && [ "$current" = empty ]; }; then + if active; then result=active; return; fi + [ "$(location)" = "$place" ] || fail 'Network location changed during DNS recovery.' + set_dns "$service" "$target" + [ "$(read_dns "$service")" = "$target" ] || fail 'DNS restoration could not be verified.' + fi + done < "$JOURNAL" + /bin/rm -f "$JOURNAL" + if [ "$changed" = 1 ]; then + /usr/bin/dscacheutil -flushcache >/dev/null 2>&1 || true + /usr/bin/killall -HUP mDNSResponder >/dev/null 2>&1 || true + result=restored + else result=unchanged + fi +} +capture_services() { + services=$(/usr/sbin/networksetup -listallnetworkservices 2>/dev/null) || fail 'Cannot list network services.' + printf '%s\n' "$services" | /usr/bin/sed '1d' | while IFS= read -r service; do + case "$service" in ''|\**) continue ;; esac + single_line "$service" || fail 'Unsupported network service name.' + if SERVICE_NAME="$service" /usr/bin/awk -F '\t' '$1=="service" && $2==ENVIRON["SERVICE_NAME"] {found=1} END {exit !found}' "$NEXT"; then continue; fi + before=$(read_dns "$service") || fail 'Cannot capture original DNS.' + printf 'service\t%s\t%s\t%s\n' "$service" "$before" "$before" >> "$NEXT" + done +} +stock() { + if [ -x /opt/homebrew/etc/vpnc/vpnc-script ]; then + /bin/sh /opt/homebrew/etc/vpnc/vpnc-script + elif [ -x /usr/local/etc/vpnc/vpnc-script ]; then + /bin/sh /usr/local/etc/vpnc/vpnc-script + else fail 'The installed vpnc-script is missing.' + fi +} +case "$mode" in --begin|--stop|--restart|--recover-session) session_operation; exit 0 ;; esac +if [ "$mode" = --recover ]; then + recover + printf '%s\n' "$result" + exit 0 +fi +owner +if [ -n "$token" ]; then + load_session + [ "$st:$sb" = "$token:$current_boot" ] || fail 'VPN hook belongs to another session.' + if [ "${reason:-}" != disconnect ]; then + [ "$stopped" = 0 ] || fail 'VPN session was cancelled.' + [ "$(process_field "$sc" start)" = "$ss" ] && descendant "$VPNPID" "$sc" || fail 'VPN engine is not a session child.' + [ "$(process_uid "$VPNPID")" = 0 ] || fail 'VPN engine requires root.' + if [ "$se" != 0 ] && [ "$se:$ses" != "$VPNPID:$current_start" ] && [ "$(process_field "$se" start)" = "$ses" ]; then + fail 'Another engine is still running in this session.' + fi + engine_attempt=${caller:--} + [ "$engine_attempt" = - ] || valid_token "$engine_attempt" || fail 'Invalid engine attempt.' + se=$VPNPID; ses=$current_start; save_session + else + [ "$se:$ses" = "$VPNPID:$current_start" ] || fail 'VPN disconnect belongs to another engine.' + fi +fi +case "${reason:-}" in + pre-init|attempt-reconnect) stock; exit $? ;; + connect|reconnect) + incoming=$(printf '%s\n' "${INTERNAL_IP4_DNS:-}" | normalize_dns) || fail 'Invalid VPN DNS addresses.' + if active; then fail 'Another VPN connection is active.'; fi + current_place=$(location) || fail 'Cannot read network location.' + single_line "$current_place" || fail 'Invalid network location.' + if [ -e "$JOURNAL" ] || [ -L "$JOURNAL" ]; then + load_journal + if [ "$boot:$pid:$start" != "$current_boot:$VPNPID:$current_start" ] || [ "$phase" != connected ]; then + recover + [ ! -e "$JOURNAL" ] || fail 'Previous DNS recovery is incomplete.' + else + [ "$place" = "$current_place" ] || fail 'Saved DNS belongs to another network location.' + fi + fi + if [ -z "$incoming" ]; then stock; exit $?; fi + if [ -e "$JOURNAL" ]; then + expected_dns "$incoming" || expected="$expected;$incoming" + write_header > "$NEXT" + /usr/bin/sed '1d' "$JOURNAL" >> "$NEXT" + else + boot=$current_boot; pid=$VPNPID; start=$current_start; place=$current_place; phase=connected; expected=$incoming + write_header > "$NEXT" + fi + capture_services + [ "$(/usr/bin/wc -l < "$NEXT")" -gt 1 ] || fail 'No enabled network services to record.' + commit + load_journal + stock || fail 'VPN network setup failed; DNS journal retained.' + ;; + disconnect) + if [ ! -e "$JOURNAL" ] && [ ! -L "$JOURNAL" ]; then stock; exit $?; fi + load_journal + [ "$boot:$pid:$start" = "$current_boot:$VPNPID:$current_start" ] || fail 'DNS journal belongs to another VPN session.' + [ "$(location)" = "$place" ] || fail 'Saved DNS belongs to another network location.' + if active; then fail 'Another VPN connection is active.'; fi + previous_phase=$phase + phase=disconnecting + write_header > "$NEXT" + while IFS="$TAB" read -r tag service before wanted; do + [ "$tag" = service ] || continue + if [ "$previous_phase" = connected ]; then + current=$(read_dns "$service") || fail 'Cannot read recorded network service DNS.' + wanted=$current + if expected_dns "$current"; then wanted=$before; fi + fi + printf 'service\t%s\t%s\t%s\n' "$service" "$before" "$wanted" >> "$NEXT" + done < "$JOURNAL" + capture_services + commit + stock || fail 'VPN network cleanup failed; DNS journal retained.' + recover + [ "$result" != active ] || fail 'DNS recovery deferred while another VPN is active.' + ;; + *) fail 'Unsupported VPN hook reason.' ;; +esac +LOCKED diff --git a/templates/bitwarden-auto.env b/templates/bitwarden-auto.env new file mode 100644 index 0000000..f273244 --- /dev/null +++ b/templates/bitwarden-auto.env @@ -0,0 +1,10 @@ +LEMANA_VPN_MODE="bitwarden-auto" +LEMANA_VPN_USERNAME="{{USERNAME}}" +LEMANA_VPN_AUTH_PROFILE="auto" +LEMANA_VPN_CREDENTIAL_SOURCE="bitwarden" +LEMANA_VPN_BW_ITEM="{{BW_ITEM}}" +LEMANA_VPN_USE_BITWARDEN="1" +LEMANA_VPN_USE_TOUCHID="{{USE_TOUCHID}}" +LEMANA_VPN_LOCAL_OTP="0" +LEMANA_VPN_AUTO_SOURCE="bitwarden" +LEMANA_VPN_DNS_CLEANUP="{{DNS_CLEANUP}}" diff --git a/templates/keychain-auto.env b/templates/keychain-auto.env new file mode 100644 index 0000000..453a32d --- /dev/null +++ b/templates/keychain-auto.env @@ -0,0 +1,10 @@ +LEMANA_VPN_MODE="keychain-auto" +LEMANA_VPN_USERNAME="{{USERNAME}}" +LEMANA_VPN_AUTH_PROFILE="auto" +LEMANA_VPN_CREDENTIAL_SOURCE="keychain" +LEMANA_VPN_BW_ITEM="{{BW_ITEM}}" +LEMANA_VPN_USE_BITWARDEN="0" +LEMANA_VPN_USE_TOUCHID="0" +LEMANA_VPN_LOCAL_OTP="1" +LEMANA_VPN_AUTO_SOURCE="keychain" +LEMANA_VPN_DNS_CLEANUP="{{DNS_CLEANUP}}" diff --git a/templates/manual-keychain.env b/templates/manual-keychain.env new file mode 100644 index 0000000..b056d4c --- /dev/null +++ b/templates/manual-keychain.env @@ -0,0 +1,10 @@ +LEMANA_VPN_MODE="manual-keychain" +LEMANA_VPN_USERNAME="{{USERNAME}}" +LEMANA_VPN_AUTH_PROFILE="saved-login" +LEMANA_VPN_CREDENTIAL_SOURCE="keychain" +LEMANA_VPN_BW_ITEM="{{BW_ITEM}}" +LEMANA_VPN_USE_BITWARDEN="0" +LEMANA_VPN_USE_TOUCHID="0" +LEMANA_VPN_LOCAL_OTP="0" +LEMANA_VPN_AUTO_SOURCE="{{AUTO_SOURCE}}" +LEMANA_VPN_DNS_CLEANUP="{{DNS_CLEANUP}}" diff --git a/templates/openconnect-lite-config.toml b/templates/openconnect-lite-config.toml index 21f7ebf..59deea4 100644 --- a/templates/openconnect-lite-config.toml +++ b/templates/openconnect-lite-config.toml @@ -1,4 +1,4 @@ -on_disconnect = "sudo /usr/local/sbin/lemana-vpn-dns-cleanup >/dev/null 2>&1 || true" +on_disconnect = "sudo -n /usr/local/sbin/lemana-vpn-dns-cleanup --recover >/dev/null 2>&1 || true" [default_profile] address = "vpn.lemanapro.ru" @@ -29,4 +29,3 @@ fill = "totp" [[auto_fill_rules."https://employee.auth.lemanapro.ru/*"]] selector = "input[type=submit], button[type=submit], #kc-login" action = "click" - diff --git a/tests/auth_retry.py b/tests/auth_retry.py new file mode 100644 index 0000000..4c235a1 --- /dev/null +++ b/tests/auth_retry.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Patch a disposable engine and execute its actual autofill JS without Qt/browser.""" +import ast +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import tomllib +from types import SimpleNamespace + +ROOT = Path(__file__).resolve().parents[1] +SMOKE = (ROOT/'tests/smoke.sh').read_text() +with tempfile.TemporaryDirectory(prefix='lemana-auth-retry-') as temporary: + folder = Path(temporary) + for name in ('webengine', 'authenticator'): + fixture = SMOKE.split('cat > "$fake_'+name+'" <<\'PY\'\n', 1)[1].split('\nPY\n', 1)[0] + (folder/(name+'.py')).write_text(fixture) + env = {key:value for key,value in os.environ.items() if not key.startswith(('LEMANA_VPN_', 'OPENCONNECT_LITE_'))} + env.update(HOME=temporary, LEMANA_VPN_WEBENGINE_PROCESS=str(folder/'webengine.py'), + LEMANA_VPN_AUTHENTICATOR=str(folder/'authenticator.py'), LEMANA_VPN_OC_PYTHON=sys.executable, + LEMANA_VPN_PATCH_BACKUP_DIR=str(folder/'backups')) + command = ['bash', str(ROOT/'bin/vpn-lemanapro.sh'), '--patch-only'] + subprocess.run(command, env=env, check=True, capture_output=True) + patched = (folder/'webengine.py').read_text() + subprocess.run(command, env=env, check=True, capture_output=True) + assert patched == (folder/'webengine.py').read_text(), 'Patch must be idempotent' + compile(patched, '', 'exec') + tree = ast.parse(patched) + selectors = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == 'get_selectors') + template = next(n.args[0] for n in ast.walk(tree) if isinstance(n, ast.Call) + and isinstance(n.func, ast.Attribute) and n.func.attr == 'setSourceCode') + profile = tomllib.loads((ROOT/'templates/openconnect-lite-config.toml').read_text()) + url_pattern, rule_data = next(iter(profile['auto_fill_rules'].items())) + rules = [SimpleNamespace(selector=r['selector'], action=r.get('action'), fill=r.get('fill')) for r in rule_data] + messages, exits, delegated = [], [], [] + class BasePage: + def javaScriptConsoleMessage(self, *args): delegated.append(args) + logger = SimpleNamespace(warning=lambda *args, **kwargs: messages.append(args)) + fake_os = SimpleNamespace(environ={}) + namespace = dict(os=fake_os, json=json, logger=logger, QWebEnginePage=BasePage, + QApplication=SimpleNamespace(instance=lambda: SimpleNamespace(exit=exits.append)), + credentials=SimpleNamespace(username='test-user', password='test-password', totp='123456'), + rules=rules, url_pattern=url_pattern, + js_regex_str=json.dumps('^'+url_pattern.replace('.', '\\.').replace('*', '.*')+'$')) + callback = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == 'LemanaVPNPage') + exec(compile(ast.Module(body=[selectors, callback], type_ignores=[]), '', 'exec'), namespace) + page = namespace['LemanaVPNPage']() + page.javaScriptConsoleMessage(1, 'LEMANA_VPN_AUTH_REJECTED', 0, 'test') + assert exits == [77] and messages == [('LEMANA_VPN_AUTH_REJECTED',)] + fake_os.environ['LEMANA_VPN_AUTOFILL_CLICK'] = '0' + page.javaScriptConsoleMessage(1, 'LEMANA_VPN_AUTH_REJECTED', 0, 'test') + assert exits == [77] and len(delegated) == 1 + scripts = {} + for name, click, attempt in [('auto', '1', 'attempt-one'), ('fresh', '1', 'attempt-two'), ('manual', '0', 'attempt-one')]: + fake_os.environ.update(LEMANA_VPN_AUTOFILL_CLICK=click, LEMANA_VPN_AUTH_ATTEMPT=attempt, + LEMANA_VPN_AUTOFILL_TOTP='0' if name == 'manual' else '1') + scripts[name] = eval(compile(ast.Expression(template), '