diff --git a/README.md b/README.md index 09247a0..89eb522 100644 --- a/README.md +++ b/README.md @@ -369,10 +369,9 @@ 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 корректнее видит изменение значения | +| `input/change events` | Оставляет старое прямое `value = ...`, но после него отправляет DOM events | Keycloak не реагирует на прямую запись value без событий | +| `legacy auto-fill` | Сохраняет старую рабочую схему `ApplicationWorld`, прямой `value = ...` и простой `click()` | Это ровно тот режим, на котором hidden SSO раньше стабильно проходил 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 сохраняет оригинальный файл: @@ -435,10 +434,9 @@ vpn --manual-sso --debug CLI перед подключением патчит `openconnect-lite`: - `minimal` -> `offscreen`, чтобы Qt WebEngine не падал на macOS; -- добавляет `input` и `change` events для Keycloak auto-fill; -- заполняет поля через native value setter; +- добавляет `input` и `change` events для Keycloak auto-fill, сохраняя старое прямое присваивание `value = ...`; +- оставляет auto-fill в старом `ApplicationWorld` и не добавляет stateful click guards/native setters; - добавляет 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 49b777b..ed7f535 100755 --- a/bin/vpn-lemanapro.sh +++ b/bin/vpn-lemanapro.sh @@ -160,7 +160,8 @@ _patches_active() { grep -q '"offscreen"' "$wep" \ && grep -q 'new Event("input", {{bubbles: true}})' "$wep" \ && grep -q 'new RegExp' "$wep" \ - && grep -q '__lemanaVpnClicked' "$wep" + && grep -q 'LEMANA_VPN_AUTOFILL_DISABLE' "$wep" \ + && ! grep -Eq '__lemanaVpnClicked|valueSetter|ScriptWorldId.MainWorld' "$wep" } _keychain_has() { @@ -332,88 +333,70 @@ 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") +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: - old_autologin = ''' if credentials: + replace_once( + ''' if credentials: logger.info("Initiating autologin", cred=credentials) -''' - new_autologin = ''' if os.environ.get("LEMANA_VPN_AUTOFILL_DISABLE") == "1": +''', + ''' 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);', +''', + "manual SSO disable", + required=True, ) - 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") +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", +) -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") +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_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( +old_script = ''' script.setSourceCode( f""" // ==UserScript== // @include {url_pattern} @@ -426,9 +409,9 @@ function autoFill() {{ autoFill(); """ )''' - new_block = ''' regex_str = "^" + url_pattern.replace(".", "\\\\.").replace("*", ".*") + "$" + +canonical_script = ''' 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== @@ -437,12 +420,6 @@ 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++; @@ -455,84 +432,58 @@ function autoFill() {{ }} 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) + ) +''' + +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") -if "js_debug =" not in src: - marker = ''' js_regex_str = json.dumps(regex_str) - script.setSourceCode( +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": + statements.append( + f"""var elem = document.querySelector({selector}); if (elem) {{ elem.dispatchEvent(new Event("focus")); elem.click(); }}""" + ) + return "\\n".join(statements) ''' - 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 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") if src != before: backup_dir.mkdir(parents=True, exist_ok=True) diff --git a/tests/smoke.sh b/tests/smoke.sh index aba92cf..cc98cb8 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -37,7 +37,88 @@ 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" + +fake_webengine="$TMP_DIR/webengine_process.py" +cat > "$fake_webengine" <<'PY' +import json +import sys + +class Browser: + def run(self, display_mode, credentials, url_pattern, rules): + argv = sys.argv.copy() + if display_mode == "hidden": + argv += ["-platform", "minimal"] + + if credentials: + logger.info("Initiating autologin", cred=credentials) + for url_pattern, rules in auto_fill_rules.items(): + script = QWebEngineScript() + script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentReady) + script.setWorldId(QWebEngineScript.ScriptWorldId.ApplicationWorld) + script.setSourceCode( + f""" +// ==UserScript== +// @include {url_pattern} +// ==/UserScript== + +function autoFill() {{ + {get_selectors(rules, credentials)} + setTimeout(autoFill, 1000); +}} +autoFill(); +""" + ) + self.page().scripts().insert(script) + +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("blur")); }}""" + ) + else: + logger.warning( + "Credential info not available", + type=rule.fill, + possibilities=dir(credentials), + ) + elif rule.action == "click": + statements.append( + f"""var elem = document.querySelector({selector}); if (elem) {{ elem.dispatchEvent(new Event("focus")); elem.click(); }}""" + ) + return "\n".join(statements) +PY + +LEMANA_VPN_WEBENGINE_PROCESS="$fake_webengine" \ +LEMANA_VPN_OC_PYTHON=python3 \ +LEMANA_VPN_PATCH_BACKUP_DIR="$TMP_DIR/patch-backups" \ + bash "$ROOT/bin/vpn-lemanapro.sh" --patch-only >/dev/null + +grep -q '"offscreen"' "$fake_webengine" +grep -q 'LEMANA_VPN_AUTOFILL_DISABLE' "$fake_webengine" +grep -q 'new RegExp' "$fake_webengine" +grep -q 'script.setWorldId(QWebEngineScript.ScriptWorldId.ApplicationWorld)' "$fake_webengine" +grep -q 'new Event("input", {{bubbles: true}})' "$fake_webengine" +if grep -q 'ScriptWorldId.MainWorld' "$fake_webengine"; then + echo "patched auto-fill should keep the original ApplicationWorld behavior" >&2 + exit 1 +fi +if grep -q '__lemanaVpnClicked' "$fake_webengine"; then + echo "patched auto-fill should stay stateless like the original working setup" >&2 + exit 1 +fi +if grep -q 'valueSetter' "$fake_webengine"; then + echo "patched auto-fill should use the original direct value assignment with input/change events" >&2 + exit 1 +fi status_text="$(bash "$ROOT/bin/vpn-lemanapro.sh" --status)" printf '%s\n' "$status_text" | grep -q 'Modules:'