From 519c246269da48721dc0f8692d41c0cd8be282e2 Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Tue, 19 May 2026 11:45:00 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D1=8C=20=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D1=89=D0=B8=D0=BA=20Lemana?= =?UTF-8?q?=20VPN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 + README.md | 207 +++++++++++ bin/vpn-lemanapro.sh | 459 +++++++++++++++++++++++++ install.sh | 346 +++++++++++++++++++ libexec/lemana-vpn-dns-cleanup | 23 ++ templates/openconnect-lite-config.toml | 32 ++ 6 files changed, 1071 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 bin/vpn-lemanapro.sh create mode 100755 install.sh create mode 100755 libexec/lemana-vpn-dns-cleanup create mode 100644 templates/openconnect-lite-config.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..725a02f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +*.log +*.tmp + diff --git a/README.md b/README.md new file mode 100644 index 0000000..19d36fe --- /dev/null +++ b/README.md @@ -0,0 +1,207 @@ +# Lemana VPN + +CLI-установка корпоративного VPN `vpn.lemanapro.ru` для macOS. + +Репозиторий собирает в один воспроизводимый пакет то, что раньше было ручной локальной настройкой: + +- `openconnect` как VPN-клиент; +- `openconnect-lite` для SAML SSO через Keycloak; +- опциональный Bitwarden CLI для LDAP-пароля и TOTP; +- опциональный Touch ID helper для мастер-пароля Bitwarden; +- безопасный DNS cleanup через root-owned wrapper; +- алиасы `vpn`, `vpn-debug`, `vpn-fix-dns`. + +## Быстрая установка + +```sh +curl -fsSL http://192.168.50.109/dokril/lemana-vpn/raw/branch/main/install.sh | sh +``` + +После установки открой новый shell или выполни: + +```sh +exec zsh +vpn +``` + +## Варианты установки + +Полная установка, режим по умолчанию: + +```sh +curl -fsSL http://192.168.50.109/dokril/lemana-vpn/raw/branch/main/install.sh | sh +``` + +Без Touch ID, но с Bitwarden: + +```sh +curl -fsSL http://192.168.50.109/dokril/lemana-vpn/raw/branch/main/install.sh | sh -s -- --without-touchid +``` + +Минимальная установка без Bitwarden и Touch ID. Пароль LDAP и TOTP будут один раз записаны в macOS Keychain вручную: + +```sh +curl -fsSL http://192.168.50.109/dokril/lemana-vpn/raw/branch/main/install.sh | sh -s -- --minimal --configure-keychain +``` + +Проверить действия без изменений: + +```sh +curl -fsSL http://192.168.50.109/dokril/lemana-vpn/raw/branch/main/install.sh | sh -s -- --dry-run +``` + +Если 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/keychain-fingerprint` | Опциональный Touch ID helper для мастер-пароля Bitwarden | +| `~/.config/lemana-vpn/env` | Локальная конфигурация модулей | +| `~/.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-debug`, `vpn-fix-dns` | + +## Модули + +### Core + +Всегда устанавливается: + +- `openconnect` через Homebrew; +- `pipx` через Homebrew; +- `openconnect-lite` через `pipx`; +- CLI `vpn-lemanapro.sh`; +- `openconnect-lite` config; +- DNS cleanup wrapper. + +### Bitwarden + +Включён по умолчанию. CLI при каждом запуске `vpn` пытается получить LDAP-пароль и TOTP из записи Bitwarden `LM LDAP`, затем записывает их в macOS Keychain для `openconnect-lite`. + +Отключить: + +```sh +sh install.sh --without-bitwarden +``` + +В этом режиме credentials нужно положить в Keychain вручную: + +```sh +vpn-lemanapro.sh --configure-keychain +``` + +### Touch ID + +Включён по умолчанию. Установщик собирает `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 +``` + +## Использование + +```sh +vpn # подключиться +vpn --status # статус без нового подключения +vpn --status --json # статус в JSON +vpn-debug # видимый браузер и debug-логи +vpn-fix-dns # сбросить корпоративные DNS после аварийного завершения +``` + +Первый запуск с Bitwarden: + +1. CLI проверит `bw`. +2. Если vault locked, попросит мастер-пароль. +3. Если установлен Touch ID helper, предложит сохранить мастер-пароль за Touch ID prompt. +4. Достанет `LM LDAP`, запишет LDAP-пароль и TOTP в Keychain. +5. Запустит `openconnect-lite` и пройдёт Keycloak SSO. + +## Настройка + +Файл `~/.config/lemana-vpn/env`: + +```sh +LEMANA_VPN_USERNAME="60103293" +LEMANA_VPN_BW_ITEM="LM LDAP" +LEMANA_VPN_USE_BITWARDEN="1" +LEMANA_VPN_USE_TOUCHID="1" +LEMANA_VPN_DNS_CLEANUP="/usr/local/sbin/lemana-vpn-dns-cleanup" +``` + +Для другого логина: + +```sh +curl -fsSL http://192.168.50.109/dokril/lemana-vpn/raw/branch/main/install.sh \ + | sh -s -- --username 12345678 +``` + +## Bitwarden item + +Нужна запись: + +- название: `LM LDAP`; +- username: корпоративный LDAP логин; +- password: LDAP пароль; +- TOTP: `otpauth://...secret=BASE32...` или raw BASE32 secret. + +## Почему DNS wrapper, а не wildcard sudoers + +Старый вариант давал `NOPASSWD` на `networksetup -setdnsservers *`. Это слишком широкое право: любой локальный процесс пользователя мог поменять DNS на произвольный сервер. + +Новый вариант разрешает sudo только на `/usr/local/sbin/lemana-vpn-dns-cleanup`. Wrapper сбрасывает DNS только если текущий DNS начинается с `10.`, то есть похож на корпоративный VPN DNS. + +## Диагностика + +Проверить установку: + +```sh +command -v vpn-lemanapro.sh +openconnect --version +~/.local/bin/openconnect-lite --help +sudo -n /usr/local/sbin/lemana-vpn-dns-cleanup +vpn --status +``` + +Если SSO ломается после обновления `openconnect-lite`, запусти: + +```sh +vpn-debug +``` + +CLI перед подключением патчит `openconnect-lite`: + +- `minimal` -> `offscreen`, чтобы Qt WebEngine не падал на macOS; +- добавляет `input` и `change` events для Keycloak auto-fill; +- добавляет URL guard, чтобы auto-fill не кликал submit на Cisco ACS. + +## Удаление + +```sh +rm -f ~/bin/vpn-lemanapro.sh ~/bin/keychain-fingerprint +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 +``` + +Из `~/.zshrc` удалить блок: + +```sh +# >>> lemana-vpn +... +# <<< lemana-vpn +``` + diff --git a/bin/vpn-lemanapro.sh b/bin/vpn-lemanapro.sh new file mode 100755 index 0000000..59a34b2 --- /dev/null +++ b/bin/vpn-lemanapro.sh @@ -0,0 +1,459 @@ +#!/usr/bin/env bash +set -euo pipefail + +CONFIG_DIR="${LEMANA_VPN_CONFIG_DIR:-$HOME/.config/lemana-vpn}" +CONFIG_FILE="$CONFIG_DIR/env" + +if [[ -f "$CONFIG_FILE" ]]; then + # shellcheck disable=SC1090 + source "$CONFIG_FILE" +fi + +OC_VENV="${LEMANA_VPN_OC_VENV:-$HOME/.local/pipx/venvs/openconnect-lite}" +OC_PYTHON="${LEMANA_VPN_OC_PYTHON:-$OC_VENV/bin/python}" +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:-60103293}" +KC_FP="${LEMANA_VPN_KEYCHAIN_FINGERPRINT:-$HOME/bin/keychain-fingerprint}" +USE_BITWARDEN="${LEMANA_VPN_USE_BITWARDEN:-1}" +USE_TOUCHID="${LEMANA_VPN_USE_TOUCHID:-1}" +CACHE_BW_SESSION="${LEMANA_VPN_CACHE_BW_SESSION:-0}" +DNS_CLEANUP="${LEMANA_VPN_DNS_CLEANUP:-/usr/local/sbin/lemana-vpn-dns-cleanup}" +BW_KC_SERVICE="${LEMANA_VPN_BW_KC_SERVICE:-vpn-lemanapro}" +BW_KC_ACCOUNT_SESSION="${LEMANA_VPN_BW_KC_ACCOUNT_SESSION:-bw-session}" +BW_KC_ACCOUNT_MASTER="${LEMANA_VPN_BW_KC_ACCOUNT_MASTER:-bw-master}" +STATUS_DIR="${LEMANA_VPN_STATUS_DIR:-$HOME/.local/state/vpn-lemanapro}" +STATUS_FILE="$STATUS_DIR/status.json" + +DEBUG=false +JSON_MODE=false +STATUS_MODE=false +CONFIGURE_KEYCHAIN_MODE=false + +for arg in "$@"; do + case "$arg" in + --debug) DEBUG=true ;; + --json) JSON_MODE=true ;; + --status) STATUS_MODE=true ;; + --configure-keychain) CONFIGURE_KEYCHAIN_MODE=true ;; + --help|-h) + cat <<'HELP' +Usage: vpn-lemanapro.sh [--debug] [--json] [--status] [--configure-keychain] + + --status Show current VPN status without connecting + --status --json Show current VPN status as JSON + --debug Run visible browser and passthrough debug logs + --json Emit JSON Lines events for UI wrappers + --configure-keychain Prompt for LDAP password/TOTP and save them to Keychain +HELP + exit 0 + ;; + esac +done + +_emit() { + local json="$1" human="$2" + if $JSON_MODE; then + printf '%s\n' "$json" + else + printf '%s\n' "$human" + fi +} + +_write_status() { + mkdir -p "$STATUS_DIR" + printf '%s\n' "$1" > "$STATUS_FILE" +} + +_clear_status() { + _write_status "{\"pid\":$$,\"state\":\"disconnected\",\"updated_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" +} + +_json_get() { + local key="$1" + python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('$key',''))" 2>/dev/null || true +} + +_check_status() { + if [[ ! -f "$STATUS_FILE" ]]; then + if $JSON_MODE; then + printf '%s\n' '{"state":"disconnected","reason":"no status file"}' + else + printf '%s\n' "VPN disconnected (нет status-файла)" + fi + return 0 + fi + + local status_json pid state ip expires 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)" + dns_target="$(printf '%s' "$status_json" | _json_get dns_target)" + + local process_alive=false + if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then + process_alive=true + fi + + if [[ "$state" != "connected" ]] || ! $process_alive; then + if $JSON_MODE; then + printf '{"state":"disconnected","reason":"%s"}\n' "$(! $process_alive && printf 'process dead (pid=%s)' "$pid" || printf '%s' "$state")" + else + printf '%s\n' "VPN disconnected" + fi + return 0 + fi + + local healthy=false + if [[ -n "$dns_target" ]] && ping -c 1 -W 2000 "$dns_target" >/dev/null 2>&1; then + healthy=true + fi + + local remaining_sec=0 hours=0 mins=0 + 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)" + now_ts="$(date "+%s")" + if [[ -n "$exp_ts" ]]; then + remaining_sec=$((exp_ts - now_ts)) + hours=$((remaining_sec / 3600)) + mins=$(((remaining_sec % 3600) / 60)) + fi + fi + + if $JSON_MODE; then + printf '{"state":"connected","ip":"%s","healthy":%s,"remaining_sec":%s,"expires":"%s","pid":%s,"dns_target":"%s"}\n' \ + "$ip" "$healthy" "$remaining_sec" "$expires" "$pid" "$dns_target" + else + printf 'VPN connected - %s (session: %sh %sm, tunnel: %s)\n' \ + "$ip" "$hours" "$mins" "$($healthy && printf healthy || printf unhealthy)" + fi +} + +_find_webengine_process() { + if [[ -n "${LEMANA_VPN_WEBENGINE_PROCESS:-}" ]]; then + printf '%s\n' "$LEMANA_VPN_WEBENGINE_PROCESS" + return 0 + fi + find "$OC_VENV/lib" -path '*/site-packages/openconnect_lite/browser/webengine_process.py' -print -quit 2>/dev/null +} + +_patch_oc() { + local wep + wep="$(_find_webengine_process)" + if [[ -z "$wep" || ! -f "$wep" ]]; then + printf 'webengine_process.py not found. Run: pipx install openconnect-lite\n' >&2 + return 1 + fi + + "$OC_PYTHON" - "$wep" <<'PY' +from pathlib import Path +import sys + +path = Path(sys.argv[1]) +src = path.read_text() +original = src +messages = [] + +src = src.replace('argv += ["-platform", "minimal"]', 'argv += ["-platform", "offscreen"]') +if src != original: + messages.append("minimal -> offscreen") + original = src + +old_fill = 'elem.dispatchEvent(new Event("focus")); elem.value = {value}; elem.dispatchEvent(new Event("blur"));' +new_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"));' +if old_fill in src: + src = src.replace(old_fill, new_fill) + messages.append("input/change events") + +if "new RegExp" not in src: + old_block = ''' script.setSourceCode( + f""" +// ==UserScript== +// @include {url_pattern} +// ==/UserScript== + +function autoFill() {{ + {get_selectors(rules, credentials)} + setTimeout(autoFill, 1000); +}} +autoFill(); +""" + )''' + new_block = ''' regex_str = "^" + url_pattern.replace(".", "\\\\.").replace("*", ".*") + "$" + js_regex_str = json.dumps(regex_str) + script.setSourceCode( + f""" +// ==UserScript== +// @include {url_pattern} +// ==/UserScript== + +var _afUrlPattern = new RegExp({js_regex_str}); +var _afRun = 0; +function autoFill() {{ + if (!_afUrlPattern.test(location.href.split('?')[0]) && !_afUrlPattern.test(location.href)) {{ + _afRun++; + return; + }} + _afRun++; + + {get_selectors(rules, credentials)} + setTimeout(autoFill, 1000); +}} +autoFill(); +""" + )''' + if old_block not in src: + print("Cannot apply URL guard patch: unsupported openconnect-lite source", file=sys.stderr) + sys.exit(1) + src = src.replace(old_block, new_block) + messages.append("URL guard") + +if src != path.read_text(): + path.write_text(src) +for message in messages: + print(f"Patch applied: {message}") +PY +} + +_store_keychain() { + local password="$1" totp_secret="$2" + KC_USERNAME="$KC_USERNAME" _VPN_PASS="$password" _VPN_TOTP_SECRET="$totp_secret" "$OC_PYTHON" - <<'PY' +import keyring +import os + +username = os.environ["KC_USERNAME"] +password = os.environ.get("_VPN_PASS", "") +totp_secret = os.environ.get("_VPN_TOTP_SECRET", "") + +if password: + keyring.set_password("openconnect-lite", username, password) +if totp_secret: + keyring.set_password("openconnect-lite", "totp/" + username, totp_secret) +PY +} + +_configure_keychain() { + local password totp_secret + read -rsp "LDAP password: " password + printf '\n' + read -rsp "TOTP secret (BASE32, optional if already stored): " totp_secret + printf '\n' + if [[ -z "$password" ]]; then + printf 'Empty password, nothing was saved.\n' >&2 + return 1 + fi + _store_keychain "$password" "$totp_secret" + printf 'Credentials saved to macOS Keychain for openconnect-lite/%s.\n' "$KC_USERNAME" +} + +_bw_cache_session() { + [[ "$CACHE_BW_SESSION" == "1" ]] || return 0 + local session="$1" + security delete-generic-password -s "$BW_KC_SERVICE" -a "$BW_KC_ACCOUNT_SESSION" >/dev/null 2>&1 || true + security add-generic-password -s "$BW_KC_SERVICE" -a "$BW_KC_ACCOUNT_SESSION" -w "$session" -U >/dev/null 2>&1 || true +} + +_bw_unlock() { + [[ "$USE_BITWARDEN" == "1" ]] || return 1 + + if ! command -v bw >/dev/null 2>&1; then + printf 'Bitwarden CLI is not installed. Using existing Keychain credentials.\n' >&2 + return 1 + fi + + if [[ "$CACHE_BW_SESSION" == "1" ]]; then + local cached_session + cached_session="$(security find-generic-password -s "$BW_KC_SERVICE" -a "$BW_KC_ACCOUNT_SESSION" -w 2>/dev/null || true)" + if [[ -n "$cached_session" ]] && bw status --session "$cached_session" 2>/dev/null | grep -q '"status":"unlocked"'; then + BW_SESSION="$cached_session" + _emit '{"event":"bw_cached"}' "Bitwarden: cached session" + return 0 + fi + fi + + if [[ "$USE_TOUCHID" == "1" && -x "$KC_FP" ]]; then + local master_pw + master_pw="$("$KC_FP" get "$BW_KC_SERVICE" "$BW_KC_ACCOUNT_MASTER" 2>/dev/null || true)" + if [[ -n "$master_pw" ]]; then + _emit '{"event":"bw_touchid"}' "Bitwarden: unlocking via Touch ID..." + BW_SESSION="$(BW_PASSWORD="$master_pw" bw unlock --passwordenv BW_PASSWORD --raw 2>/dev/null || true)" + unset master_pw + if [[ -n "${BW_SESSION:-}" ]]; then + _bw_cache_session "$BW_SESSION" + return 0 + fi + printf 'Touch ID password was rejected. Falling back to manual unlock.\n' >&2 + fi + fi + + local manual_pw + _emit '{"event":"bw_manual"}' "Unlocking Bitwarden vault..." + read -rsp "Bitwarden master password: " manual_pw + printf '\n' + if [[ -z "$manual_pw" ]]; then + printf 'Empty Bitwarden password. Using existing Keychain credentials.\n' >&2 + return 1 + fi + + BW_SESSION="$(BW_PASSWORD="$manual_pw" bw unlock --passwordenv BW_PASSWORD --raw 2>/dev/null || true)" + if [[ -z "${BW_SESSION:-}" ]]; then + unset manual_pw + printf 'Failed to unlock Bitwarden. Using existing Keychain credentials.\n' >&2 + return 1 + fi + _bw_cache_session "$BW_SESSION" + + if [[ "$USE_TOUCHID" == "1" && -x "$KC_FP" ]]; then + local save_choice + read -rp "Save Bitwarden master password behind Touch ID? [Y/n] " save_choice + 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' \ + || printf 'Could not save Bitwarden master password.\n' >&2 + fi + fi + + unset manual_pw + return 0 +} + +_sync_bitwarden() { + if ! _bw_unlock; then + return 0 + fi + + local bw_password bw_item 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_totp_secret="$(printf '%s' "$bw_item" | python3 -c ' +import json +import re +import sys + +try: + data = json.load(sys.stdin) + totp = data.get("login", {}).get("totp", "") or "" + if totp.startswith("otpauth://"): + match = re.search(r"secret=([A-Z2-7]+)", totp, re.IGNORECASE) + print(match.group(1) if match else "") + else: + print(totp.strip()) +except Exception: + print("") +')" + + if [[ -n "$bw_password" ]]; then + _store_keychain "$bw_password" "$bw_totp_secret" + _emit '{"event":"bw_synced"}' "Credentials synced from Bitwarden to Keychain" + else + printf 'Could not fetch Bitwarden item "%s". Using existing Keychain credentials.\n' "$BW_ITEM_NAME" >&2 + fi +} + +_dns_cleanup() { + _emit '{"event":"dns_cleanup"}' "Cleaning up VPN DNS..." + if [[ -x "$DNS_CLEANUP" ]]; then + sudo "$DNS_CLEANUP" || true + return 0 + 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 + fi + done + sudo dscacheutil -flushcache >/dev/null 2>&1 || true + sudo killall -HUP mDNSResponder >/dev/null 2>&1 || true +} + +_filter_output() { + local vpn_ip="" + while IFS= read -r line; do + if $DEBUG; then + printf '%s\n' "$line" + fi + + if [[ "$line" =~ Configured\ as\ ([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+) ]]; then + vpn_ip="${BASH_REMATCH[1]}" + fi + + if [[ "$line" =~ Session\ authentication\ will\ expire\ at\ (.+) ]]; then + 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)) + 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" + 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 +} + +if $STATUS_MODE; then + _check_status + exit 0 +fi + +if $CONFIGURE_KEYCHAIN_MODE; then + _configure_keychain + exit 0 +fi + +trap '_dns_cleanup; _clear_status' EXIT + +_patch_oc +_sync_bitwarden + +_emit '{"event":"connecting"}' "Connecting to VPN (lemanapro)..." +_write_status "{\"pid\":$$,\"state\":\"connecting\",\"updated_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" + +display_mode="hidden" +log_level="" +if $DEBUG; then + display_mode="shown" + log_level="--log-level debug" +fi + +reconnect_count=0 +while true; do + QTWEBENGINE_CHROMIUM_FLAGS="--disable-gpu" \ + "$OC_BIN" --browser-display-mode "$display_mode" $log_level 2>&1 \ + | _filter_output + exit_code=${PIPESTATUS[0]} + + if [[ $exit_code -eq 0 ]]; then + _emit '{"event":"disconnected","reason":"user"}' "VPN disconnected" + break + fi + + 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 + diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..5b651f4 --- /dev/null +++ b/install.sh @@ -0,0 +1,346 @@ +#!/bin/sh +set -eu + +APP_NAME="lemana-vpn" +DEFAULT_RAW_BASE_URL="http://192.168.50.109/dokril/lemana-vpn/raw/branch/main" + +RAW_BASE_URL="${LEMANA_VPN_RAW_BASE_URL:-$DEFAULT_RAW_BASE_URL}" +INSTALL_BIN_DIR="${LEMANA_VPN_BIN_DIR:-$HOME/bin}" +CONFIG_DIR="${LEMANA_VPN_CONFIG_DIR:-$HOME/.config/lemana-vpn}" +OC_CONFIG_DIR="${OPENCONNECT_LITE_CONFIG_DIR:-$HOME/.config/openconnect-lite}" +DNS_CLEANUP="/usr/local/sbin/lemana-vpn-dns-cleanup" +USERNAME="${LEMANA_VPN_USERNAME:-60103293}" +BW_ITEM="${LEMANA_VPN_BW_ITEM:-LM LDAP}" +USE_BITWARDEN=1 +USE_TOUCHID=1 +INSTALL_SUDOERS=1 +INSTALL_ALIASES=1 +CONFIGURE_KEYCHAIN=0 +DRY_RUN=0 +FORCE=0 + +usage() { + cat <<'USAGE' +Usage: + sh install.sh [options] + +Options: + --with-bitwarden Install/use Bitwarden CLI module (default) + --without-bitwarden Do not install/use Bitwarden CLI; use Keychain credentials + --with-touchid Install/use keychain-fingerprint Touch ID helper (default) + --without-touchid Do not install/use Touch ID helper + --configure-keychain Prompt for LDAP password/TOTP after install + --username VALUE Corporate LDAP username (default: 60103293) + --bw-item VALUE Bitwarden item name (default: LM LDAP) + --raw-base-url URL Raw file base URL for curl installs + --no-sudoers Do not install sudoers rules + --no-shell Do not update ~/.zshrc aliases + --minimal Same as --without-bitwarden --without-touchid + --dry-run Print actions without changing files + --force Reinstall files even when present + -h, --help Show this help + +Examples: + sh install.sh + sh install.sh --minimal --configure-keychain + sh install.sh --without-touchid +USAGE +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --with-bitwarden) USE_BITWARDEN=1 ;; + --without-bitwarden) USE_BITWARDEN=0 ;; + --with-touchid) USE_TOUCHID=1 ;; + --without-touchid) USE_TOUCHID=0 ;; + --configure-keychain) CONFIGURE_KEYCHAIN=1 ;; + --username) + shift + [ "$#" -gt 0 ] || { echo "--username requires a value" >&2; exit 1; } + USERNAME="$1" + ;; + --bw-item) + shift + [ "$#" -gt 0 ] || { echo "--bw-item requires a value" >&2; exit 1; } + BW_ITEM="$1" + ;; + --raw-base-url) + shift + [ "$#" -gt 0 ] || { echo "--raw-base-url requires a value" >&2; exit 1; } + RAW_BASE_URL="${1%/}" + ;; + --no-sudoers) INSTALL_SUDOERS=0 ;; + --no-shell) INSTALL_ALIASES=0 ;; + --minimal) + USE_BITWARDEN=0 + USE_TOUCHID=0 + ;; + --dry-run) DRY_RUN=1 ;; + --force) FORCE=1 ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac + shift +done + +log() { + printf '%s\n' "$*" +} + +die() { + printf 'ERROR: %s\n' "$*" >&2 + exit 1 +} + +run() { + if [ "$DRY_RUN" -eq 1 ]; then + printf '+' + for arg in "$@"; do + printf ' %s' "$arg" + done + printf '\n' + return 0 + fi + "$@" +} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || die "Command not found: $1" +} + +script_dir() { + case "$0" in + */*) cd "$(dirname "$0")" 2>/dev/null && pwd ;; + *) pwd ;; + esac +} + +download_file() { + src="$1" + dst="$2" + local_dir="$(script_dir)" + + if [ -f "$local_dir/$src" ]; then + run cp "$local_dir/$src" "$dst" + return 0 + fi + + if [ -f "$PWD/$src" ]; then + run cp "$PWD/$src" "$dst" + return 0 + fi + + need_cmd curl + run curl -fsSL "$RAW_BASE_URL/$src" -o "$dst" +} + +write_file() { + dst="$1" + content="$2" + if [ "$DRY_RUN" -eq 1 ]; then + printf '+ write %s\n' "$dst" + return 0 + fi + printf '%s\n' "$content" > "$dst" +} + +install_homebrew_packages() { + need_cmd brew + + for pkg in openconnect pipx; do + if brew list "$pkg" >/dev/null 2>&1; then + log "Homebrew package already installed: $pkg" + else + log "Installing Homebrew package: $pkg" + run brew install "$pkg" + fi + done + + if [ "$USE_BITWARDEN" -eq 1 ]; then + if brew list bitwarden-cli >/dev/null 2>&1; then + log "Homebrew package already installed: bitwarden-cli" + else + log "Installing Homebrew package: bitwarden-cli" + run brew install bitwarden-cli + fi + fi +} + +install_openconnect_lite() { + need_cmd pipx + + if [ -x "$HOME/.local/bin/openconnect-lite" ] && [ "$FORCE" -eq 0 ]; then + log "openconnect-lite already installed" + else + log "Installing openconnect-lite via pipx" + run pipx install openconnect-lite + fi + + if pipx --help 2>/dev/null | grep -q ' pin '; then + run pipx pin openconnect-lite >/dev/null 2>&1 || true + fi +} + +install_cli() { + tmp="$1" + run mkdir -p "$INSTALL_BIN_DIR" + + download_file "bin/vpn-lemanapro.sh" "$tmp/vpn-lemanapro.sh" + run install -m 755 "$tmp/vpn-lemanapro.sh" "$INSTALL_BIN_DIR/vpn-lemanapro.sh" +} + +install_config() { + tmp="$1" + run mkdir -p "$CONFIG_DIR" "$OC_CONFIG_DIR" + + download_file "templates/openconnect-lite-config.toml" "$tmp/openconnect-lite-config.toml" + if [ "$DRY_RUN" -eq 1 ]; then + printf '+ render %s/config.toml\n' "$OC_CONFIG_DIR" + else + sed "s/{{USERNAME}}/$USERNAME/g" "$tmp/openconnect-lite-config.toml" > "$OC_CONFIG_DIR/config.toml" + chmod 600 "$OC_CONFIG_DIR/config.toml" + fi + + env_content="LEMANA_VPN_USERNAME=\"$USERNAME\" +LEMANA_VPN_BW_ITEM=\"$BW_ITEM\" +LEMANA_VPN_USE_BITWARDEN=\"$USE_BITWARDEN\" +LEMANA_VPN_USE_TOUCHID=\"$USE_TOUCHID\" +LEMANA_VPN_DNS_CLEANUP=\"$DNS_CLEANUP\"" + write_file "$tmp/env" "$env_content" + run install -m 600 "$tmp/env" "$CONFIG_DIR/env" +} + +install_dns_cleanup() { + tmp="$1" + + download_file "libexec/lemana-vpn-dns-cleanup" "$tmp/lemana-vpn-dns-cleanup" + log "Installing DNS cleanup wrapper: $DNS_CLEANUP" + run sudo install -m 755 -o root -g wheel "$tmp/lemana-vpn-dns-cleanup" "$DNS_CLEANUP" +} + +install_sudoers() { + [ "$INSTALL_SUDOERS" -eq 1 ] || return 0 + + openconnect_bin="$(brew --prefix)/bin/openconnect" + [ -x "$openconnect_bin" ] || openconnect_bin="$(command -v openconnect || true)" + [ -n "$openconnect_bin" ] || die "openconnect binary not found" + + tmp="$1" + current_user="$(id -un)" + + write_file "$tmp/sudoers-openconnect" "$current_user ALL=(ALL) NOPASSWD: $openconnect_bin" + run sudo install -m 440 -o root -g wheel "$tmp/sudoers-openconnect" /etc/sudoers.d/lemana-vpn-openconnect + run sudo visudo -c -f /etc/sudoers.d/lemana-vpn-openconnect + + write_file "$tmp/sudoers-dns" "$current_user ALL=(ALL) NOPASSWD: $DNS_CLEANUP" + run sudo install -m 440 -o root -g wheel "$tmp/sudoers-dns" /etc/sudoers.d/lemana-vpn-dns + run sudo visudo -c -f /etc/sudoers.d/lemana-vpn-dns +} + +install_touchid_helper() { + [ "$USE_TOUCHID" -eq 1 ] || return 0 + + if [ -x "$INSTALL_BIN_DIR/keychain-fingerprint" ] && [ "$FORCE" -eq 0 ]; then + log "Touch ID helper already installed: $INSTALL_BIN_DIR/keychain-fingerprint" + return 0 + fi + + need_cmd git + need_cmd swiftc + + tmp="$1" + log "Building keychain-fingerprint helper" + run git clone --depth 1 https://github.com/dss99911/keychain-fingerprint.git "$tmp/keychain-fingerprint" + run swiftc -o "$tmp/keychain-fingerprint-bin" "$tmp/keychain-fingerprint/main.swift" -framework LocalAuthentication -framework Security + run install -m 700 "$tmp/keychain-fingerprint-bin" "$INSTALL_BIN_DIR/keychain-fingerprint" +} + +install_shell_aliases() { + [ "$INSTALL_ALIASES" -eq 1 ] || return 0 + + zshrc="$HOME/.zshrc" + tmp="$1" + [ -f "$zshrc" ] || run touch "$zshrc" + + block="$tmp/zshrc-block" + if [ "$DRY_RUN" -eq 1 ]; then + printf '+ update %s aliases\n' "$zshrc" + return 0 + fi + + cat > "$block" <>> lemana-vpn +vpn() { "$INSTALL_BIN_DIR/vpn-lemanapro.sh" "\$@"; } +vpn-debug() { "$INSTALL_BIN_DIR/vpn-lemanapro.sh" --debug "\$@"; } +vpn-fix-dns() { sudo "$DNS_CLEANUP"; } +# <<< lemana-vpn +EOF + + awk ' + /^# >>> lemana-vpn$/ { skip=1; next } + /^# <<< lemana-vpn$/ { skip=0; next } + skip != 1 { print } + ' "$zshrc" > "$tmp/zshrc" + + { + cat "$tmp/zshrc" + printf '\n' + cat "$block" + } > "$tmp/zshrc.new" + + mv "$tmp/zshrc.new" "$zshrc" +} + +maybe_login_bitwarden() { + [ "$USE_BITWARDEN" -eq 1 ] || return 0 + command -v bw >/dev/null 2>&1 || return 0 + + status="$(bw status 2>/dev/null || true)" + if printf '%s\n' "$status" | grep -q '"status":"unauthenticated"'; then + log "Bitwarden CLI is not logged in. Run later: bw login" + elif printf '%s\n' "$status" | grep -q '"status":"locked"'; then + log "Bitwarden CLI is logged in but locked. First vpn run will ask for master password." + else + log "Bitwarden CLI is available." + fi +} + +main() { + [ "$(uname -s)" = "Darwin" ] || die "This installer supports macOS only" + + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT INT TERM + + log "Installing Lemana VPN" + log "Modules: bitwarden=$USE_BITWARDEN touchid=$USE_TOUCHID sudoers=$INSTALL_SUDOERS shell=$INSTALL_ALIASES" + + install_homebrew_packages + install_openconnect_lite + install_cli "$tmp" + install_config "$tmp" + install_dns_cleanup "$tmp" + install_sudoers "$tmp" + install_touchid_helper "$tmp" + install_shell_aliases "$tmp" + maybe_login_bitwarden + + if [ "$CONFIGURE_KEYCHAIN" -eq 1 ]; then + run "$INSTALL_BIN_DIR/vpn-lemanapro.sh" --configure-keychain + fi + + log "" + log "Done." + log "Open a new shell or run: exec zsh" + log "Connect: vpn" + log "Status: vpn --status" +} + +main "$@" + diff --git a/libexec/lemana-vpn-dns-cleanup b/libexec/lemana-vpn-dns-cleanup new file mode 100755 index 0000000..fe1a7ff --- /dev/null +++ b/libexec/lemana-vpn-dns-cleanup @@ -0,0 +1,23 @@ +#!/bin/sh +set -eu + +SERVICES="${LEMANA_VPN_DNS_SERVICES:-Wi-Fi +USB 10/100/1000 LAN +Ethernet}" + +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 + diff --git a/templates/openconnect-lite-config.toml b/templates/openconnect-lite-config.toml new file mode 100644 index 0000000..21f7ebf --- /dev/null +++ b/templates/openconnect-lite-config.toml @@ -0,0 +1,32 @@ +on_disconnect = "sudo /usr/local/sbin/lemana-vpn-dns-cleanup >/dev/null 2>&1 || true" + +[default_profile] +address = "vpn.lemanapro.ru" +user_group = "" +name = "Default-K" + +[credentials] +username = "{{USERNAME}}" + +[auto_fill_rules] + +[[auto_fill_rules."https://employee.auth.lemanapro.ru/*"]] +selector = "span#input-error" +action = "stop" + +[[auto_fill_rules."https://employee.auth.lemanapro.ru/*"]] +selector = "input#username" +fill = "username" + +[[auto_fill_rules."https://employee.auth.lemanapro.ru/*"]] +selector = "input#password" +fill = "password" + +[[auto_fill_rules."https://employee.auth.lemanapro.ru/*"]] +selector = "input#otp, input[name=otp], form input:not(#username):not(#password):not([type=hidden]):not([type=submit]):not([type=button])" +fill = "totp" + +[[auto_fill_rules."https://employee.auth.lemanapro.ru/*"]] +selector = "input[type=submit], button[type=submit], #kc-login" +action = "click" +