#!/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}" APP_DIR="${LEMANA_VPN_APP_DIR:-$HOME/Applications/LemanaVPN.app}" LAUNCH_AGENT="${LEMANA_VPN_LAUNCH_AGENT:-$HOME/Library/LaunchAgents/ru.dokops.LemanaVPN.plist}" 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" PATCH_BACKUP_DIR="${LEMANA_VPN_PATCH_BACKUP_DIR:-$CONFIG_DIR/patch-backups}" 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 and TOTP secret, then 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 } _find_webengine_process() { if [[ -n "${LEMANA_VPN_WEBENGINE_PROCESS:-}" ]]; then printf '%s\n' "$LEMANA_VPN_WEBENGINE_PROCESS" return 0 fi find "$OC_VENV/lib" -path '*/site-packages/openconnect_lite/browser/webengine_process.py' -print -quit 2>/dev/null } _module_bool() { if "$@" >/dev/null 2>&1; then printf true else printf false fi } _patches_active() { local wep wep="$(_find_webengine_process)" [[ -n "$wep" && -f "$wep" ]] || return 1 grep -q '"offscreen"' "$wep" \ && grep -q 'new Event("input", {{bubbles: true}})' "$wep" \ && grep -q 'new RegExp' "$wep" } _keychain_has() { security find-generic-password -s "$1" -a "$2" >/dev/null 2>&1 } _module_status_json() { local openconnect_installed openconnect_lite_installed bitwarden_installed touchid_installed dns_cleanup_installed local app_installed app_autostart local config_present oc_config_present patch_backup_present patches_active keychain_password keychain_totp_seed openconnect_installed="$(_module_bool command -v openconnect)" openconnect_lite_installed="$(_module_bool test -x "$OC_BIN")" bitwarden_installed="$(_module_bool command -v bw)" touchid_installed="$(_module_bool test -x "$KC_FP")" dns_cleanup_installed="$(_module_bool test -x "$DNS_CLEANUP")" app_installed="$(_module_bool test -x "$APP_DIR/Contents/MacOS/LemanaVPN")" app_autostart="$(_module_bool test -f "$LAUNCH_AGENT")" config_present="$(_module_bool test -f "$CONFIG_FILE")" oc_config_present="$(_module_bool test -f "$HOME/.config/openconnect-lite/config.toml")" patch_backup_present="$(_module_bool test -f "$PATCH_BACKUP_DIR/webengine_process.py.before-lemana-vpn")" patches_active="$(_module_bool _patches_active)" keychain_password="$(_module_bool _keychain_has openconnect-lite "$KC_USERNAME")" keychain_totp_seed="$(_module_bool _keychain_has openconnect-lite "totp/$KC_USERNAME")" printf '{"core":{"openconnect":%s,"openconnect_lite":%s,"config":%s,"openconnect_lite_config":%s},"bitwarden":{"enabled":%s,"installed":%s,"item":"%s"},"touchid":{"enabled":%s,"installed":%s},"keychain":{"password":%s,"totp_seed":%s},"dns_cleanup":{"installed":%s},"patches":{"active":%s,"backup":%s},"app":{"installed":%s,"autostart":%s}}' \ "$openconnect_installed" \ "$openconnect_lite_installed" \ "$config_present" \ "$oc_config_present" \ "$([[ "$USE_BITWARDEN" == "1" ]] && printf true || printf false)" \ "$bitwarden_installed" \ "$BW_ITEM_NAME" \ "$([[ "$USE_TOUCHID" == "1" ]] && printf true || printf false)" \ "$touchid_installed" \ "$keychain_password" \ "$keychain_totp_seed" \ "$dns_cleanup_installed" \ "$patches_active" \ "$patch_backup_present" \ "$app_installed" \ "$app_autostart" } _module_human_part() { local name="$1" enabled="$2" installed="$3" if [[ "$enabled" == "0" ]]; then printf '⏭️ %s=off' "$name" elif [[ "$installed" == "true" ]]; then printf '✅ %s=on' "$name" else printf '⚠️ %s=missing' "$name" fi } _module_status_human() { local core bitwarden_installed touchid_installed dns_cleanup_installed patches_active keychain_password keychain_totp_seed local app_installed app_autostart if command -v openconnect >/dev/null 2>&1 && [[ -x "$OC_BIN" && -f "$HOME/.config/openconnect-lite/config.toml" ]]; then core="core=ok" else core="core=missing" fi bitwarden_installed="$(_module_bool command -v bw)" touchid_installed="$(_module_bool test -x "$KC_FP")" dns_cleanup_installed="$(_module_bool test -x "$DNS_CLEANUP")" patches_active="$(_module_bool _patches_active)" keychain_password="$(_module_bool _keychain_has openconnect-lite "$KC_USERNAME")" keychain_totp_seed="$(_module_bool _keychain_has openconnect-lite "totp/$KC_USERNAME")" app_installed="$(_module_bool test -x "$APP_DIR/Contents/MacOS/LemanaVPN")" app_autostart="$(_module_bool test -f "$LAUNCH_AGENT")" printf 'Modules: %s %s, ' "$([[ "$core" == "core=ok" ]] && printf '✅' || printf '⚠️')" "$core" _module_human_part "bitwarden" "$USE_BITWARDEN" "$bitwarden_installed" printf ', ' _module_human_part "touchid" "$USE_TOUCHID" "$touchid_installed" printf ', %s dns=%s, %s app=%s, %s autostart=%s, %s patches=%s, %s keychain=password:%s/totp_seed:%s\n' \ "$([[ "$dns_cleanup_installed" == "true" ]] && printf '✅' || printf '⚠️')" \ "$([[ "$dns_cleanup_installed" == "true" ]] && printf on || printf missing)" \ "$([[ "$app_installed" == "true" ]] && printf '✅' || printf '⚠️')" \ "$([[ "$app_installed" == "true" ]] && printf on || printf missing)" \ "$([[ "$app_autostart" == "true" ]] && printf '✅' || printf '⏭️')" \ "$([[ "$app_autostart" == "true" ]] && printf on || printf off)" \ "$([[ "$patches_active" == "true" ]] && printf '✅' || printf '⚠️')" \ "$([[ "$patches_active" == "true" ]] && printf active || printf pending)" \ "$([[ "$keychain_password" == "true" && "$keychain_totp_seed" == "true" ]] && printf '✅' || printf '⚠️')" \ "$([[ "$keychain_password" == "true" ]] && printf yes || printf no)" \ "$([[ "$keychain_totp_seed" == "true" ]] && printf yes || printf no)" } _check_status() { local modules_json modules_json="$(_module_status_json)" if [[ ! -f "$STATUS_FILE" ]]; then if $JSON_MODE; then printf '{"state":"disconnected","reason":"no status file","modules":%s}\n' "$modules_json" else _module_status_human 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","modules":%s}\n' "$(! $process_alive && printf 'process dead (pid=%s)' "$pid" || printf '%s' "$state")" "$modules_json" else _module_status_human printf '%s\n' "VPN disconnected" fi return 0 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","modules":%s}\n' \ "$ip" "$healthy" "$remaining_sec" "$expires" "$pid" "$dns_target" "$modules_json" else _module_status_human printf 'VPN connected - %s (session: %sh %sm, tunnel: %s)\n' \ "$ip" "$hours" "$mins" "$($healthy && printf healthy || printf unhealthy)" fi } _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" "$PATCH_BACKUP_DIR" <<'PY' from pathlib import Path import sys path = Path(sys.argv[1]) backup_dir = Path(sys.argv[2]) backup_file = backup_dir / "webengine_process.py.before-lemana-vpn" src = path.read_text() before = src 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 != before: backup_dir.mkdir(parents=True, exist_ok=True) if not backup_file.exists(): backup_file.write_text(before) 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 } _can_prompt() { [[ -t 0 ]] } _configure_keychain() { local password totp_secret local password_present=false totp_present=false _keychain_has openconnect-lite "$KC_USERNAME" && password_present=true _keychain_has openconnect-lite "totp/$KC_USERNAME" && totp_present=true printf 'Manual LDAP credentials setup for Lemana VPN\n' printf 'User: %s\n' "$KC_USERNAME" printf 'LDAP password: your corporate LDAP/domain password, not the Bitwarden master password.\n' printf 'TOTP seed: permanent BASE32 secret from 2FA setup, not the current 6-digit code.\n' printf 'Saved values go to macOS Keychain service openconnect-lite.\n\n' if $password_present; then read -rsp "Corporate LDAP password for $KC_USERNAME [leave empty to keep saved password]: " password else read -rsp "Corporate LDAP password for $KC_USERNAME: " password fi printf '\n' if $totp_present; then read -rsp "TOTP seed BASE32 [leave empty to keep saved seed]: " totp_secret else read -rsp "TOTP seed BASE32 from 2FA setup: " totp_secret fi printf '\n' if [[ -z "$password" && "$password_present" != "true" ]]; then printf 'LDAP password is required because no saved password was found.\n' >&2 return 1 fi if [[ -z "$totp_secret" && "$totp_present" != "true" ]]; then printf 'TOTP seed is required because no saved seed was found. Use the BASE32 setup secret, not the current 6-digit code.\n' >&2 return 1 fi _store_keychain "$password" "$totp_secret" printf 'Credentials are ready in macOS Keychain for openconnect-lite/%s.\n' "$KC_USERNAME" } _ensure_keychain_credentials() { local password_present=false totp_present=false _keychain_has openconnect-lite "$KC_USERNAME" && password_present=true _keychain_has openconnect-lite "totp/$KC_USERNAME" && totp_present=true if [[ "$password_present" == "true" && "$totp_present" == "true" ]]; then if [[ "$USE_BITWARDEN" == "1" ]]; then _emit '{"event":"keychain_ready","source":"keychain"}' "LDAP credentials are ready in macOS Keychain for $KC_USERNAME." else _emit '{"event":"keychain_ready","source":"keychain","bitwarden":false}' "Bitwarden is disabled. Using saved LDAP password and TOTP seed from macOS Keychain for $KC_USERNAME." fi return 0 fi if [[ "$USE_BITWARDEN" == "1" ]]; then _emit '{"event":"keychain_required","bitwarden":true}' "Bitwarden sync did not produce complete Keychain credentials." else _emit '{"event":"keychain_required","bitwarden":false}' "Bitwarden is disabled and saved LDAP credentials are incomplete." fi if ! _can_prompt; then _emit '{"event":"error","message":"LDAP credentials are missing. Run vpn --configure-keychain in Terminal, or reinstall with --configure-keychain."}' \ "LDAP credentials are missing. Run: vpn --configure-keychain" return 1 fi _configure_keychain } _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 if ! _can_prompt; then printf 'Bitwarden vault is locked and no interactive terminal is available. Using existing Keychain credentials.\n' >&2 return 1 fi _emit '{"event":"bw_manual"}' "Bitwarden vault is locked. Enter Bitwarden master password to sync LDAP credentials." read -rsp "Bitwarden master password (not LDAP 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 for next VPN unlock? [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 if ! $JSON_MODE; then _module_status_human else printf '{"event":"modules","modules":%s}\n' "$(_module_status_json)" fi _sync_bitwarden _ensure_keychain_credentials _patch_oc _emit '{"event":"connecting"}' "Connecting to VPN (lemanapro)..." _write_status "{\"pid\":$$,\"state\":\"connecting\",\"updated_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" trap '_dns_cleanup; _clear_status' EXIT 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