Improve VPN status reporting and recovery
This commit is contained in:
@@ -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')
|
||||
Reference in New Issue
Block a user