Добавь установщик Lemana VPN

This commit is contained in:
2026-05-19 11:45:00 +03:00
commit 519c246269
6 changed files with 1071 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.DS_Store
*.log
*.tmp

207
README.md Normal file
View File

@@ -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
```

459
bin/vpn-lemanapro.sh Executable file
View File

@@ -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

346
install.sh Executable file
View File

@@ -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" <<EOF
# >>> 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 "$@"

23
libexec/lemana-vpn-dns-cleanup Executable file
View File

@@ -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

View File

@@ -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"