Files
lemana-vpn/bin/vpn-lemanapro.sh

882 lines
36 KiB
Bash
Executable File

#!/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
MANUAL_SSO_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 ;;
--patch-only) PATCH_ONLY_MODE=true ;;
--manual-sso) MANUAL_SSO_MODE=true ;;
--help|-h)
cat <<'HELP'
Usage: vpn-lemanapro.sh [--debug] [--json] [--status] [--configure-keychain] [--patch-only] [--manual-sso]
--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
--patch-only Apply openconnect-lite runtime patches and exit
--manual-sso Show browser and disable Keycloak auto-fill/auto-submit
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)\"}"
}
_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
}
_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" \
&& grep -q '__lemanaVpnClicked' "$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
if "import os" not in src:
src = src.replace("import json\n", "import json\nimport os\n")
messages.append("autofill debug import")
if "Autologin disabled by Lemana VPN" not in src:
old_autologin = ''' if credentials:
logger.info("Initiating autologin", cred=credentials)
'''
new_autologin = ''' if os.environ.get("LEMANA_VPN_AUTOFILL_DISABLE") == "1":
logger.info("Autologin disabled by Lemana VPN")
elif credentials:
logger.info("Initiating autologin", cred=credentials)
'''
if old_autologin not in src:
print("Cannot apply manual SSO patch: unsupported openconnect-lite source", file=sys.stderr)
sys.exit(1)
src = src.replace(old_autologin, new_autologin)
messages.append("manual SSO disable")
if 'console.info("lemana autofill: " + message);' in src:
src = src.replace(
'console.info("lemana autofill: " + message);',
'console.warn("lemana autofill: " + message);',
)
messages.append("autofill diagnostics warning log")
plain_value_set = 'elem.value = {value}; window.__lemanaVpnFilled = true;'
native_value_set = 'var valueSetter = Object.getOwnPropertyDescriptor(elem.__proto__, "value") || Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value"); if (valueSetter && valueSetter.set) {{ valueSetter.set.call(elem, {value}); }} else {{ elem.value = {value}; }} window.__lemanaVpnFilled = true;'
if plain_value_set in src:
src = src.replace(plain_value_set, native_value_set)
messages.append("native value setter")
plain_click = 'elem.dispatchEvent(new Event("focus")); elem.click();'
delayed_click = 'elem.dispatchEvent(new Event("focus")); setTimeout(function() {{ if (document.contains(elem)) {{ elem.click(); }} }}, 250);'
if plain_click in src:
src = src.replace(plain_click, delayed_click)
messages.append("delayed submit click")
old_fill = 'elem.dispatchEvent(new Event("focus")); elem.value = {value}; elem.dispatchEvent(new Event("blur"));'
fill_with_events = '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"));'
new_fill = 'elem.dispatchEvent(new Event("focus")); var valueSetter = Object.getOwnPropertyDescriptor(elem.__proto__, "value") || Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value"); if (valueSetter && valueSetter.set) {{ valueSetter.set.call(elem, {value}); }} else {{ elem.value = {value}; }} window.__lemanaVpnFilled = true; 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")
elif fill_with_events in src:
src = src.replace(fill_with_events, new_fill)
messages.append("fill marker")
old_click = 'var elem = document.querySelector({selector}); if (elem) {{ elem.dispatchEvent(new Event("focus")); elem.click(); }}'
new_click = 'var elem = document.querySelector({selector}); if (elem && !elem.disabled && elem.offsetParent !== null && window.__lemanaVpnFilled) {{ window.__lemanaVpnClicked = window.__lemanaVpnClicked || {{}}; var clickKey = location.href + "|" + {selector}; if (!window.__lemanaVpnClicked[clickKey]) {{ window.__lemanaVpnClicked[clickKey] = true; elem.dispatchEvent(new Event("focus")); setTimeout(function() {{ if (document.contains(elem)) {{ elem.click(); }} }}, 250); }} }}'
if old_click in src:
src = src.replace(old_click, new_click)
messages.append("submit click guard")
stop_plain = 'var elem = document.querySelector({selector}); if (elem) {{ return; }}'
stop_debug = 'var elem = document.querySelector({selector}); if (elem && elem.offsetParent !== null && (elem.textContent || "").trim()) {{ _afLog("stop selector=" + {selector} + " text=" + (elem.textContent || "").trim().slice(0, 80)); return; }}'
if stop_plain in src:
src = src.replace(stop_plain, stop_debug)
messages.append("visible stop guard")
fill_plain = 'var elem = document.querySelector({selector}); if (elem) {{ elem.dispatchEvent(new Event("focus")); elem.value = {value}; window.__lemanaVpnFilled = true; elem.dispatchEvent(new Event("input", {{bubbles: true}})); elem.dispatchEvent(new Event("change", {{bubbles: true}})); elem.dispatchEvent(new Event("blur")); }}'
fill_debug = 'var elem = document.querySelector({selector}); if (elem) {{ _afLog("fill " + {rule.fill!r} + " selector=" + {selector} + " tag=" + elem.tagName + " type=" + (elem.type || "") + " id=" + (elem.id || "") + " name=" + (elem.name || "") + " value_len=" + String({value}.length)); elem.dispatchEvent(new Event("focus")); var valueSetter = Object.getOwnPropertyDescriptor(elem.__proto__, "value") || Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value"); if (valueSetter && valueSetter.set) {{ valueSetter.set.call(elem, {value}); }} else {{ elem.value = {value}; }} window.__lemanaVpnFilled = true; elem.dispatchEvent(new Event("input", {{bubbles: true}})); elem.dispatchEvent(new Event("change", {{bubbles: true}})); elem.dispatchEvent(new Event("blur")); }} else {{ _afLog("missing " + {rule.fill!r} + " selector=" + {selector}); }}'
if fill_plain in src and "_afLog(\"fill \"" not in src:
src = src.replace(fill_plain, fill_debug)
messages.append("fill diagnostics")
click_plain = 'var elem = document.querySelector({selector}); if (elem && !elem.disabled && elem.offsetParent !== null && window.__lemanaVpnFilled) {{ window.__lemanaVpnClicked = window.__lemanaVpnClicked || {{}}; var clickKey = location.href + "|" + {selector}; if (!window.__lemanaVpnClicked[clickKey]) {{ window.__lemanaVpnClicked[clickKey] = true; elem.dispatchEvent(new Event("focus")); elem.click(); }} }}'
click_debug = 'var elem = document.querySelector({selector}); window.__lemanaVpnClicked = window.__lemanaVpnClicked || {{}}; var clickKey = location.href + "|" + {selector}; _afLog("click-check selector=" + {selector} + " found=" + Boolean(elem) + " filled=" + Boolean(window.__lemanaVpnFilled) + " disabled=" + Boolean(elem && elem.disabled) + " visible=" + Boolean(elem && elem.offsetParent !== null) + " already=" + Boolean(window.__lemanaVpnClicked[clickKey])); if (elem && !elem.disabled && elem.offsetParent !== null && window.__lemanaVpnFilled && !window.__lemanaVpnClicked[clickKey]) {{ window.__lemanaVpnClicked[clickKey] = true; _afLog("click selector=" + {selector}); elem.dispatchEvent(new Event("focus")); setTimeout(function() {{ if (document.contains(elem)) {{ elem.click(); }} }}, 250); }}'
if click_plain in src and 'click-check selector=' not in src:
src = src.replace(click_plain, click_debug)
messages.append("click diagnostics")
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)
js_debug = "true" if os.environ.get("LEMANA_VPN_AUTOFILL_DEBUG") == "1" else "false"
script.setSourceCode(
f"""
// ==UserScript==
// @include {url_pattern}
// ==/UserScript==
var _afUrlPattern = new RegExp({js_regex_str});
var _afRun = 0;
var _afDebug = {js_debug};
function _afLog(message) {{
if (_afDebug && _afRun <= 5) {{
console.warn("lemana autofill: " + message);
}}
}}
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 "js_debug =" not in src:
marker = ''' js_regex_str = json.dumps(regex_str)
script.setSourceCode(
'''
if marker in src:
src = src.replace(marker, ''' js_regex_str = json.dumps(regex_str)
js_debug = "true" if os.environ.get("LEMANA_VPN_AUTOFILL_DEBUG") == "1" else "false"
script.setSourceCode(
''')
messages.append("autofill debug flag")
elif "_afLog(" in src:
print("Cannot apply autofill debug flag patch: unsupported openconnect-lite source", file=sys.stderr)
sys.exit(1)
if "function _afLog" not in src:
marker = '''var _afUrlPattern = new RegExp({js_regex_str});
var _afRun = 0;
function autoFill() {{
'''
if marker in src:
src = src.replace(marker, '''var _afUrlPattern = new RegExp({js_regex_str});
var _afRun = 0;
var _afDebug = {js_debug};
function _afLog(message) {{
if (_afDebug && _afRun <= 5) {{
console.warn("lemana autofill: " + message);
}}
}}
function autoFill() {{
''')
messages.append("autofill diagnostics")
elif "_afLog(" in src:
print("Cannot apply autofill diagnostics patch: unsupported openconnect-lite source", file=sys.stderr)
sys.exit(1)
page_state = ''' if (window.__lemanaVpnPageHref !== location.href) {{
window.__lemanaVpnPageHref = location.href;
window.__lemanaVpnFilled = false;
window.__lemanaVpnClicked = {{}};
}}
if (_afDebug && _afRun === 1 && document.body) {{
_afLog("body=" + (document.body.innerText || "").replace(/\\s+/g, " ").trim().slice(0, 180));
}}
'''
if "window.__lemanaVpnPageHref" not in src:
marker = ''' _afRun++;
{get_selectors(rules, credentials)}
'''
if marker not in src:
print("Cannot apply page state guard patch: unsupported openconnect-lite source", file=sys.stderr)
sys.exit(1)
src = src.replace(marker, ''' _afRun++;
''' + page_state + '''
{get_selectors(rules, credentials)}
''')
messages.append("page state guard")
if 'body=" + (document.body.innerText || "")' not in src:
marker = ''' if (window.__lemanaVpnPageHref !== location.href) {{
window.__lemanaVpnPageHref = location.href;
window.__lemanaVpnFilled = false;
window.__lemanaVpnClicked = {{}};
}}
'''
if marker in src:
src = src.replace(marker, marker + ''' if (_afDebug && _afRun === 1 && document.body) {{
_afLog("body=" + (document.body.innerText || "").replace(/\\s+/g, " ").trim().slice(0, 180));
}}
''')
messages.append("body diagnostics")
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
_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}"
if $MANUAL_SSO_MODE; then
display_mode="shown"
autofill_disable="1"
_emit '{"event":"manual_sso","autofill":false}' "Manual SSO mode: browser is visible, Keycloak auto-fill is disabled."
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" \
"$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