#!/usr/bin/env bash set -euo pipefail CONFIG_DIR="${LEMANA_VPN_CONFIG_DIR:-$HOME/.config/lemana-vpn}" CONFIG_FILE="$CONFIG_DIR/env" _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_USE_BITWARDEN="${LEMANA_VPN_USE_BITWARDEN+x}${LEMANA_VPN_USE_BITWARDEN-}" _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-}" if [[ -f "$CONFIG_FILE" ]]; then # shellcheck disable=SC1090 source "$CONFIG_FILE" 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_USE_BITWARDEN:0:1}" == "x" ]] && LEMANA_VPN_USE_BITWARDEN="${_ENV_LEMANA_VPN_USE_BITWARDEN: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}" 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}" CONNECT_LOG_DIR="${LEMANA_VPN_LOG_DIR:-$HOME/Library/Logs}" CONNECT_LOG_FILE="${LEMANA_VPN_CONNECT_LOG:-$CONNECT_LOG_DIR/LemanaVPN-openconnect-lite.log}" CONNECT_WAIT_SECONDS="${LEMANA_VPN_CONNECT_WAIT_SECONDS:-20}" DEBUG=false JSON_MODE=false STATUS_MODE=false CONFIGURE_KEYCHAIN_MODE=false PATCH_ONLY_MODE=false CONNECT_MODE="${LEMANA_VPN_MODE:-auto}" for arg in "$@"; do case "$arg" in --debug) DEBUG=true ;; --json) JSON_MODE=true ;; --status) STATUS_MODE=true ;; --configure-keychain) CONFIGURE_KEYCHAIN_MODE=true ;; --patch-only) PATCH_ONLY_MODE=true ;; --auto|auto) CONNECT_MODE="auto" ;; --manual|manual|--manual-sso) CONNECT_MODE="manual" ;; --help|-h) cat <<'HELP' Usage: vpn-lemanapro.sh [--auto|--manual] [--debug] [--json] [--status] [--configure-keychain] [--patch-only] --status Show current VPN status without connecting --status --json Show current VPN status as JSON --auto Hidden browser, auto-fill and auto-submit (default) --manual Visible browser, auto-fill fields, do not press submit --manual-sso Compatibility alias for --manual --debug Passthrough debug logs; also shows browser in auto mode --json Emit JSON Lines events for UI wrappers --configure-keychain Prompt for LDAP password and TOTP secret, then save them to Keychain --patch-only Apply openconnect-lite runtime patches and exit HELP exit 0 ;; esac done case "$CONNECT_MODE" in auto|manual) ;; *) printf 'Unknown VPN mode: %s. Use --auto or --manual.\n' "$CONNECT_MODE" >&2 exit 2 ;; esac _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)\"}" } _prepare_connection_log() { mkdir -p "$CONNECT_LOG_DIR" { printf '\n==== Lemana VPN openconnect-lite session %s ====\n' "$(date '+%Y-%m-%d %H:%M:%S')" printf 'Command: %s --browser-display-mode hidden\n' "$OC_BIN" } >> "$CONNECT_LOG_FILE" chmod 600 "$CONNECT_LOG_FILE" 2>/dev/null || true } _log_connection_line() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" >> "$CONNECT_LOG_FILE" } _show_connection_log_tail() { [[ -f "$CONNECT_LOG_FILE" ]] || return 0 printf 'Last openconnect-lite log lines (%s):\n' "$CONNECT_LOG_FILE" >&2 tail -n 40 "$CONNECT_LOG_FILE" >&2 || true } _progress_pid="" _start_connect_progress() { ( while sleep "$CONNECT_WAIT_SECONDS"; do _emit '{"event":"waiting","message":"Still waiting for SSO/openconnect-lite"}' \ "Still waiting for SSO/openconnect-lite... log: $CONNECT_LOG_FILE" done ) & _progress_pid="$!" } _stop_connect_progress() { if [[ -n "${_progress_pid:-}" ]]; then kill "$_progress_pid" >/dev/null 2>&1 || true wait "$_progress_pid" 2>/dev/null || true _progress_pid="" fi } _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 } _find_authenticator() { if [[ -n "${LEMANA_VPN_AUTHENTICATOR:-}" ]]; then printf '%s\n' "$LEMANA_VPN_AUTHENTICATOR" return 0 fi find "$OC_VENV/lib" -path '*/site-packages/openconnect_lite/authenticator.py' -print -quit 2>/dev/null } _module_bool() { if "$@" >/dev/null 2>&1; then printf true else printf false fi } _patches_active() { local wep authp wep="$(_find_webengine_process)" authp="$(_find_authenticator)" [[ -n "$wep" && -f "$wep" && -n "$authp" && -f "$authp" ]] || return 1 grep -q '"offscreen"' "$wep" \ && grep -q 'new Event("input", {{bubbles: true}})' "$wep" \ && grep -q 'new RegExp' "$wep" \ && grep -q 'LEMANA_VPN_AUTOFILL_DISABLE' "$wep" \ && grep -q 'LEMANA_VPN_AUTOFILL_CLICK' "$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" \ && ! grep -q 'requests.get(self.host.vpn_url)' "$authp" } _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 authp 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 authp="$(_find_authenticator)" if [[ -z "$authp" || ! -f "$authp" ]]; then printf 'authenticator.py not found. Run: pipx install openconnect-lite\n' >&2 return 1 fi "$OC_PYTHON" - "$wep" "$authp" "$PATCH_BACKUP_DIR" <<'PY' from pathlib import Path import sys path = Path(sys.argv[1]) auth_path = Path(sys.argv[2]) backup_dir = Path(sys.argv[3]) backup_file = backup_dir / "webengine_process.py.before-lemana-vpn" auth_backup_file = backup_dir / "authenticator.py.before-lemana-vpn" src = path.read_text() before = src messages = [] def fail(label): print(f"Cannot apply {label} patch: unsupported openconnect-lite source", file=sys.stderr) sys.exit(1) def replace_once(old, new, label, required=False): global src if old in src: src = src.replace(old, new, 1) messages.append(label) return True if required: fail(label) return False replace_once( 'argv += ["-platform", "minimal"]', 'argv += ["-platform", "offscreen"]', "minimal -> offscreen", ) if '"offscreen"' not in src: fail("minimal -> offscreen") if "\nimport os\n" not in src: replace_once("import json\n", "import json\nimport os\n", "manual SSO import", required=True) if "Autologin disabled by Lemana VPN" not in src: replace_once( ''' if credentials: logger.info("Initiating autologin", cred=credentials) ''', ''' if os.environ.get("LEMANA_VPN_AUTOFILL_DISABLE") == "1": logger.info("Autologin disabled by Lemana VPN") elif credentials: logger.info("Initiating autologin", cred=credentials) ''', "manual SSO disable", required=True, ) replace_once( ''' script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentReady) script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld) ''', ''' script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentReady) script.setWorldId(QWebEngineScript.ScriptWorldId.ApplicationWorld) ''', "restore auto-fill ApplicationWorld", ) src = src.replace( ' # Convert glob pattern to JS regex (not literal — URLs contain /)\n', '', ) src = src.replace( ' # Convert glob pattern to JS regex (not literal - URLs contain /)\n', '', ) old_script = ''' script.setSourceCode( f""" // ==UserScript== // @include {url_pattern} // ==/UserScript== function autoFill() {{ {get_selectors(rules, credentials)} setTimeout(autoFill, 1000); }} autoFill(); """ )''' canonical_script = ''' 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(); """ ) ''' script_marker = ''' regex_str = "^" + url_pattern.replace(".", "\\\\.").replace("*", ".*") + "$" ''' script_end_marker = ''' self.page().scripts().insert(script) ''' if script_marker in src: start = src.index(script_marker) end = src.index(script_end_marker, start) if src[start:end] != canonical_script: src = src[:start] + canonical_script + src[end:] messages.append("URL guard") elif old_script in src: src = src.replace(old_script, canonical_script.rstrip(), 1) messages.append("URL guard") else: fail("URL guard") selectors_marker = "def get_selectors(rules, credentials):\n" canonical_selectors = '''def get_selectors(rules, credentials): statements = [] for rule in rules: selector = json.dumps(rule.selector) if rule.action == "stop": statements.append( f"""var elem = document.querySelector({selector}); if (elem) {{ return; }}""" ) elif rule.fill: 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")); }}""" ) else: logger.warning( "Credential info not available", type=rule.fill, possibilities=dir(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(); }}""" ) return "\\n".join(statements) ''' if selectors_marker not in src: fail("auto-fill selectors") selectors_start = src.index(selectors_marker) if src[selectors_start:] != canonical_selectors: src = src[:selectors_start] + canonical_selectors messages.append("input/change events") auth_src = auth_path.read_text() auth_before = auth_src if "from urllib.parse import urljoin" not in auth_src: if "import requests\n" not in auth_src: fail("auth redirect import") auth_src = auth_src.replace( "import requests\n", "import requests\nfrom urllib.parse import urljoin\n", 1, ) messages.append("auth redirect import") detect_marker = " def _detect_authentication_target_url(self):\n" start_marker = " def _start_authentication(self):\n" canonical_detect = ''' def _detect_authentication_target_url(self): # Read the Cisco redirect target without opening / on the final headend. response = self.session.get(self.host.vpn_url, allow_redirects=False) if response.is_redirect: location = response.headers.get("Location") if not location: response.raise_for_status() self.host.address = urljoin(self.host.vpn_url, location) else: response.raise_for_status() self.host.address = response.url logger.debug("Auth target url", url=self.host.vpn_url) ''' if detect_marker not in auth_src or start_marker not in auth_src: fail("auth redirect") detect_start = auth_src.index(detect_marker) detect_end = auth_src.index(start_marker, detect_start) if auth_src[detect_start:detect_end] != canonical_detect: auth_src = auth_src[:detect_start] + canonical_detect + auth_src[detect_end:] messages.append("auth redirect") 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) if auth_src != auth_before: backup_dir.mkdir(parents=True, exist_ok=True) if not auth_backup_file.exists(): auth_backup_file.write_text(auth_before) auth_path.write_text(auth_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 _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" ;; esac 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 _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)) 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 $PATCH_ONLY_MODE; then _patch_oc 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)\"}" _prepare_connection_log _emit '{"event":"log","message":"openconnect-lite log is enabled"}' "openconnect-lite log: $CONNECT_LOG_FILE" trap '_stop_connect_progress; _dns_cleanup; _clear_status' EXIT display_mode="hidden" log_level="" autofill_debug="${LEMANA_VPN_AUTOFILL_DEBUG:-0}" autofill_disable="${LEMANA_VPN_AUTOFILL_DISABLE:-0}" autofill_click="${LEMANA_VPN_AUTOFILL_CLICK:-1}" if [[ "$CONNECT_MODE" == "manual" ]]; then display_mode="shown" autofill_disable="0" autofill_click="0" _emit '{"event":"manual_sso","autofill":true,"submit":false}' "Manual mode: browser is visible, fields are auto-filled, submit is not pressed." fi if $DEBUG; then display_mode="shown" log_level="--log-level debug" autofill_debug="1" fi reconnect_count=0 while true; do _start_connect_progress QTWEBENGINE_CHROMIUM_FLAGS="--disable-gpu" \ LEMANA_VPN_AUTOFILL_DEBUG="$autofill_debug" \ LEMANA_VPN_AUTOFILL_DISABLE="$autofill_disable" \ LEMANA_VPN_AUTOFILL_CLICK="$autofill_click" \ "$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