Добавь ручной и автоматический режимы VPN

This commit is contained in:
2026-05-20 12:10:49 +03:00
parent 1385364265
commit 7c625e840e
6 changed files with 185 additions and 32 deletions

View File

@@ -48,7 +48,7 @@ JSON_MODE=false
STATUS_MODE=false
CONFIGURE_KEYCHAIN_MODE=false
PATCH_ONLY_MODE=false
MANUAL_SSO_MODE=false
CONNECT_MODE="${LEMANA_VPN_MODE:-auto}"
for arg in "$@"; do
case "$arg" in
@@ -57,24 +57,35 @@ 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 ;;
--auto|auto) CONNECT_MODE="auto" ;;
--manual|manual|--manual-sso) CONNECT_MODE="manual" ;;
--help|-h)
cat <<'HELP'
Usage: vpn-lemanapro.sh [--debug] [--json] [--status] [--configure-keychain] [--patch-only] [--manual-sso]
Usage: vpn-lemanapro.sh [--auto|--manual] [--debug] [--json] [--status] [--configure-keychain] [--patch-only]
--status Show current VPN status without connecting
--status --json Show current VPN status as JSON
--debug Run visible browser and passthrough debug logs
--auto Hidden browser, auto-fill and auto-submit (default)
--manual Visible browser, auto-fill fields, do not press submit
--manual-sso Compatibility alias for --manual
--debug Passthrough debug logs; also shows browser in auto mode
--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
;;
esac
done
case "$CONNECT_MODE" in
auto|manual) ;;
*)
printf 'Unknown VPN mode: %s. Use --auto or --manual.\n' "$CONNECT_MODE" >&2
exit 2
;;
esac
_emit() {
local json="$1" human="$2"
if $JSON_MODE; then
@@ -145,6 +156,14 @@ _find_webengine_process() {
find "$OC_VENV/lib" -path '*/site-packages/openconnect_lite/browser/webengine_process.py' -print -quit 2>/dev/null
}
_find_authenticator() {
if [[ -n "${LEMANA_VPN_AUTHENTICATOR:-}" ]]; then
printf '%s\n' "$LEMANA_VPN_AUTHENTICATOR"
return 0
fi
find "$OC_VENV/lib" -path '*/site-packages/openconnect_lite/authenticator.py' -print -quit 2>/dev/null
}
_module_bool() {
if "$@" >/dev/null 2>&1; then
printf true
@@ -154,14 +173,19 @@ _module_bool() {
}
_patches_active() {
local wep
local wep authp
wep="$(_find_webengine_process)"
[[ -n "$wep" && -f "$wep" ]] || return 1
authp="$(_find_authenticator)"
[[ -n "$wep" && -f "$wep" && -n "$authp" && -f "$authp" ]] || return 1
grep -q '"offscreen"' "$wep" \
&& grep -q 'new Event("input", {{bubbles: true}})' "$wep" \
&& grep -q 'new RegExp' "$wep" \
&& grep -q 'LEMANA_VPN_AUTOFILL_DISABLE' "$wep" \
&& ! grep -Eq '__lemanaVpnClicked|valueSetter|ScriptWorldId.MainWorld' "$wep"
&& grep -q 'LEMANA_VPN_AUTOFILL_CLICK' "$wep" \
&& ! grep -Eq '__lemanaVpnClicked|valueSetter|ScriptWorldId.MainWorld' "$wep" \
&& grep -q 'self.session.get(self.host.vpn_url, allow_redirects=False)' "$authp" \
&& grep -q 'response.headers.get("Location")' "$authp" \
&& ! grep -q 'requests.get(self.host.vpn_url)' "$authp"
}
_keychain_has() {
@@ -317,20 +341,27 @@ _check_status() {
}
_patch_oc() {
local wep
local wep authp
wep="$(_find_webengine_process)"
if [[ -z "$wep" || ! -f "$wep" ]]; then
printf 'webengine_process.py not found. Run: pipx install openconnect-lite\n' >&2
return 1
fi
authp="$(_find_authenticator)"
if [[ -z "$authp" || ! -f "$authp" ]]; then
printf 'authenticator.py not found. Run: pipx install openconnect-lite\n' >&2
return 1
fi
"$OC_PYTHON" - "$wep" "$PATCH_BACKUP_DIR" <<'PY'
"$OC_PYTHON" - "$wep" "$authp" "$PATCH_BACKUP_DIR" <<'PY'
from pathlib import Path
import sys
path = Path(sys.argv[1])
backup_dir = Path(sys.argv[2])
auth_path = Path(sys.argv[2])
backup_dir = Path(sys.argv[3])
backup_file = backup_dir / "webengine_process.py.before-lemana-vpn"
auth_backup_file = backup_dir / "authenticator.py.before-lemana-vpn"
src = path.read_text()
before = src
messages = []
@@ -473,9 +504,10 @@ canonical_selectors = '''def get_selectors(rules, credentials):
possibilities=dir(credentials),
)
elif rule.action == "click":
statements.append(
f"""var elem = document.querySelector({selector}); if (elem) {{ elem.dispatchEvent(new Event("focus")); elem.click(); }}"""
)
if os.environ.get("LEMANA_VPN_AUTOFILL_CLICK", "1") != "0":
statements.append(
f"""var elem = document.querySelector({selector}); if (elem) {{ elem.dispatchEvent(new Event("focus")); elem.click(); }}"""
)
return "\\n".join(statements)
'''
if selectors_marker not in src:
@@ -485,11 +517,53 @@ if src[selectors_start:] != canonical_selectors:
src = src[:selectors_start] + canonical_selectors
messages.append("input/change events")
auth_src = auth_path.read_text()
auth_before = auth_src
if "from urllib.parse import urljoin" not in auth_src:
if "import requests\n" not in auth_src:
fail("auth redirect import")
auth_src = auth_src.replace(
"import requests\n",
"import requests\nfrom urllib.parse import urljoin\n",
1,
)
messages.append("auth redirect import")
detect_marker = " def _detect_authentication_target_url(self):\n"
start_marker = " def _start_authentication(self):\n"
canonical_detect = ''' def _detect_authentication_target_url(self):
# Read the Cisco redirect target without opening / on the final headend.
response = self.session.get(self.host.vpn_url, allow_redirects=False)
if response.is_redirect:
location = response.headers.get("Location")
if not location:
response.raise_for_status()
self.host.address = urljoin(self.host.vpn_url, location)
else:
response.raise_for_status()
self.host.address = response.url
logger.debug("Auth target url", url=self.host.vpn_url)
'''
if detect_marker not in auth_src or start_marker not in auth_src:
fail("auth redirect")
detect_start = auth_src.index(detect_marker)
detect_end = auth_src.index(start_marker, detect_start)
if auth_src[detect_start:detect_end] != canonical_detect:
auth_src = auth_src[:detect_start] + canonical_detect + auth_src[detect_end:]
messages.append("auth redirect")
if src != before:
backup_dir.mkdir(parents=True, exist_ok=True)
if not backup_file.exists():
backup_file.write_text(before)
path.write_text(src)
if auth_src != auth_before:
backup_dir.mkdir(parents=True, exist_ok=True)
if not auth_backup_file.exists():
auth_backup_file.write_text(auth_before)
auth_path.write_text(auth_src)
for message in messages:
print(f"Patch applied: {message}")
PY
@@ -797,10 +871,12 @@ display_mode="hidden"
log_level=""
autofill_debug="${LEMANA_VPN_AUTOFILL_DEBUG:-0}"
autofill_disable="${LEMANA_VPN_AUTOFILL_DISABLE:-0}"
if $MANUAL_SSO_MODE; then
autofill_click="${LEMANA_VPN_AUTOFILL_CLICK:-1}"
if [[ "$CONNECT_MODE" == "manual" ]]; then
display_mode="shown"
autofill_disable="1"
_emit '{"event":"manual_sso","autofill":false}' "Manual SSO mode: browser is visible, Keycloak auto-fill is disabled."
autofill_disable="0"
autofill_click="0"
_emit '{"event":"manual_sso","autofill":true,"submit":false}' "Manual mode: browser is visible, fields are auto-filled, submit is not pressed."
fi
if $DEBUG; then
display_mode="shown"
@@ -814,6 +890,7 @@ while true; do
QTWEBENGINE_CHROMIUM_FLAGS="--disable-gpu" \
LEMANA_VPN_AUTOFILL_DEBUG="$autofill_debug" \
LEMANA_VPN_AUTOFILL_DISABLE="$autofill_disable" \
LEMANA_VPN_AUTOFILL_CLICK="$autofill_click" \
"$OC_BIN" --browser-display-mode "$display_mode" $log_level 2>&1 \
| _filter_output
exit_code=${PIPESTATUS[0]}