diff --git a/README.md b/README.md index b59b805..09247a0 100644 --- a/README.md +++ b/README.md @@ -307,6 +307,7 @@ vpn # подключиться vpn --status # статус без нового подключения vpn --status --json # статус в JSON vpn-debug # видимый браузер и debug-логи +vpn --manual-sso --debug # видимый браузер без auto-fill/auto-submit Keycloak vpn-fix-dns # сбросить корпоративные DNS после аварийного завершения open ~/Applications/LemanaVPN.app # открыть Swift-приложение в menu bar ``` @@ -369,8 +370,10 @@ curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh \ | --- | --- | --- | | `minimal -> offscreen` | Меняет Qt platform mode для скрытого браузера | `minimal` падает с Qt WebEngine на macOS | | `input/change events` | После `value = ...` отправляет DOM events | Keycloak не реагирует на прямую запись value | +| `native value setter` | Заполняет поля через нативный setter `HTMLInputElement` | React/Keycloak корректнее видит изменение значения | | `URL guard` | Проверяет `location.href` через `new RegExp(...)` перед auto-fill | Qt игнорирует `@include`, без guard auto-fill может кликнуть Cisco ACS и сломать SAML | | `submit click guard` | Кликает submit один раз на страницу и только после заполнения поля | Без guard hidden-браузер может зациклиться на Keycloak `login-actions/authenticate` | +| `manual SSO disable` | Позволяет отключить auto-fill через `LEMANA_VPN_AUTOFILL_DISABLE=1` | Нужен для ручной диагностики в видимом браузере | Перед первым изменением CLI сохраняет оригинальный файл: @@ -404,7 +407,7 @@ vpn --status tail -f ~/Library/Logs/LemanaVPN-openconnect-lite.log ``` -В обычном режиме CLI также печатает heartbeat `Still waiting for SSO/openconnect-lite...`, чтобы было понятно, что процесс живой. В `vpn-debug` дополнительно показываются raw-логи и видимый браузер. +В обычном режиме CLI также печатает heartbeat `Still waiting for SSO/openconnect-lite...` до успешного подключения, чтобы было понятно, что процесс живой. В `vpn-debug` дополнительно показываются raw-логи и видимый браузер. Если в логе повторяется один и тот же URL вида `employee.auth.lemanapro.ru/realms/employee/login-actions/authenticate`, значит hidden-браузер застрял на Keycloak до перехода в Cisco ACS. Сначала обнови и примени runtime-патчи без подключения: @@ -419,14 +422,24 @@ vpn-lemanapro.sh --patch-only vpn-debug ``` +Если нужно самому посмотреть форму Keycloak и исключить влияние автоматического заполнения: + +```sh +vpn --manual-sso --debug +``` + +В этом режиме браузер видимый, а `openconnect-lite` не запускает auto-fill/auto-submit. LDAP-пароль и TOTP seed всё ещё берутся из Keychain, но ввод на странице выполняется вручную. + Если установка падает на строке `install: /usr/local/sbin/...: No such file or directory`, значит на машине не было `/usr/local/sbin`. Актуальный `install.sh` создаёт эту директорию сам; достаточно повторить установку свежей командой `curl`. CLI перед подключением патчит `openconnect-lite`: - `minimal` -> `offscreen`, чтобы Qt WebEngine не падал на macOS; - добавляет `input` и `change` events для Keycloak auto-fill; -- добавляет URL guard, чтобы auto-fill не кликал submit на Cisco ACS. +- заполняет поля через native value setter; +- добавляет URL guard, чтобы auto-fill не кликал submit на Cisco ACS; - добавляет submit click guard, чтобы auto-fill не отправлял одну и ту же Keycloak форму бесконечно. +- добавляет manual SSO disable для видимой ручной диагностики без auto-fill. ## Удаление diff --git a/bin/vpn-lemanapro.sh b/bin/vpn-lemanapro.sh index 9b08c5a..49b777b 100755 --- a/bin/vpn-lemanapro.sh +++ b/bin/vpn-lemanapro.sh @@ -4,11 +4,23 @@ 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}" @@ -36,6 +48,7 @@ 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 @@ -44,9 +57,10 @@ for arg in "$@"; do --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] +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 @@ -54,6 +68,7 @@ Usage: vpn-lemanapro.sh [--debug] [--json] [--status] [--configure-keychain] [-- --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 ;; @@ -325,9 +340,47 @@ 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")); 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"));' +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") @@ -336,11 +389,29 @@ elif fill_with_events in src: 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")); 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""" @@ -357,6 +428,7 @@ 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== @@ -365,6 +437,12 @@ autoFill(); 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++; @@ -384,11 +462,49 @@ autoFill(); 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++; @@ -404,6 +520,20 @@ if "window.__lemanaVpnPageHref" not in src: ''') 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(): @@ -649,6 +779,7 @@ _filter_output() { 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)" @@ -713,15 +844,25 @@ 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]} diff --git a/tests/smoke.sh b/tests/smoke.sh index e6cca06..aba92cf 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -35,6 +35,8 @@ printf '%s\n' "$status_json" | grep -q '"app":' grep -q 'LemanaVPN-openconnect-lite.log' "$ROOT/bin/vpn-lemanapro.sh" grep -q '"event":"waiting"' "$ROOT/bin/vpn-lemanapro.sh" grep -q -- '--patch-only' "$ROOT/bin/vpn-lemanapro.sh" +grep -q -- '--manual-sso' "$ROOT/bin/vpn-lemanapro.sh" +grep -q 'LEMANA_VPN_AUTOFILL_DISABLE' "$ROOT/bin/vpn-lemanapro.sh" grep -q '__lemanaVpnClicked' "$ROOT/bin/vpn-lemanapro.sh" status_text="$(bash "$ROOT/bin/vpn-lemanapro.sh" --status)"