Восстанови старую схему автозаполнения VPN

This commit is contained in:
2026-05-20 11:20:32 +03:00
parent 9082dde0d7
commit 1385364265
3 changed files with 190 additions and 160 deletions

View File

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

View File

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

View File

@@ -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:'