Добавь ручной режим SSO

This commit is contained in:
2026-05-20 10:37:41 +03:00
parent 94863e50bd
commit 9082dde0d7
3 changed files with 161 additions and 5 deletions

View File

@@ -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.
## Удаление

View File

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

View File

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