Improve VPN status reporting and recovery

This commit is contained in:
2026-10-13 06:53:52 +03:00
parent fdb15ae462
commit 7a716b4c9c
188 changed files with 6953 additions and 1911 deletions
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""Patch a disposable engine and execute its actual autofill JS without Qt/browser."""
import ast
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import tomllib
from types import SimpleNamespace
ROOT = Path(__file__).resolve().parents[1]
SMOKE = (ROOT/'tests/smoke.sh').read_text()
with tempfile.TemporaryDirectory(prefix='lemana-auth-retry-') as temporary:
folder = Path(temporary)
for name in ('webengine', 'authenticator'):
fixture = SMOKE.split('cat > "$fake_'+name+'" <<\'PY\'\n', 1)[1].split('\nPY\n', 1)[0]
(folder/(name+'.py')).write_text(fixture)
env = {key:value for key,value in os.environ.items() if not key.startswith(('LEMANA_VPN_', 'OPENCONNECT_LITE_'))}
env.update(HOME=temporary, LEMANA_VPN_WEBENGINE_PROCESS=str(folder/'webengine.py'),
LEMANA_VPN_AUTHENTICATOR=str(folder/'authenticator.py'), LEMANA_VPN_OC_PYTHON=sys.executable,
LEMANA_VPN_PATCH_BACKUP_DIR=str(folder/'backups'))
command = ['bash', str(ROOT/'bin/vpn-lemanapro.sh'), '--patch-only']
subprocess.run(command, env=env, check=True, capture_output=True)
patched = (folder/'webengine.py').read_text()
subprocess.run(command, env=env, check=True, capture_output=True)
assert patched == (folder/'webengine.py').read_text(), 'Patch must be idempotent'
compile(patched, '<patched engine>', 'exec')
tree = ast.parse(patched)
selectors = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == 'get_selectors')
template = next(n.args[0] for n in ast.walk(tree) if isinstance(n, ast.Call)
and isinstance(n.func, ast.Attribute) and n.func.attr == 'setSourceCode')
profile = tomllib.loads((ROOT/'templates/openconnect-lite-config.toml').read_text())
url_pattern, rule_data = next(iter(profile['auto_fill_rules'].items()))
rules = [SimpleNamespace(selector=r['selector'], action=r.get('action'), fill=r.get('fill')) for r in rule_data]
messages, exits, delegated = [], [], []
class BasePage:
def javaScriptConsoleMessage(self, *args): delegated.append(args)
logger = SimpleNamespace(warning=lambda *args, **kwargs: messages.append(args))
fake_os = SimpleNamespace(environ={})
namespace = dict(os=fake_os, json=json, logger=logger, QWebEnginePage=BasePage,
QApplication=SimpleNamespace(instance=lambda: SimpleNamespace(exit=exits.append)),
credentials=SimpleNamespace(username='test-user', password='test-password', totp='123456'),
rules=rules, url_pattern=url_pattern,
js_regex_str=json.dumps('^'+url_pattern.replace('.', '\\.').replace('*', '.*')+'$'))
callback = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == 'LemanaVPNPage')
exec(compile(ast.Module(body=[selectors, callback], type_ignores=[]), '<autofill>', 'exec'), namespace)
page = namespace['LemanaVPNPage']()
page.javaScriptConsoleMessage(1, 'LEMANA_VPN_AUTH_REJECTED', 0, 'test')
assert exits == [77] and messages == [('LEMANA_VPN_AUTH_REJECTED',)]
fake_os.environ['LEMANA_VPN_AUTOFILL_CLICK'] = '0'
page.javaScriptConsoleMessage(1, 'LEMANA_VPN_AUTH_REJECTED', 0, 'test')
assert exits == [77] and len(delegated) == 1
scripts = {}
for name, click, attempt in [('auto', '1', 'attempt-one'), ('fresh', '1', 'attempt-two'), ('manual', '0', 'attempt-one')]:
fake_os.environ.update(LEMANA_VPN_AUTOFILL_CLICK=click, LEMANA_VPN_AUTH_ATTEMPT=attempt,
LEMANA_VPN_AUTOFILL_TOTP='0' if name == 'manual' else '1')
scripts[name] = eval(compile(ast.Expression(template), '<script>', 'eval'), namespace)
(folder/'scripts.json').write_text(json.dumps(scripts))
test_js = r'''
const assert = require('node:assert/strict');
const vm = require('node:vm');
const scripts = require(process.argv[2]);
function page(script, fields, storage = new Map(), url = 'https://employee.auth.lemanapro.ru/login') {
const clicks = [], messages = [], timers = [], elements = {};
for (const name of fields) elements[name] = {
type: name === 'password' ? 'password' : 'text', disabled: false,
getClientRects: () => [1], dispatchEvent: () => {}, value: ''
};
const button = {dispatchEvent: () => {}, click: () => clicks.push(1)};
const context = vm.createContext({location: {href: url}, Event: function() {},
console: {warn: message => messages.push(message)}, setTimeout: f => timers.push(f),
sessionStorage: {getItem: k => storage.get(k), setItem: (k,v) => storage.set(k,v)},
document: {querySelector: selector => {
if (selector.includes('input-error')) return elements.error || null;
if (selector === 'input#username') return elements.username || null;
if (selector === 'input#password') return elements.password || null;
if (selector.startsWith('input#otp')) return elements.totp || null;
if (selector.startsWith('input[type=submit]')) return button;
throw Error(selector);
}}
});
vm.runInContext(script, context);
return {clicks, messages, elements, context, storage, tick: () => timers.shift()?.()};
}
const login = page(scripts.auto, ['username','password']);
assert.equal(login.clicks.length, 1);
assert.equal(login.elements.password.value, 'test-password');
for(let i=0;i<5;i++) login.tick();
assert.equal(login.clicks.length, 1, 'No repeated click on a slow page');
const otp = page(scripts.auto, ['totp'], login.storage);
assert.equal(otp.clicks.length, 1, 'Password then OTP are separate stages');
const repeated = page(scripts.auto, ['username','password'], login.storage);
assert.equal(repeated.clicks.length, 0);
assert.deepEqual(repeated.messages, ['LEMANA_VPN_AUTH_REJECTED']);
const repeatedOtp = page(scripts.auto, ['totp'], login.storage);
assert.equal(repeatedOtp.clicks.length, 0);
assert.deepEqual(repeatedOtp.messages, ['LEMANA_VPN_AUTH_REJECTED']);
assert.equal(page(scripts.fresh, ['username','password'], login.storage).clicks.length, 1);
login.elements.error = {getClientRects: () => [1], textContent: 'Invalid credentials'};
login.tick(); login.tick();
assert.deepEqual(login.messages, ['LEMANA_VPN_AUTH_REJECTED']);
const manual = page(scripts.manual, ['username','password','totp'], login.storage);
manual.elements.error = {getClientRects: () => [1], textContent: 'Invalid code'};
manual.tick();
assert.equal(manual.elements.password.value, 'test-password');
assert.equal(manual.elements.totp.value, '');
assert.equal(manual.clicks.length, 0); assert.equal(manual.messages.length, 0);
const foreign = page(scripts.auto, ['username','password'], new Map(), 'https://unrelated.example/login');
assert.equal(foreign.elements.password.value, ''); assert.equal(foreign.clicks.length, 0);
const unavailable = page(scripts.auto, ['username','password'], {get: () => {throw Error('blocked')}});
assert.equal(unavailable.clicks.length, 0);
assert.deepEqual(unavailable.messages, ['LEMANA_VPN_AUTH_REJECTED']);
const staged = page(scripts.auto, ['username']);
staged.elements.password = {type:'password', getClientRects:()=>[1], dispatchEvent:()=>{}, value:''};
staged.tick();
assert.equal(staged.clicks.length, 2, 'Username then password in the same document');
console.log('PASS autofill: one submit per stage, reload/error terminal, fresh attempt, manual, URL guard, unavailable storage');
'''
(folder/'checks.cjs').write_text(test_js)
subprocess.run(['node', str(folder/'checks.cjs'), str(folder/'scripts.json')], check=True)
print('PASS engine: isolated patch idempotence, Python syntax, rejection callback terminates only automatic authentication')