1782 lines
69 KiB
Bash
Executable File
1782 lines
69 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
CONFIG_DIR="${LEMANA_VPN_CONFIG_DIR:-$HOME/.config/lemana-vpn}"
|
|
CONFIG_FILE="$CONFIG_DIR/env"
|
|
OC_CONFIG_DIR="${OPENCONNECT_LITE_CONFIG_DIR:-$HOME/.config/openconnect-lite}"
|
|
|
|
_ENV_LEMANA_VPN_USERNAME="${LEMANA_VPN_USERNAME+x}${LEMANA_VPN_USERNAME-}"
|
|
_ENV_LEMANA_VPN_BW_ITEM="${LEMANA_VPN_BW_ITEM+x}${LEMANA_VPN_BW_ITEM-}"
|
|
_ENV_LEMANA_VPN_MODE="${LEMANA_VPN_MODE+x}${LEMANA_VPN_MODE-}"
|
|
_ENV_LEMANA_VPN_AUTH_PROFILE="${LEMANA_VPN_AUTH_PROFILE+x}${LEMANA_VPN_AUTH_PROFILE-}"
|
|
_ENV_LEMANA_VPN_USE_BITWARDEN="${LEMANA_VPN_USE_BITWARDEN+x}${LEMANA_VPN_USE_BITWARDEN-}"
|
|
_ENV_LEMANA_VPN_CREDENTIAL_SOURCE="${LEMANA_VPN_CREDENTIAL_SOURCE+x}${LEMANA_VPN_CREDENTIAL_SOURCE-}"
|
|
_ENV_LEMANA_VPN_USE_TOUCHID="${LEMANA_VPN_USE_TOUCHID+x}${LEMANA_VPN_USE_TOUCHID-}"
|
|
_ENV_LEMANA_VPN_DNS_CLEANUP="${LEMANA_VPN_DNS_CLEANUP+x}${LEMANA_VPN_DNS_CLEANUP-}"
|
|
_ENV_LEMANA_VPN_LOG_MAX_BYTES="${LEMANA_VPN_LOG_MAX_BYTES+x}${LEMANA_VPN_LOG_MAX_BYTES-}"
|
|
_ENV_LEMANA_VPN_LOCAL_OTP="${LEMANA_VPN_LOCAL_OTP+x}${LEMANA_VPN_LOCAL_OTP-}"
|
|
_ENV_LEMANA_VPN_AUTO_SOURCE="${LEMANA_VPN_AUTO_SOURCE+x}${LEMANA_VPN_AUTO_SOURCE-}"
|
|
|
|
if [[ -f "$CONFIG_FILE" ]]; then
|
|
# shellcheck disable=SC1090
|
|
source "$CONFIG_FILE"
|
|
fi
|
|
|
|
[[ "${_ENV_LEMANA_VPN_USERNAME:0:1}" == "x" ]] && LEMANA_VPN_USERNAME="${_ENV_LEMANA_VPN_USERNAME:1}"
|
|
[[ "${_ENV_LEMANA_VPN_BW_ITEM:0:1}" == "x" ]] && LEMANA_VPN_BW_ITEM="${_ENV_LEMANA_VPN_BW_ITEM:1}"
|
|
[[ "${_ENV_LEMANA_VPN_MODE:0:1}" == "x" ]] && LEMANA_VPN_MODE="${_ENV_LEMANA_VPN_MODE:1}"
|
|
[[ "${_ENV_LEMANA_VPN_AUTH_PROFILE:0:1}" == "x" ]] && LEMANA_VPN_AUTH_PROFILE="${_ENV_LEMANA_VPN_AUTH_PROFILE:1}"
|
|
[[ "${_ENV_LEMANA_VPN_USE_BITWARDEN:0:1}" == "x" ]] && LEMANA_VPN_USE_BITWARDEN="${_ENV_LEMANA_VPN_USE_BITWARDEN:1}"
|
|
[[ "${_ENV_LEMANA_VPN_CREDENTIAL_SOURCE:0:1}" == "x" ]] && LEMANA_VPN_CREDENTIAL_SOURCE="${_ENV_LEMANA_VPN_CREDENTIAL_SOURCE:1}"
|
|
[[ "${_ENV_LEMANA_VPN_USE_TOUCHID:0:1}" == "x" ]] && LEMANA_VPN_USE_TOUCHID="${_ENV_LEMANA_VPN_USE_TOUCHID:1}"
|
|
[[ "${_ENV_LEMANA_VPN_DNS_CLEANUP:0:1}" == "x" ]] && LEMANA_VPN_DNS_CLEANUP="${_ENV_LEMANA_VPN_DNS_CLEANUP:1}"
|
|
[[ "${_ENV_LEMANA_VPN_LOG_MAX_BYTES:0:1}" == "x" ]] && LEMANA_VPN_LOG_MAX_BYTES="${_ENV_LEMANA_VPN_LOG_MAX_BYTES:1}"
|
|
[[ "${_ENV_LEMANA_VPN_LOCAL_OTP:0:1}" == "x" ]] && LEMANA_VPN_LOCAL_OTP="${_ENV_LEMANA_VPN_LOCAL_OTP:1}"
|
|
[[ "${_ENV_LEMANA_VPN_AUTO_SOURCE:0:1}" == "x" ]] && LEMANA_VPN_AUTO_SOURCE="${_ENV_LEMANA_VPN_AUTO_SOURCE:1}"
|
|
|
|
OC_VENV="${LEMANA_VPN_OC_VENV:-$HOME/.local/pipx/venvs/openconnect-lite}"
|
|
OC_PYTHON="${LEMANA_VPN_OC_PYTHON:-$OC_VENV/bin/python}"
|
|
OC_BIN="${LEMANA_VPN_OC_BIN:-$HOME/.local/bin/openconnect-lite}"
|
|
BW_ITEM_NAME="${LEMANA_VPN_BW_ITEM:-LM LDAP}"
|
|
KC_USERNAME="${LEMANA_VPN_USERNAME:-}"
|
|
KC_FP="${LEMANA_VPN_KEYCHAIN_FINGERPRINT:-$HOME/bin/keychain-fingerprint}"
|
|
MODE="${LEMANA_VPN_MODE:-}"
|
|
LOCAL_OTP="${LEMANA_VPN_LOCAL_OTP-0}"
|
|
AUTO_SOURCE="${LEMANA_VPN_AUTO_SOURCE:-keychain}"
|
|
case "$LOCAL_OTP" in
|
|
0|1) ;;
|
|
*) printf 'LEMANA_VPN_LOCAL_OTP must be 0 or 1.\n' >&2; exit 2 ;;
|
|
esac
|
|
AUTH_PROFILE="${LEMANA_VPN_AUTH_PROFILE:-manual}"
|
|
CREDENTIAL_SOURCE="${LEMANA_VPN_CREDENTIAL_SOURCE:-}"
|
|
if [[ -z "$CREDENTIAL_SOURCE" ]]; then
|
|
if [[ "${LEMANA_VPN_USE_BITWARDEN:-0}" == "1" ]]; then
|
|
CREDENTIAL_SOURCE="bitwarden"
|
|
else
|
|
CREDENTIAL_SOURCE="none"
|
|
fi
|
|
fi
|
|
USE_TOUCHID="${LEMANA_VPN_USE_TOUCHID:-0}"
|
|
CACHE_BW_SESSION="${LEMANA_VPN_CACHE_BW_SESSION:-0}"
|
|
DNS_CLEANUP="${LEMANA_VPN_DNS_CLEANUP:-/usr/local/sbin/lemana-vpn-dns-cleanup}"
|
|
APP_DIR="${LEMANA_VPN_APP_DIR:-$HOME/Applications/LemanaVPN.app}"
|
|
LAUNCH_AGENT="${LEMANA_VPN_LAUNCH_AGENT:-$HOME/Library/LaunchAgents/ru.dokops.LemanaVPN.plist}"
|
|
BW_KC_SERVICE="${LEMANA_VPN_BW_KC_SERVICE:-vpn-lemanapro}"
|
|
BW_KC_ACCOUNT_SESSION="${LEMANA_VPN_BW_KC_ACCOUNT_SESSION:-bw-session}"
|
|
BW_KC_ACCOUNT_MASTER="${LEMANA_VPN_BW_KC_ACCOUNT_MASTER:-bw-master}"
|
|
STATUS_DIR="${LEMANA_VPN_STATUS_DIR:-$HOME/.local/state/vpn-lemanapro}"
|
|
STATUS_FILE="$STATUS_DIR/status.json"
|
|
PATCH_BACKUP_DIR="${LEMANA_VPN_PATCH_BACKUP_DIR:-$CONFIG_DIR/patch-backups}"
|
|
CONNECT_LOG_DIR="${LEMANA_VPN_LOG_DIR:-$HOME/Library/Logs}"
|
|
CONNECT_LOG_FILE="${LEMANA_VPN_CONNECT_LOG:-$CONNECT_LOG_DIR/LemanaVPN-openconnect-lite.log}"
|
|
LOG_MAX_BYTES="${LEMANA_VPN_LOG_MAX_BYTES-5242880}"
|
|
if [[ ! "$LOG_MAX_BYTES" =~ ^[1-9][0-9]{0,8}$ ]]; then
|
|
printf 'LEMANA_VPN_LOG_MAX_BYTES must be an integer from 1 to 999999999.\n' >&2
|
|
exit 2
|
|
fi
|
|
CONNECT_WAIT_SECONDS="${LEMANA_VPN_CONNECT_WAIT_SECONDS:-20}"
|
|
OC_RUNTIME_XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"
|
|
MANUAL_XDG_CONFIG_HOME=""
|
|
|
|
DEBUG=false
|
|
JSON_MODE=false
|
|
STATUS_MODE=false
|
|
REPAIR_DNS_MODE=false
|
|
SESSION_ID=""
|
|
STOP_SESSION=""
|
|
RESTART_SESSION=""
|
|
RESTART_ATTEMPT=""
|
|
CONFIGURE_LOGIN_MODE=false
|
|
CONFIGURE_KEYCHAIN_MODE=false
|
|
IMPORT_YANDEX_KEY_MODE=false
|
|
SET_AUTH=""
|
|
PATCH_ONLY_MODE=false
|
|
CONNECT_MODE="${LEMANA_VPN_MODE:-}"
|
|
FULL_MANUAL_OVERRIDE=false
|
|
|
|
_normalize_public_mode() {
|
|
if [[ -z "${MODE:-}" ]]; then
|
|
case "$CREDENTIAL_SOURCE:$AUTH_PROFILE" in
|
|
bitwarden:*|*:auto)
|
|
MODE="bitwarden-auto"
|
|
;;
|
|
*)
|
|
MODE="manual-keychain"
|
|
;;
|
|
esac
|
|
fi
|
|
|
|
if [[ "$MODE" == "manual-keychain" && "$LOCAL_OTP" == "1" ]]; then
|
|
MODE="keychain-auto"
|
|
fi
|
|
case "$MODE" in
|
|
manual-keychain)
|
|
LOCAL_OTP="0"
|
|
AUTH_PROFILE="saved-login"
|
|
CREDENTIAL_SOURCE="keychain"
|
|
USE_BITWARDEN="0"
|
|
USE_TOUCHID="0"
|
|
;;
|
|
keychain-auto)
|
|
AUTH_PROFILE="auto"
|
|
CREDENTIAL_SOURCE="keychain"
|
|
LOCAL_OTP="1"
|
|
AUTO_SOURCE="keychain"
|
|
USE_BITWARDEN="0"
|
|
USE_TOUCHID="0"
|
|
;;
|
|
bitwarden-auto)
|
|
LOCAL_OTP="0"
|
|
AUTO_SOURCE="bitwarden"
|
|
AUTH_PROFILE="auto"
|
|
CREDENTIAL_SOURCE="bitwarden"
|
|
USE_BITWARDEN="1"
|
|
USE_TOUCHID="${LEMANA_VPN_USE_TOUCHID:-1}"
|
|
;;
|
|
*)
|
|
printf 'Unknown mode: %s. Use manual-keychain, keychain-auto or bitwarden-auto.\n' "$MODE" >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
case "$AUTO_SOURCE" in
|
|
keychain|bitwarden) ;;
|
|
*) printf 'LEMANA_VPN_AUTO_SOURCE must be keychain or bitwarden.\n' >&2; exit 2 ;;
|
|
esac
|
|
}
|
|
|
|
for arg in "$@"; do
|
|
case "$arg" in
|
|
--debug) DEBUG=true ;;
|
|
--json) JSON_MODE=true ;;
|
|
--status) STATUS_MODE=true ;;
|
|
--repair-dns) REPAIR_DNS_MODE=true ;;
|
|
--session-id=*) SESSION_ID="${arg#--session-id=}"; [[ "$SESSION_ID" =~ ^[a-f0-9-]{36}$ ]] || exit 2 ;;
|
|
--stop-session=*) STOP_SESSION="${arg#--stop-session=}"; [[ "$STOP_SESSION" =~ ^[a-f0-9-]{36}$ ]] || exit 2 ;;
|
|
--restart-session=*) RESTART_SESSION="${arg#--restart-session=}"; [[ "$RESTART_SESSION" =~ ^[a-f0-9-]{36}$ ]] || exit 2 ;;
|
|
--restart-attempt=*) RESTART_ATTEMPT="${arg#--restart-attempt=}"; [[ "$RESTART_ATTEMPT" =~ ^[a-f0-9-]{36}$ ]] || exit 2 ;;
|
|
--configure-login) CONFIGURE_LOGIN_MODE=true ;;
|
|
--configure-keychain) CONFIGURE_KEYCHAIN_MODE=true ;;
|
|
--import-yandex-key) IMPORT_YANDEX_KEY_MODE=true ;;
|
|
--set-auth=*) SET_AUTH="${arg#--set-auth=}"; case "$SET_AUTH" in manual|auto|keychain|bitwarden) ;; *) printf 'Unknown auth setting. Use manual, auto, keychain or bitwarden.\n' >&2; exit 2 ;; esac ;;
|
|
--patch-only) PATCH_ONLY_MODE=true ;;
|
|
--mode=manual-keychain|manual-keychain) MODE="manual-keychain"; CONNECT_MODE="manual-keychain"; LOCAL_OTP="0" ;;
|
|
--mode=keychain-auto|keychain-auto) MODE="keychain-auto"; CONNECT_MODE="keychain-auto" ;;
|
|
--mode=bitwarden-auto|bitwarden-auto) MODE="bitwarden-auto"; CONNECT_MODE="bitwarden-auto" ;;
|
|
--auto|auto) MODE="bitwarden-auto"; CONNECT_MODE="bitwarden-auto" ;;
|
|
--manual|manual|--manual-sso|--saved-login|saved-login|--login-only) MODE="manual-keychain"; CONNECT_MODE="manual-keychain"; LOCAL_OTP="0" ;;
|
|
--manual-full|manual-full|--manual-no-autofill) MODE="manual-keychain"; CONNECT_MODE="manual-keychain"; LOCAL_OTP="0"; FULL_MANUAL_OVERRIDE=true ;;
|
|
--help|-h)
|
|
cat <<'HELP'
|
|
Usage: vpn-lemanapro.sh [manual-keychain|keychain-auto|bitwarden-auto] [--manual-full] [--debug] [--json] [--status] [--configure-login] [--configure-keychain] [--import-yandex-key] [--set-auth=VALUE] [--patch-only]
|
|
|
|
--status Show current VPN status without connecting
|
|
--status --json Show current VPN status as JSON
|
|
--repair-dns Restore recorded orphaned VPN DNS without connecting
|
|
manual-keychain Visible browser, saved username/password, OTP entered manually
|
|
keychain-auto Automatic login using the key saved on this Mac
|
|
bitwarden-auto Automatic login using Bitwarden + optional Touch ID
|
|
--auto Compatibility alias for bitwarden-auto
|
|
--manual Compatibility alias for manual-keychain
|
|
--saved-login Compatibility alias for manual-keychain
|
|
--manual-full Diagnostic fallback: visible browser, no auto-fill, no submit
|
|
--manual-no-autofill Compatibility alias for --manual-full
|
|
--manual-sso Compatibility alias for manual-keychain
|
|
--debug Passthrough debug logs; also shows browser in auto mode
|
|
--json Emit JSON Lines events for UI wrappers
|
|
--configure-login Save LDAP username/password in macOS Keychain
|
|
--configure-keychain Save LDAP password plus permanent 2FA setup code or otpauth:// URI
|
|
--import-yandex-key Import and verify a local Yandex Key backup; enable automatic login
|
|
--set-auth=VALUE Save manual, auto, keychain or bitwarden for future connections
|
|
--patch-only Apply openconnect-lite runtime patches and exit
|
|
HELP
|
|
exit 0
|
|
;;
|
|
*)
|
|
printf 'Unknown argument: %s. Use --help.\n' "$arg" >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
done
|
|
|
|
if [[ -n "$RESTART_ATTEMPT" && -z "$RESTART_SESSION" ]]; then exit 2; fi
|
|
|
|
case "$CONNECT_MODE" in
|
|
"")
|
|
;;
|
|
manual-keychain|keychain-auto|bitwarden-auto)
|
|
MODE="$CONNECT_MODE"
|
|
;;
|
|
*)
|
|
printf 'Unknown mode: %s. Use manual-keychain, keychain-auto or bitwarden-auto.\n' "$CONNECT_MODE" >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
|
|
_normalize_public_mode
|
|
|
|
_emit() {
|
|
local json="$1" human="$2"
|
|
if $JSON_MODE; then
|
|
printf '%s\n' "$json"
|
|
else
|
|
printf '%s\n' "$human"
|
|
fi
|
|
}
|
|
|
|
_write_status() {
|
|
mkdir -p "$STATUS_DIR"
|
|
printf '%s\n' "$1" > "$STATUS_FILE"
|
|
}
|
|
|
|
_clear_status() {
|
|
_write_status "{\"pid\":$$,\"state\":\"disconnected\",\"updated_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}"
|
|
}
|
|
|
|
_cleanup_manual_openconnect_config() {
|
|
if [[ -n "${MANUAL_XDG_CONFIG_HOME:-}" && -d "$MANUAL_XDG_CONFIG_HOME" ]]; then
|
|
rm -rf "$MANUAL_XDG_CONFIG_HOME"
|
|
MANUAL_XDG_CONFIG_HOME=""
|
|
fi
|
|
}
|
|
|
|
_prepare_manual_openconnect_config() {
|
|
local source_config temp_root temp_config_dir
|
|
source_config="$OC_CONFIG_DIR/config.toml"
|
|
temp_root="$(mktemp -d "${TMPDIR:-/tmp}/lemana-vpn-manual-xdg.XXXXXX")"
|
|
temp_config_dir="$temp_root/openconnect-lite"
|
|
mkdir -p "$temp_config_dir"
|
|
|
|
if [[ -f "$source_config" ]]; then
|
|
CONFIG_SRC="$source_config" CONFIG_DST="$temp_config_dir/config.toml" python3 - <<'PY'
|
|
import os
|
|
from pathlib import Path
|
|
|
|
src = Path(os.environ["CONFIG_SRC"])
|
|
dst = Path(os.environ["CONFIG_DST"])
|
|
|
|
lines = src.read_text().splitlines()
|
|
rendered = []
|
|
skip_credentials = False
|
|
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
if stripped == "[credentials]" or stripped.startswith("[credentials."):
|
|
skip_credentials = True
|
|
continue
|
|
if skip_credentials and stripped.startswith("[") and stripped.endswith("]"):
|
|
skip_credentials = False
|
|
if not skip_credentials:
|
|
rendered.append(line)
|
|
|
|
dst.write_text("\n".join(rendered).rstrip() + "\n")
|
|
dst.chmod(0o600)
|
|
PY
|
|
fi
|
|
|
|
MANUAL_XDG_CONFIG_HOME="$temp_root"
|
|
OC_RUNTIME_XDG_CONFIG_HOME="$temp_root"
|
|
}
|
|
|
|
_prepare_connection_log() {
|
|
_log_connection_line "==== Lemana VPN openconnect-lite session; command: $OC_BIN ===="
|
|
}
|
|
|
|
_connection_log_failed=false
|
|
_connection_log_ready=false
|
|
|
|
_append_connection_log() {
|
|
local entry="$1" size archive_size=0
|
|
local LC_ALL=C
|
|
if ! ${_connection_log_ready:-false}; then
|
|
(umask 077; mkdir -p "$(dirname "$CONNECT_LOG_FILE")" && touch "$CONNECT_LOG_FILE") || return 1
|
|
chmod 600 "$CONNECT_LOG_FILE" || return 1
|
|
if [[ -f "$CONNECT_LOG_FILE.1" ]]; then
|
|
chmod 600 "$CONNECT_LOG_FILE.1" || return 1
|
|
archive_size="$(wc -c < "$CONNECT_LOG_FILE.1")" || return 1
|
|
fi
|
|
_connection_log_ready=true
|
|
fi
|
|
size="$(wc -c < "$CONNECT_LOG_FILE")" || return 1
|
|
if (( size + ${#entry} > LOG_MAX_BYTES || archive_size > LOG_MAX_BYTES )); then
|
|
# Copy only the bounded tail, including when upgrading a multi-GB legacy log.
|
|
(umask 077; tail -c "$LOG_MAX_BYTES" "$CONNECT_LOG_FILE" > "$CONNECT_LOG_FILE.1") || return 1
|
|
: > "$CONNECT_LOG_FILE" || return 1
|
|
fi
|
|
if (( ${#entry} > LOG_MAX_BYTES )); then
|
|
entry="${entry: -LOG_MAX_BYTES}"
|
|
fi
|
|
printf '%s' "$entry" >> "$CONNECT_LOG_FILE"
|
|
}
|
|
|
|
_log_connection_line() {
|
|
$_connection_log_failed && return 0
|
|
local entry
|
|
printf -v entry '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1"
|
|
if ! _append_connection_log "$entry" 2>/dev/null; then
|
|
printf 'Cannot write VPN log: %s; continuing without file logging.\n' "$CONNECT_LOG_FILE" >&2
|
|
_connection_log_failed=true
|
|
fi
|
|
}
|
|
|
|
_show_connection_log_tail() {
|
|
[[ -f "$CONNECT_LOG_FILE" ]] || return 0
|
|
printf 'Last openconnect-lite log lines (%s):\n' "$CONNECT_LOG_FILE" >&2
|
|
tail -n 40 "$CONNECT_LOG_FILE" >&2 || true
|
|
}
|
|
|
|
_progress_pid=""
|
|
|
|
_start_connect_progress() {
|
|
(
|
|
while sleep "$CONNECT_WAIT_SECONDS"; do
|
|
_emit '{"event":"waiting","message":"Still waiting for SSO/openconnect-lite"}' \
|
|
"Still waiting for SSO/openconnect-lite... log: $CONNECT_LOG_FILE"
|
|
done
|
|
) &
|
|
_progress_pid="$!"
|
|
}
|
|
|
|
_stop_connect_progress() {
|
|
if [[ -n "${_progress_pid:-}" ]]; then
|
|
kill "$_progress_pid" >/dev/null 2>&1 || true
|
|
wait "$_progress_pid" 2>/dev/null || true
|
|
_progress_pid=""
|
|
fi
|
|
}
|
|
|
|
_json_get() {
|
|
local key="$1"
|
|
python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('$key',''))" 2>/dev/null || true
|
|
}
|
|
|
|
_find_webengine_process() {
|
|
if [[ -n "${LEMANA_VPN_WEBENGINE_PROCESS:-}" ]]; then
|
|
printf '%s\n' "$LEMANA_VPN_WEBENGINE_PROCESS"
|
|
return 0
|
|
fi
|
|
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
|
|
else
|
|
printf false
|
|
fi
|
|
}
|
|
|
|
_dns_recovery_supported() {
|
|
# Never probe an old helper by executing it: old versions ignored arguments.
|
|
[[ -x "$DNS_CLEANUP" ]] && grep -qx '# LEMANA_DNS_PROTOCOL=2' "$DNS_CLEANUP"
|
|
}
|
|
|
|
_session_stop_supported() {
|
|
[[ -x "$DNS_CLEANUP" ]] && grep -qx '# LEMANA_SESSION_PROTOCOL=1' "$DNS_CLEANUP"
|
|
}
|
|
|
|
_stop_session() {
|
|
local result=error code=1
|
|
if _session_stop_supported && [[ "$STOP_SESSION" =~ ^[a-f0-9-]{36}$ ]]; then
|
|
if result="$(sudo -n "$DNS_CLEANUP" --stop "$STOP_SESSION")" && [[ "$result" == stopping ]]; then
|
|
result=stopping; code=0
|
|
else
|
|
result=error
|
|
fi
|
|
fi
|
|
_emit "{\"event\":\"session_stop\",\"result\":\"$result\"}" "Session stop: $result"
|
|
return "$code"
|
|
}
|
|
|
|
_patches_active() {
|
|
local wep authp
|
|
wep="$(_find_webengine_process)"
|
|
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 -q 'LEMANA_VPN_AUTOFILL_CLICK' "$wep" \
|
|
&& grep -q 'class LemanaVPNPage(QWebEnginePage)' "$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() {
|
|
security find-generic-password -s "$1" -a "$2" >/dev/null 2>&1
|
|
}
|
|
|
|
_set_auth() {
|
|
CONFIG_FILE="$CONFIG_FILE" AUTH_CHOICE="$SET_AUTH" AUTO_SOURCE="$AUTO_SOURCE" python3 - <<'PY'
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import tempfile
|
|
|
|
choice = os.environ["AUTH_CHOICE"]
|
|
source = os.environ["AUTO_SOURCE"] if choice in ("manual", "auto") else choice
|
|
mode = "manual-keychain" if choice == "manual" else source + "-auto"
|
|
settings = {
|
|
"LEMANA_VPN_MODE": mode,
|
|
"LEMANA_VPN_LOCAL_OTP": "1" if mode == "keychain-auto" else "0",
|
|
"LEMANA_VPN_AUTO_SOURCE": source,
|
|
"LEMANA_VPN_AUTH_PROFILE": "saved-login" if choice == "manual" else "auto",
|
|
"LEMANA_VPN_CREDENTIAL_SOURCE": "bitwarden" if mode == "bitwarden-auto" else "keychain",
|
|
"LEMANA_VPN_USE_BITWARDEN": "1" if mode == "bitwarden-auto" else "0",
|
|
}
|
|
path = Path(os.environ["CONFIG_FILE"])
|
|
staging = None
|
|
try:
|
|
text = path.read_text() if path.exists() else ""
|
|
for key in settings:
|
|
text = re.sub(r"(?m)^(?:export[ \t]+)?" + key + r"=[^\n]*(?:\n|$)", "", text)
|
|
text = text.rstrip("\n") + "\n" + "".join(f'{key}="{value}"\n' for key, value in settings.items())
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
fd, staging = tempfile.mkstemp(prefix=".auth-env-", dir=path.parent)
|
|
with os.fdopen(fd, "w") as stream:
|
|
stream.write(text)
|
|
os.replace(staging, path)
|
|
except OSError:
|
|
raise SystemExit("Не удалось сохранить способ входа. Проверьте доступ к настройкам Lemana VPN.") from None
|
|
finally:
|
|
if staging and os.path.exists(staging):
|
|
os.unlink(staging)
|
|
print('Способ входа сохранён. Настройка применяется при следующем подключении.')
|
|
PY
|
|
}
|
|
|
|
_app_menu_json() {
|
|
local login_ready="$1" keychain_ready="$2"
|
|
local install_label configured_mode available_modes setup_hint_json
|
|
|
|
configured_mode="$MODE"
|
|
setup_hint_json="null"
|
|
|
|
case "$MODE" in
|
|
manual-keychain)
|
|
if [[ "$login_ready" == "true" ]]; then
|
|
install_label="Режим: ручной вход, логин и пароль сохранены"
|
|
available_modes='"configured","bitwarden-auto"'
|
|
else
|
|
install_label="Режим: ручной вход, логин и пароль не настроены"
|
|
available_modes='"configured","bitwarden-auto"'
|
|
setup_hint_json='"Настройте в Terminal: vpn --configure-login"'
|
|
fi
|
|
;;
|
|
keychain-auto)
|
|
install_label="Автоматически · ключ на этом Mac"
|
|
available_modes='"configured","manual-keychain"'
|
|
if [[ "$login_ready" != "true" ]]; then
|
|
setup_hint_json='"Настройте логин и пароль: vpn --configure-login"'
|
|
elif [[ "$keychain_ready" != "true" ]]; then
|
|
setup_hint_json='"Импортируйте код: vpn --import-yandex-key"'
|
|
fi
|
|
;;
|
|
bitwarden-auto)
|
|
if [[ "$keychain_ready" == "true" ]]; then
|
|
install_label="Автоматически · Bitwarden"
|
|
available_modes='"configured","manual-keychain"'
|
|
else
|
|
install_label="Автоматически · Bitwarden, данные будут синхронизированы"
|
|
available_modes='"configured","manual-keychain"'
|
|
setup_hint_json='"Bitwarden синхронизирует данные при подключении; если не сработает, проверьте bw login"'
|
|
fi
|
|
;;
|
|
*)
|
|
install_label="Режим: неизвестен"
|
|
configured_mode="manual-keychain"
|
|
available_modes='"configured"'
|
|
setup_hint_json='"Обновите Lemana VPN через install.sh"'
|
|
;;
|
|
esac
|
|
|
|
printf '{"install_label":"%s","configured_mode":"%s","available_modes":[%s],"setup_hint":%s}' \
|
|
"$install_label" "$configured_mode" "$available_modes" "$setup_hint_json"
|
|
}
|
|
|
|
_module_status_json() {
|
|
local openconnect_installed openconnect_lite_installed bitwarden_installed touchid_installed dns_cleanup_installed
|
|
local app_installed app_autostart
|
|
local config_present oc_config_present patch_backup_present patches_active keychain_password keychain_totp_seed
|
|
local credential_keychain_ready credential_login_ready app_menu_json
|
|
local mode_label mode_ready mode_hint_json auth_kind auth_detail
|
|
openconnect_installed="$(_module_bool command -v openconnect)"
|
|
openconnect_lite_installed="$(_module_bool test -x "$OC_BIN")"
|
|
bitwarden_installed="$(_module_bool command -v bw)"
|
|
touchid_installed="$(_module_bool test -x "$KC_FP")"
|
|
dns_cleanup_installed="$(_module_bool test -x "$DNS_CLEANUP")"
|
|
app_installed="$(_module_bool test -x "$APP_DIR/Contents/MacOS/LemanaVPN")"
|
|
app_autostart="$(_module_bool test -f "$LAUNCH_AGENT")"
|
|
config_present="$(_module_bool test -f "$CONFIG_FILE")"
|
|
oc_config_present="$(_module_bool test -f "$HOME/.config/openconnect-lite/config.toml")"
|
|
patch_backup_present="$(_module_bool test -f "$PATCH_BACKUP_DIR/webengine_process.py.before-lemana-vpn")"
|
|
patches_active="$(_module_bool _patches_active)"
|
|
keychain_password="$(_module_bool _keychain_has openconnect-lite "$KC_USERNAME")"
|
|
keychain_totp_seed="$(_module_bool _keychain_has openconnect-lite "totp/$KC_USERNAME")"
|
|
credential_keychain_ready="$([[ "$keychain_password" == "true" && "$keychain_totp_seed" == "true" ]] && printf true || printf false)"
|
|
credential_login_ready="$([[ "$keychain_password" == "true" ]] && printf true || printf false)"
|
|
app_menu_json="$(_app_menu_json "$credential_login_ready" "$credential_keychain_ready")"
|
|
|
|
case "$MODE" in
|
|
manual-keychain)
|
|
mode_label="Вручную"
|
|
auth_kind="manual"
|
|
auth_detail="Логин и пароль с этого Mac, код вводится вручную"
|
|
mode_ready="$credential_login_ready"
|
|
mode_hint_json="$([[ "$credential_login_ready" == "true" ]] && printf null || printf '"Настройте логин и пароль: vpn --configure-login"')"
|
|
;;
|
|
keychain-auto)
|
|
mode_label="Автоматически · ключ на этом Mac"
|
|
auth_kind="auto"
|
|
auth_detail="Логин, пароль и код с этого Mac; вход без ручного ввода"
|
|
mode_ready="$credential_keychain_ready"
|
|
mode_hint_json=null
|
|
if [[ "$credential_login_ready" != "true" ]]; then
|
|
mode_hint_json='"Настройте логин и пароль: vpn --configure-login"'
|
|
elif [[ "$keychain_totp_seed" != "true" ]]; then
|
|
mode_hint_json='"Импортируйте код: vpn --import-yandex-key"'
|
|
fi
|
|
;;
|
|
bitwarden-auto)
|
|
mode_label="Автоматически · Bitwarden"
|
|
auth_kind="auto"
|
|
auth_detail="Логин, пароль и код из Bitwarden; вход без ручного ввода"
|
|
mode_ready="$([[ "$bitwarden_installed" == "true" ]] && printf true || printf false)"
|
|
mode_hint_json="$([[ "$bitwarden_installed" == "true" ]] && printf null || printf '"Установите Bitwarden CLI или переустановите ручной режим"')"
|
|
;;
|
|
*)
|
|
auth_kind="manual"
|
|
auth_detail="Обновите установку Lemana VPN"
|
|
mode_label="Неизвестный режим"
|
|
mode_ready=false
|
|
mode_hint_json='"Обновите Lemana VPN через install.sh"'
|
|
;;
|
|
esac
|
|
|
|
printf '{"core":{"openconnect":%s,"openconnect_lite":%s,"config":%s,"openconnect_lite_config":%s},"mode":{"id":"%s","label":"%s","ready":%s,"setup_hint":%s},"credentials":{"source":"%s","auth_profile":"%s","login_ready":%s,"keychain_ready":%s},"app_menu":%s,"bitwarden":{"enabled":%s,"installed":%s,"item":%s},"touchid":{"enabled":%s,"installed":%s},"keychain":{"password":%s,"totp_seed":%s},"dns_cleanup":{"installed":%s,"recovery_supported":%s,"stop_supported":%s},"patches":{"active":%s,"backup":%s},"app":{"installed":%s,"autostart":%s},"logging":{"max_bytes":%s},"local_otp":%s,"auth":{"mode":"%s","source":"%s","auto_source":"%s","label":"%s","detail":"%s","ready":%s,"setup_hint":%s}}' \
|
|
"$openconnect_installed" \
|
|
"$openconnect_lite_installed" \
|
|
"$config_present" \
|
|
"$oc_config_present" \
|
|
"$MODE" \
|
|
"$mode_label" \
|
|
"$mode_ready" \
|
|
"$mode_hint_json" \
|
|
"$CREDENTIAL_SOURCE" \
|
|
"$AUTH_PROFILE" \
|
|
"$credential_login_ready" \
|
|
"$credential_keychain_ready" \
|
|
"$app_menu_json" \
|
|
"$([[ "$USE_BITWARDEN" == "1" ]] && printf true || printf false)" \
|
|
"$bitwarden_installed" \
|
|
"$(printf '%s' "$BW_ITEM_NAME" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')" \
|
|
"$([[ "$USE_TOUCHID" == "1" ]] && printf true || printf false)" \
|
|
"$touchid_installed" \
|
|
"$keychain_password" \
|
|
"$keychain_totp_seed" \
|
|
"$dns_cleanup_installed" \
|
|
"$(_module_bool _dns_recovery_supported)" \
|
|
"$(_module_bool _session_stop_supported)" \
|
|
"$patches_active" \
|
|
"$patch_backup_present" \
|
|
"$app_installed" \
|
|
"$app_autostart" \
|
|
"$LOG_MAX_BYTES" \
|
|
"$([[ "$MODE" == "keychain-auto" ]] && printf true || printf false)" \
|
|
"$auth_kind" "$CREDENTIAL_SOURCE" "$AUTO_SOURCE" "$mode_label" "$auth_detail" "$mode_ready" "$mode_hint_json"
|
|
}
|
|
|
|
_module_human_part() {
|
|
local name="$1" enabled="$2" installed="$3"
|
|
if [[ "$enabled" == "0" ]]; then
|
|
printf '⏭️ %s=off' "$name"
|
|
elif [[ "$installed" == "true" ]]; then
|
|
printf '✅ %s=on' "$name"
|
|
else
|
|
printf '⚠️ %s=missing' "$name"
|
|
fi
|
|
}
|
|
|
|
_module_status_human() {
|
|
local core bitwarden_installed touchid_installed dns_cleanup_installed patches_active keychain_password keychain_totp_seed
|
|
local app_installed app_autostart
|
|
if command -v openconnect >/dev/null 2>&1 && [[ -x "$OC_BIN" && -f "$HOME/.config/openconnect-lite/config.toml" ]]; then
|
|
core="core=ok"
|
|
else
|
|
core="core=missing"
|
|
fi
|
|
|
|
bitwarden_installed="$(_module_bool command -v bw)"
|
|
touchid_installed="$(_module_bool test -x "$KC_FP")"
|
|
dns_cleanup_installed="$(_module_bool test -x "$DNS_CLEANUP")"
|
|
patches_active="$(_module_bool _patches_active)"
|
|
keychain_password="$(_module_bool _keychain_has openconnect-lite "$KC_USERNAME")"
|
|
keychain_totp_seed="$(_module_bool _keychain_has openconnect-lite "totp/$KC_USERNAME")"
|
|
app_installed="$(_module_bool test -x "$APP_DIR/Contents/MacOS/LemanaVPN")"
|
|
app_autostart="$(_module_bool test -f "$LAUNCH_AGENT")"
|
|
|
|
printf 'Modules: %s %s, 🔐 mode=%s, ' "$([[ "$core" == "core=ok" ]] && printf '✅' || printf '⚠️')" "$core" "$MODE"
|
|
_module_human_part "bitwarden" "$USE_BITWARDEN" "$bitwarden_installed"
|
|
printf ', '
|
|
_module_human_part "touchid" "$USE_TOUCHID" "$touchid_installed"
|
|
printf ', %s dns=%s, %s app=%s, %s autostart=%s, %s patches=%s, %s keychain=password:%s/totp_seed:%s\n' \
|
|
"$([[ "$dns_cleanup_installed" == "true" ]] && printf '✅' || printf '⚠️')" \
|
|
"$([[ "$dns_cleanup_installed" == "true" ]] && printf on || printf missing)" \
|
|
"$([[ "$app_installed" == "true" ]] && printf '✅' || printf '⚠️')" \
|
|
"$([[ "$app_installed" == "true" ]] && printf on || printf missing)" \
|
|
"$([[ "$app_autostart" == "true" ]] && printf '✅' || printf '⏭️')" \
|
|
"$([[ "$app_autostart" == "true" ]] && printf on || printf off)" \
|
|
"$([[ "$patches_active" == "true" ]] && printf '✅' || printf '⚠️')" \
|
|
"$([[ "$patches_active" == "true" ]] && printf active || printf pending)" \
|
|
"$([[ "$keychain_password" == "true" && ( "$MODE" == "manual-keychain" && "$LOCAL_OTP" == "0" || "$keychain_totp_seed" == "true" ) ]] && printf '✅' || printf '⚠️')" \
|
|
"$([[ "$keychain_password" == "true" ]] && printf yes || printf no)" \
|
|
"$([[ "$keychain_totp_seed" == "true" ]] && printf yes || printf no)"
|
|
if [[ "$MODE" == "keychain-auto" ]]; then
|
|
printf 'Local OTP: %s\n' "$([[ "$keychain_totp_seed" == "true" ]] && printf ready || printf 'missing; run vpn --import-yandex-key')"
|
|
fi
|
|
}
|
|
|
|
_check_status() {
|
|
local modules_json
|
|
modules_json="$(_module_status_json)"
|
|
|
|
if [[ ! -f "$STATUS_FILE" ]]; then
|
|
if $JSON_MODE; then
|
|
printf '{"state":"disconnected","reason":"no status file","modules":%s}\n' "$modules_json"
|
|
else
|
|
_module_status_human
|
|
printf '%s\n' "VPN disconnected (нет status-файла)"
|
|
fi
|
|
return 0
|
|
fi
|
|
|
|
local status_json pid state ip expires started_at dns_target
|
|
status_json="$(cat "$STATUS_FILE")"
|
|
pid="$(printf '%s' "$status_json" | _json_get pid)"
|
|
state="$(printf '%s' "$status_json" | _json_get state)"
|
|
ip="$(printf '%s' "$status_json" | _json_get ip)"
|
|
expires="$(printf '%s' "$status_json" | _json_get expires)"
|
|
started_at="$(printf '%s' "$status_json" | _json_get started_at)"
|
|
dns_target="$(printf '%s' "$status_json" | _json_get dns_target)"
|
|
|
|
local process_alive=false
|
|
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
|
|
process_alive=true
|
|
fi
|
|
|
|
if [[ "$state" != "connected" ]] || ! $process_alive; then
|
|
if $process_alive && [[ "$state" == connecting || "$state" == reconnecting || "$state" == disconnecting ]]; then
|
|
if $JSON_MODE; then printf '{"state":"%s","pid":%s,"modules":%s}\n' "$state" "$pid" "$modules_json"
|
|
else printf 'VPN %s\n' "$state"; fi
|
|
return 0
|
|
fi
|
|
if $JSON_MODE; then
|
|
printf '{"state":"disconnected","reason":"%s","modules":%s}\n' "$(! $process_alive && printf 'process dead (pid=%s)' "$pid" || printf '%s' "$state")" "$modules_json"
|
|
else
|
|
_module_status_human
|
|
printf '%s\n' "VPN disconnected"
|
|
fi
|
|
return 0
|
|
fi
|
|
|
|
local healthy=false
|
|
if [[ -n "$dns_target" ]] && ping -c 1 -W 2000 "$dns_target" >/dev/null 2>&1; then
|
|
healthy=true
|
|
fi
|
|
|
|
local remaining_sec=0 hours=0 mins=0 session_label="срок неизвестен"
|
|
if [[ -n "$expires" ]]; then
|
|
local exp_ts now_ts
|
|
exp_ts="$(date -u -jf "%Y-%m-%dT%H:%M:%SZ" "$expires" "+%s" 2>/dev/null || true)"
|
|
now_ts="$(date "+%s")"
|
|
if [[ -n "$exp_ts" ]]; then
|
|
remaining_sec=$((exp_ts - now_ts))
|
|
(( remaining_sec >= 0 )) || remaining_sec=0
|
|
hours=$((remaining_sec / 3600))
|
|
mins=$(((remaining_sec % 3600) / 60))
|
|
session_label="${hours}h ${mins}m"
|
|
fi
|
|
fi
|
|
|
|
if $JSON_MODE; then
|
|
printf '{"state":"connected","ip":"%s","healthy":%s,"remaining_sec":%s,"expires":"%s","started_at":"%s","pid":%s,"dns_target":"%s","modules":%s}\n' \
|
|
"$ip" "$healthy" "$remaining_sec" "$expires" "$started_at" "$pid" "$dns_target" "$modules_json"
|
|
else
|
|
_module_status_human
|
|
printf 'VPN connected - %s (session: %s, tunnel: %s)\n' \
|
|
"$ip" "$session_label" "$($healthy && printf healthy || printf unhealthy)"
|
|
fi
|
|
}
|
|
|
|
_patch_oc() {
|
|
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" "$authp" "$PATCH_BACKUP_DIR" <<'PY'
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
path = Path(sys.argv[1])
|
|
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 = []
|
|
|
|
|
|
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:
|
|
replace_once(
|
|
''' if credentials:
|
|
logger.info("Initiating autologin", cred=credentials)
|
|
''',
|
|
''' if os.environ.get("LEMANA_VPN_AUTOFILL_DISABLE") == "1":
|
|
logger.info("Autologin disabled by Lemana VPN")
|
|
elif credentials:
|
|
logger.info("Initiating autologin", cred=credentials)
|
|
''',
|
|
"manual SSO disable",
|
|
required=True,
|
|
)
|
|
|
|
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",
|
|
)
|
|
|
|
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_script = ''' script.setSourceCode(
|
|
f"""
|
|
// ==UserScript==
|
|
// @include {url_pattern}
|
|
// ==/UserScript==
|
|
|
|
function autoFill() {{
|
|
{get_selectors(rules, credentials)}
|
|
setTimeout(autoFill, 1000);
|
|
}}
|
|
autoFill();
|
|
"""
|
|
)'''
|
|
|
|
canonical_script = ''' regex_str = "^" + url_pattern.replace(".", "\\\\.").replace("*", ".*") + "$"
|
|
js_regex_str = json.dumps(regex_str)
|
|
script.setSourceCode(
|
|
f"""
|
|
// ==UserScript==
|
|
// @include {url_pattern}
|
|
// ==/UserScript==
|
|
|
|
var _afUrlPattern = new RegExp({js_regex_str});
|
|
var _afRun = 0;
|
|
var _afSubmitted = "";
|
|
var _afRejectedOnce = false;
|
|
var _afFields = [];
|
|
var _afAttempt = {json.dumps(os.environ.get("LEMANA_VPN_AUTH_ATTEMPT", ""))};
|
|
function _afRejected() {{
|
|
if (!_afRejectedOnce) console.warn("LEMANA_VPN_AUTH_REJECTED");
|
|
_afRejectedOnce = true;
|
|
}}
|
|
function _afMaySubmit() {{
|
|
if (_afRejectedOnce || _afFields.length === 0) return false;
|
|
var stage = _afFields.includes("totp") ? "totp" : (_afFields.includes("password") ? "password" : "username");
|
|
if (_afSubmitted === stage) return false;
|
|
try {{
|
|
if (sessionStorage.getItem("lemana:" + _afAttempt + ":" + stage)) {{
|
|
_afRejected(); return false;
|
|
}}
|
|
sessionStorage.setItem("lemana:" + _afAttempt + ":" + stage, "1");
|
|
}} catch (e) {{ _afRejected(); return false; }}
|
|
_afSubmitted = stage;
|
|
return true;
|
|
}}
|
|
function autoFill() {{
|
|
if (!_afUrlPattern.test(location.href.split('?')[0]) && !_afUrlPattern.test(location.href)) {{
|
|
_afRun++;
|
|
return;
|
|
}}
|
|
_afRun++;
|
|
if (_afRejectedOnce) return;
|
|
var error = document.querySelector("#input-error, #kc-error-message, .alert-error");
|
|
if ({json.dumps(os.environ.get("LEMANA_VPN_AUTOFILL_CLICK", "1") != "0")} && error && error.getClientRects().length && error.textContent.trim()) {{
|
|
_afRejected(); return;
|
|
}}
|
|
_afFields = [];
|
|
{get_selectors(rules, credentials)}
|
|
setTimeout(autoFill, 1000);
|
|
}}
|
|
autoFill();
|
|
"""
|
|
)
|
|
'''
|
|
|
|
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 "page = LemanaVPNPage(profile, self)" not in src:
|
|
replace_once("page = QWebEnginePage(profile, self)", "page = LemanaVPNPage(profile, self)", "terminal auth rejection", required=True)
|
|
|
|
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:
|
|
if rule.fill == "totp" and os.environ.get("LEMANA_VPN_AUTOFILL_TOTP", "1") == "0":
|
|
continue
|
|
value = json.dumps(getattr(credentials, rule.fill, None))
|
|
if value:
|
|
statements.append(
|
|
f"""var elem = document.querySelector({selector}); if (elem && !elem.disabled && elem.type !== "hidden" && elem.getClientRects().length) {{ _afFields.push({json.dumps(rule.fill)}); 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":
|
|
if os.environ.get("LEMANA_VPN_AUTOFILL_CLICK", "1") != "0":
|
|
statements.append(
|
|
f"""var elem = document.querySelector({selector}); if (elem && _afMaySubmit()) {{ elem.dispatchEvent(new Event("focus")); elem.click(); }}"""
|
|
)
|
|
return "\\n".join(statements)
|
|
|
|
|
|
class LemanaVPNPage(QWebEnginePage):
|
|
def javaScriptConsoleMessage(self, level, message, line, source):
|
|
if message == "LEMANA_VPN_AUTH_REJECTED" and os.environ.get("LEMANA_VPN_AUTOFILL_CLICK", "1") != "0":
|
|
logger.warning("LEMANA_VPN_AUTH_REJECTED")
|
|
QApplication.instance().exit(77)
|
|
return
|
|
super().javaScriptConsoleMessage(level, message, line, source)
|
|
'''
|
|
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")
|
|
|
|
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
|
|
}
|
|
|
|
_store_keychain() {
|
|
local password="$1" totp_secret="$2"
|
|
if [[ -z "${KC_USERNAME:-}" ]]; then
|
|
printf 'Cannot store VPN credentials: LDAP username is missing.\n' >&2
|
|
return 1
|
|
fi
|
|
KC_USERNAME="$KC_USERNAME" _VPN_PASS="$password" _VPN_TOTP_SECRET="$totp_secret" "$OC_PYTHON" - <<'PY'
|
|
import keyring
|
|
import os
|
|
|
|
username = os.environ["KC_USERNAME"]
|
|
password = os.environ.get("_VPN_PASS", "")
|
|
totp_secret = os.environ.get("_VPN_TOTP_SECRET", "")
|
|
|
|
if password:
|
|
keyring.set_password("openconnect-lite", username, password)
|
|
if totp_secret:
|
|
keyring.set_password("openconnect-lite", "totp/" + username, totp_secret)
|
|
PY
|
|
}
|
|
|
|
_normalize_totp_secret() {
|
|
_VPN_TOTP_INPUT="$1" python3 - <<'PY'
|
|
import os
|
|
import re
|
|
import sys
|
|
import urllib.parse
|
|
|
|
value = os.environ.get("_VPN_TOTP_INPUT", "").strip()
|
|
|
|
if value.lower().startswith("otpauth://"):
|
|
parsed = urllib.parse.urlparse(value)
|
|
query = urllib.parse.parse_qs(parsed.query)
|
|
value = query.get("secret", [""])[0]
|
|
|
|
value = re.sub(r"[\s-]+", "", value).upper()
|
|
|
|
if not value:
|
|
print("", end="")
|
|
sys.exit(0)
|
|
|
|
if not re.fullmatch(r"[A-Z2-7]+=*", value):
|
|
print("Invalid 2FA setup secret. Use a BASE32 secret or an otpauth:// URI with secret=BASE32.", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
print(value)
|
|
PY
|
|
}
|
|
|
|
_tty_available() {
|
|
[[ -r /dev/tty && -w /dev/tty ]] || return 1
|
|
{ : <> /dev/tty; } 2>/dev/null
|
|
}
|
|
|
|
_can_prompt() {
|
|
_tty_available || [[ -t 0 ]]
|
|
}
|
|
|
|
_prompt_read() {
|
|
local var_name="$1" prompt="$2" secret="${3:-0}" value read_status=0
|
|
if _tty_available; then
|
|
if [[ "$secret" == "1" ]]; then
|
|
IFS= read -rsp "$prompt" value < /dev/tty || read_status=$?
|
|
printf '\n' > /dev/tty
|
|
else
|
|
IFS= read -rp "$prompt" value < /dev/tty || read_status=$?
|
|
fi
|
|
else
|
|
if [[ "$secret" == "1" ]]; then
|
|
IFS= read -rsp "$prompt" value || read_status=$?
|
|
printf '\n'
|
|
else
|
|
IFS= read -rp "$prompt" value || read_status=$?
|
|
fi
|
|
fi
|
|
if (( read_status != 0 )); then
|
|
printf 'Input closed; credential setup cancelled.\n' >&2
|
|
return 1
|
|
fi
|
|
printf -v "$var_name" '%s' "$value"
|
|
}
|
|
|
|
_persist_username() {
|
|
KC_USERNAME="$KC_USERNAME" CONFIG_FILE="$CONFIG_FILE" OC_CONFIG_FILE="$OC_CONFIG_DIR/config.toml" python3 - <<'PY'
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
username = os.environ["KC_USERNAME"]
|
|
config_file = Path(os.environ["CONFIG_FILE"])
|
|
oc_config_file = Path(os.environ["OC_CONFIG_FILE"])
|
|
|
|
|
|
def shell_quote(value: str) -> str:
|
|
return '"' + value.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$").replace("`", "\\`") + '"'
|
|
|
|
|
|
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
lines = []
|
|
if config_file.exists():
|
|
lines = config_file.read_text().splitlines()
|
|
|
|
username_line = "LEMANA_VPN_USERNAME=" + shell_quote(username)
|
|
for index, line in enumerate(lines):
|
|
if line.startswith("LEMANA_VPN_USERNAME="):
|
|
lines[index] = username_line
|
|
break
|
|
else:
|
|
lines.insert(0, username_line)
|
|
|
|
config_file.write_text("\n".join(lines).rstrip() + "\n")
|
|
config_file.chmod(0o600)
|
|
|
|
if oc_config_file.exists():
|
|
oc_lines = oc_config_file.read_text().splitlines()
|
|
rendered = "username = " + json.dumps(username)
|
|
for index, line in enumerate(oc_lines):
|
|
if line.strip().startswith("username"):
|
|
oc_lines[index] = rendered
|
|
break
|
|
else:
|
|
oc_lines.insert(0, rendered)
|
|
oc_config_file.write_text("\n".join(oc_lines).rstrip() + "\n")
|
|
oc_config_file.chmod(0o600)
|
|
PY
|
|
}
|
|
|
|
_ensure_username() {
|
|
if [[ -n "${KC_USERNAME:-}" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
_emit '{"event":"username_required"}' "LDAP username is missing. Enter it once to save it for future VPN runs."
|
|
|
|
if ! _can_prompt; then
|
|
_emit '{"event":"error","message":"LDAP username is missing. Run vpn in Terminal once, or reinstall with --username <LDAP_USERNAME>."}' \
|
|
"LDAP username is missing. Run in Terminal: vpn"
|
|
return 1
|
|
fi
|
|
|
|
while [[ -z "${KC_USERNAME:-}" ]]; do
|
|
_prompt_read KC_USERNAME "Corporate LDAP username: " || return 1
|
|
KC_USERNAME="${KC_USERNAME#"${KC_USERNAME%%[![:space:]]*}"}"
|
|
KC_USERNAME="${KC_USERNAME%"${KC_USERNAME##*[![:space:]]}"}"
|
|
if [[ -z "$KC_USERNAME" ]]; then
|
|
printf 'LDAP username is required.\n' >&2
|
|
fi
|
|
done
|
|
|
|
export LEMANA_VPN_USERNAME="$KC_USERNAME"
|
|
_persist_username
|
|
_emit '{"event":"username_saved"}' "LDAP username saved for future VPN runs."
|
|
}
|
|
|
|
_configure_keychain() {
|
|
local password totp_secret
|
|
local password_present=false totp_present=false
|
|
_keychain_has openconnect-lite "$KC_USERNAME" && password_present=true
|
|
_keychain_has openconnect-lite "totp/$KC_USERNAME" && totp_present=true
|
|
|
|
printf 'Manual LDAP credentials setup for Lemana VPN\n'
|
|
printf 'User: %s\n' "$KC_USERNAME"
|
|
printf 'LDAP password: your corporate LDAP/domain password, not the Bitwarden master password.\n'
|
|
printf '2FA setup secret: permanent BASE32 secret from authenticator setup, not the current 6-digit code.\n'
|
|
printf 'Saved values go to macOS Keychain service openconnect-lite.\n\n'
|
|
|
|
if $password_present; then
|
|
_prompt_read password "Corporate LDAP password for $KC_USERNAME [leave empty to keep saved password]: " 1 || return 1
|
|
else
|
|
_prompt_read password "Corporate LDAP password for $KC_USERNAME: " 1 || return 1
|
|
fi
|
|
if $totp_present; then
|
|
_prompt_read totp_secret "2FA setup secret BASE32 [leave empty to keep saved secret]: " 1 || return 1
|
|
else
|
|
_prompt_read totp_secret "2FA setup secret BASE32: " 1 || return 1
|
|
fi
|
|
|
|
if [[ -z "$password" && "$password_present" != "true" ]]; then
|
|
printf 'LDAP password is required because no saved password was found.\n' >&2
|
|
return 1
|
|
fi
|
|
if [[ -z "$totp_secret" && "$totp_present" != "true" ]]; then
|
|
printf '2FA setup secret is required because no saved secret was found. Use the BASE32 setup secret, not the current 6-digit code.\n' >&2
|
|
return 1
|
|
fi
|
|
|
|
if [[ -n "$totp_secret" ]]; then
|
|
totp_secret="$(_normalize_totp_secret "$totp_secret")"
|
|
fi
|
|
|
|
_store_keychain "$password" "$totp_secret"
|
|
printf 'Credentials are ready in macOS Keychain for openconnect-lite/%s.\n' "$KC_USERNAME"
|
|
}
|
|
|
|
_configure_login() {
|
|
local password password_present=false
|
|
_keychain_has openconnect-lite "$KC_USERNAME" && password_present=true
|
|
|
|
printf 'Manual LDAP login setup for Lemana VPN\n'
|
|
printf 'User: %s\n' "$KC_USERNAME"
|
|
printf 'LDAP password: your corporate LDAP/domain password, not the Bitwarden master password.\n'
|
|
printf 'Saved values go to macOS Keychain service openconnect-lite.\n'
|
|
if [[ "$LOCAL_OTP" == "1" ]]; then
|
|
printf 'One-time codes use the separately imported Keychain secret.\n\n'
|
|
else
|
|
printf 'One-time 2FA codes will still be entered manually during VPN login.\n\n'
|
|
fi
|
|
|
|
if $password_present; then
|
|
_prompt_read password "Corporate LDAP password for $KC_USERNAME [leave empty to keep saved password]: " 1 || return 1
|
|
else
|
|
_prompt_read password "Corporate LDAP password for $KC_USERNAME: " 1 || return 1
|
|
fi
|
|
|
|
if [[ -z "$password" && "$password_present" != "true" ]]; then
|
|
printf 'LDAP password is required because no saved password was found.\n' >&2
|
|
return 1
|
|
fi
|
|
|
|
_store_keychain "$password" ""
|
|
printf 'Login and password are ready in macOS Keychain for openconnect-lite/%s.\n' "$KC_USERNAME"
|
|
}
|
|
|
|
_ensure_keychain_login() {
|
|
local password_present=false
|
|
_keychain_has openconnect-lite "$KC_USERNAME" && password_present=true
|
|
|
|
if [[ "$password_present" == "true" ]]; then
|
|
_emit '{"event":"keychain_ready","source":"keychain","scope":"login"}' \
|
|
"Keychain login is ready: saved LDAP password is available for $KC_USERNAME."
|
|
return 0
|
|
fi
|
|
|
|
_emit '{"event":"keychain_required","source":"keychain","scope":"login"}' \
|
|
"Saved LDAP password is missing."
|
|
|
|
if ! _can_prompt; then
|
|
_emit '{"event":"error","message":"Saved LDAP password is missing. Run vpn --configure-login in Terminal, or connect fully manually."}' \
|
|
"Saved LDAP password is missing. Run: vpn --configure-login"
|
|
return 1
|
|
fi
|
|
|
|
_configure_login
|
|
}
|
|
|
|
_ensure_keychain_credentials() {
|
|
local password_present=false totp_present=false
|
|
_keychain_has openconnect-lite "$KC_USERNAME" && password_present=true
|
|
_keychain_has openconnect-lite "totp/$KC_USERNAME" && totp_present=true
|
|
|
|
if [[ "$password_present" == "true" && "$totp_present" == "true" ]]; then
|
|
if [[ "$CREDENTIAL_SOURCE" == "bitwarden" ]]; then
|
|
_emit '{"event":"keychain_ready","source":"bitwarden"}' "Bitwarden source synced LDAP credentials into macOS Keychain for $KC_USERNAME."
|
|
else
|
|
_emit '{"event":"keychain_ready","source":"keychain"}' "Keychain source is ready: saved LDAP password and 2FA setup secret are available for $KC_USERNAME."
|
|
fi
|
|
return 0
|
|
fi
|
|
|
|
if [[ "$CREDENTIAL_SOURCE" == "bitwarden" ]]; then
|
|
_emit '{"event":"keychain_required","source":"bitwarden"}' "Bitwarden source did not produce complete Keychain credentials."
|
|
_emit '{"event":"error","message":"Bitwarden did not provide complete LDAP username/password/TOTP. Check bw login and the LM LDAP item."}' \
|
|
"Bitwarden did not provide complete LDAP username/password/TOTP. Check: bw login and item '$BW_ITEM_NAME'."
|
|
return 1
|
|
else
|
|
_emit '{"event":"keychain_required","source":"keychain"}' "Keychain source is selected and saved LDAP credentials are incomplete."
|
|
fi
|
|
|
|
if ! _can_prompt; then
|
|
_emit '{"event":"error","message":"Bitwarden automatic sign-in is not configured. Check bw login/LM LDAP, or switch to manual-keychain."}' \
|
|
"Bitwarden automatic sign-in is not configured. Check bw login/LM LDAP, or switch to manual-keychain."
|
|
return 1
|
|
fi
|
|
|
|
_configure_keychain
|
|
}
|
|
|
|
_import_yandex_key() {
|
|
if ! _can_prompt; then
|
|
printf 'Импорт требует Terminal. Запустите: vpn --import-yandex-key\n' >&2
|
|
return 1
|
|
fi
|
|
if [[ -z "$KC_USERNAME" ]] || ! _keychain_has openconnect-lite "$KC_USERNAME"; then
|
|
printf 'Сначала настройте логин и пароль: vpn --configure-login\n' >&2
|
|
return 1
|
|
fi
|
|
local helper saved_username
|
|
saved_username="$(unset LEMANA_VPN_USERNAME; source "$CONFIG_FILE"; printf '%s' "${LEMANA_VPN_USERNAME:-}")"
|
|
if [[ "$saved_username" != "$KC_USERNAME" ]]; then
|
|
printf 'LDAP username отличается от сохранённой настройки. Запустите vpn --configure-login без переопределения username.\n' >&2
|
|
return 1
|
|
fi
|
|
helper="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lemana-vpn-import-yandex.py"
|
|
XDG_CONFIG_HOME="$OC_RUNTIME_XDG_CONFIG_HOME" "$OC_PYTHON" "$helper" "--username=$KC_USERNAME" --config "$CONFIG_FILE"
|
|
}
|
|
|
|
_bw_cache_session() {
|
|
[[ "$CACHE_BW_SESSION" == "1" ]] || return 0
|
|
local session="$1"
|
|
security delete-generic-password -s "$BW_KC_SERVICE" -a "$BW_KC_ACCOUNT_SESSION" >/dev/null 2>&1 || true
|
|
security add-generic-password -s "$BW_KC_SERVICE" -a "$BW_KC_ACCOUNT_SESSION" -w "$session" -U >/dev/null 2>&1 || true
|
|
}
|
|
|
|
_bw_unlock() {
|
|
[[ "$USE_BITWARDEN" == "1" ]] || return 1
|
|
|
|
if ! command -v bw >/dev/null 2>&1; then
|
|
printf 'Bitwarden CLI is not installed. Using existing Keychain credentials.\n' >&2
|
|
return 1
|
|
fi
|
|
|
|
if [[ "$CACHE_BW_SESSION" == "1" ]]; then
|
|
local cached_session
|
|
cached_session="$(security find-generic-password -s "$BW_KC_SERVICE" -a "$BW_KC_ACCOUNT_SESSION" -w 2>/dev/null || true)"
|
|
if [[ -n "$cached_session" ]] && bw status --session "$cached_session" 2>/dev/null | grep -q '"status":"unlocked"'; then
|
|
BW_SESSION="$cached_session"
|
|
_emit '{"event":"bw_cached"}' "Bitwarden: cached session"
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
if [[ "$USE_TOUCHID" == "1" && -x "$KC_FP" ]]; then
|
|
local master_pw
|
|
master_pw="$("$KC_FP" get "$BW_KC_SERVICE" "$BW_KC_ACCOUNT_MASTER" 2>/dev/null || true)"
|
|
if [[ -n "$master_pw" ]]; then
|
|
_emit '{"event":"bw_touchid"}' "Bitwarden: unlocking via Touch ID..."
|
|
BW_SESSION="$(BW_PASSWORD="$master_pw" bw unlock --passwordenv BW_PASSWORD --raw 2>/dev/null || true)"
|
|
unset master_pw
|
|
if [[ -n "${BW_SESSION:-}" ]]; then
|
|
_bw_cache_session "$BW_SESSION"
|
|
return 0
|
|
fi
|
|
printf 'Touch ID password was rejected. Falling back to manual unlock.\n' >&2
|
|
fi
|
|
fi
|
|
|
|
local manual_pw
|
|
if ! _can_prompt; then
|
|
printf 'Bitwarden vault is locked and no interactive terminal is available. Using existing Keychain credentials.\n' >&2
|
|
return 1
|
|
fi
|
|
|
|
_emit '{"event":"bw_manual"}' "Bitwarden vault is locked. Enter Bitwarden master password to sync LDAP credentials."
|
|
_prompt_read manual_pw "Bitwarden master password (not LDAP password): " 1 || return 1
|
|
if [[ -z "$manual_pw" ]]; then
|
|
printf 'Empty Bitwarden password. Using existing Keychain credentials.\n' >&2
|
|
return 1
|
|
fi
|
|
|
|
BW_SESSION="$(BW_PASSWORD="$manual_pw" bw unlock --passwordenv BW_PASSWORD --raw 2>/dev/null || true)"
|
|
if [[ -z "${BW_SESSION:-}" ]]; then
|
|
unset manual_pw
|
|
printf 'Failed to unlock Bitwarden. Using existing Keychain credentials.\n' >&2
|
|
return 1
|
|
fi
|
|
_bw_cache_session "$BW_SESSION"
|
|
|
|
if [[ "$USE_TOUCHID" == "1" && -x "$KC_FP" ]]; then
|
|
local save_choice
|
|
_prompt_read save_choice "Save Bitwarden master password behind Touch ID for next VPN unlock? [Y/n] " || { unset manual_pw; return 1; }
|
|
if [[ "${save_choice:-y}" =~ ^[Yy]?$ ]]; then
|
|
printf '%s' "$manual_pw" | "$KC_FP" set "$BW_KC_SERVICE" "$BW_KC_ACCOUNT_MASTER" >/dev/null 2>&1 \
|
|
&& printf 'Saved. Next unlock can use Touch ID.\n' \
|
|
|| printf 'Could not save Bitwarden master password.\n' >&2
|
|
fi
|
|
fi
|
|
|
|
unset manual_pw
|
|
return 0
|
|
}
|
|
|
|
_sync_bitwarden() {
|
|
if ! _bw_unlock; then
|
|
return 0
|
|
fi
|
|
|
|
local bw_password bw_item bw_username bw_totp_secret
|
|
bw_password="$(bw get password "$BW_ITEM_NAME" --session "$BW_SESSION" 2>/dev/null || true)"
|
|
bw_item="$(bw get item "$BW_ITEM_NAME" --session "$BW_SESSION" 2>/dev/null || true)"
|
|
bw_username="$(printf '%s' "$bw_item" | python3 -c '
|
|
import json
|
|
import sys
|
|
|
|
try:
|
|
data = json.load(sys.stdin)
|
|
print((data.get("login", {}).get("username", "") or "").strip())
|
|
except Exception:
|
|
print("")
|
|
')"
|
|
bw_totp_secret="$(printf '%s' "$bw_item" | python3 -c '
|
|
import json
|
|
import re
|
|
import sys
|
|
|
|
try:
|
|
data = json.load(sys.stdin)
|
|
totp = data.get("login", {}).get("totp", "") or ""
|
|
if totp.startswith("otpauth://"):
|
|
match = re.search(r"secret=([A-Z2-7]+)", totp, re.IGNORECASE)
|
|
print(match.group(1) if match else "")
|
|
else:
|
|
print(totp.strip())
|
|
except Exception:
|
|
print("")
|
|
')"
|
|
|
|
if [[ -n "$bw_username" && "$bw_username" != "${KC_USERNAME:-}" ]]; then
|
|
KC_USERNAME="$bw_username"
|
|
export LEMANA_VPN_USERNAME="$KC_USERNAME"
|
|
_persist_username
|
|
_emit '{"event":"username_saved","source":"bitwarden"}' "LDAP username synced from Bitwarden."
|
|
fi
|
|
|
|
if [[ -n "$bw_password" ]]; then
|
|
if [[ -z "${KC_USERNAME:-}" ]]; then
|
|
printf 'Could not fetch LDAP username from Bitwarden item "%s". Using existing Keychain credentials.\n' "$BW_ITEM_NAME" >&2
|
|
return 0
|
|
fi
|
|
if [[ -n "$bw_totp_secret" ]]; then
|
|
bw_totp_secret="$(_normalize_totp_secret "$bw_totp_secret")"
|
|
fi
|
|
_store_keychain "$bw_password" "$bw_totp_secret"
|
|
_emit '{"event":"bw_synced"}' "Credentials synced from Bitwarden to Keychain"
|
|
else
|
|
printf 'Could not fetch Bitwarden item "%s". Using existing Keychain credentials.\n' "$BW_ITEM_NAME" >&2
|
|
fi
|
|
}
|
|
|
|
_sync_credentials() {
|
|
case "$CREDENTIAL_SOURCE" in
|
|
none)
|
|
_emit '{"event":"credential_source","source":"none"}' "Credential source: none"
|
|
;;
|
|
bitwarden)
|
|
_sync_bitwarden
|
|
;;
|
|
keychain)
|
|
_emit '{"event":"credential_source","source":"keychain"}' "Credential source: macOS Keychain"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
_dns_cleanup() {
|
|
_emit '{"event":"dns_cleanup"}' "Cleaning up VPN DNS..."
|
|
if _dns_recovery_supported; then
|
|
# Replace the finished CLI so the process guard sees that this session ended.
|
|
# Keep the client's exit code; DNS recovery must not change reconnect policy.
|
|
# OpenConnect and its disconnect hook can outlive openconnect-lite on SIGINT.
|
|
exec /bin/sh -c '
|
|
result=active
|
|
attempt=0
|
|
while [ "$attempt" -lt 30 ]; do
|
|
attempt=$((attempt + 1))
|
|
if ! result=$(sudo -n "$1" --recover); then result=error; break; fi
|
|
case "$result" in
|
|
restored|unchanged|no_snapshot) break ;;
|
|
active) [ "$attempt" -ge 30 ] || sleep 1 ;;
|
|
*) result=error; break ;;
|
|
esac
|
|
done
|
|
if [ "$3" = true ]; then
|
|
printf "{\"event\":\"dns_recovery\",\"result\":\"%s\"}\n" "$result"
|
|
fi
|
|
case "$result" in
|
|
error) printf "DNS recovery failed; retry vpn --repair-dns.\n" >&2 ;;
|
|
active) printf "VPN processes are still active; DNS recovery deferred. Retry vpn --repair-dns after they exit.\n" >&2 ;;
|
|
esac
|
|
exit "$2"
|
|
' dns-recovery "$DNS_CLEANUP" "$1" "$JSON_MODE"
|
|
fi
|
|
}
|
|
|
|
_repair_dns() {
|
|
local result=error message="Не удалось восстановить DNS. Обновите установку: sh install.sh" code=1
|
|
if _dns_recovery_supported; then
|
|
if result="$(sudo -n "$DNS_CLEANUP" --recover)"; then
|
|
case "$result" in
|
|
restored) message="Прежние настройки DNS восстановлены"; code=0 ;;
|
|
unchanged) message="Восстановление DNS не требуется"; code=0 ;;
|
|
no_snapshot) message="Сохранённых изменений VPN DNS нет"; code=0 ;;
|
|
active) message="VPN уже запущен; DNS оставлен без изменений"; code=0 ;;
|
|
*) result=error ;;
|
|
esac
|
|
else
|
|
result=error
|
|
message="Не удалось проверить DNS. Обновите установку: sh install.sh; затем повторите vpn --repair-dns"
|
|
fi
|
|
fi
|
|
_emit "{\"event\":\"dns_recovery\",\"result\":\"$result\",\"message\":\"$message\"}" "$message"
|
|
return "$code"
|
|
}
|
|
|
|
_parse_expiry() {
|
|
python3 - "$1" <<'PYDATE'
|
|
import datetime as dt
|
|
import locale
|
|
import re
|
|
import sys
|
|
locale.setlocale(locale.LC_TIME, 'C')
|
|
raw = ' '.join(sys.argv[1].split())
|
|
zone = None
|
|
numeric = re.search(r' ([+-])(\d{2})(?::?(\d{2}))?$', raw)
|
|
if numeric:
|
|
hours, minutes = int(numeric[2]), int(numeric[3] or 0)
|
|
if hours > 14 or minutes > 59:
|
|
sys.exit(1)
|
|
zone = dt.timezone(dt.timedelta(minutes=(hours * 60 + minutes) * (-1 if numeric[1] == '-' else 1)))
|
|
raw = raw[:numeric.start()]
|
|
elif re.search(r' [A-Za-z]+$', raw):
|
|
# OpenConnect renders named zones from this machine's local time.
|
|
raw = raw.rsplit(' ', 1)[0]
|
|
for pattern in ('%a, %d %b %Y %H:%M:%S', '%a %b %d %H:%M:%S %Y'):
|
|
try:
|
|
value = dt.datetime.strptime(raw, pattern)
|
|
if zone is not None:
|
|
value = value.replace(tzinfo=zone)
|
|
print(int(value.timestamp()))
|
|
break
|
|
except ValueError:
|
|
pass
|
|
else:
|
|
sys.exit(1)
|
|
PYDATE
|
|
}
|
|
|
|
_valid_ipv4() {
|
|
local octet
|
|
local IFS=.
|
|
for octet in $1; do
|
|
[[ "$octet" =~ ^[0-9]{1,3}$ ]] && ((10#$octet <= 255)) || return 1
|
|
done
|
|
}
|
|
|
|
_publish_connection() {
|
|
local remaining=0 dns_target
|
|
if [[ -n "$expiry_ts" ]]; then
|
|
remaining=$((expiry_ts - $(date +%s)))
|
|
(( remaining >= 0 )) || remaining=0
|
|
fi
|
|
_emit "{\"event\":\"$1\",\"ip\":\"$vpn_ip\",\"expires\":\"$expiry_iso\",\"started_at\":\"$session_started_at\",\"remaining_sec\":$remaining,\"attempt_id\":\"${auth_attempt:-}\"}" "VPN connected - $vpn_ip"
|
|
dns_target="$(scutil --dns 2>/dev/null | awk '/Supplemental/{found=1} found && /nameserver\[0\]/{split($0,a," : "); if(a[2] ~ /^10\./) {print a[2]; exit}}' || true)"
|
|
_write_status "{\"pid\":$$,\"state\":\"connected\",\"ip\":\"$vpn_ip\",\"expires\":\"$expiry_iso\",\"started_at\":\"$session_started_at\",\"dns_target\":\"$dns_target\",\"updated_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}"
|
|
}
|
|
|
|
_filter_output() {
|
|
local vpn_ip="" session_started_at="" expiry_iso="" expiry_ts="" parsed_ts=""
|
|
local connected=false rejected=false
|
|
while IFS= read -r line; do
|
|
_log_connection_line "$line"
|
|
if $DEBUG; then
|
|
printf '%s\n' "$line"
|
|
elif ! $JSON_MODE; then
|
|
case "$line" in
|
|
*ERROR*|*Error*|*error*|*Failed*|*failed*|*Traceback*|*SAML*|*saml*|*Keycloak*|*keycloak*|*Cisco*|*auth*|*Auth*)
|
|
printf '[openconnect-lite] %s\n' "$line" ;;
|
|
esac
|
|
fi
|
|
if [[ "$line" == *LEMANA_VPN_AUTH_REJECTED* ]] && [[ "${MODE:-manual-keychain}" != manual-keychain ]] && ! $connected && ! $rejected; then
|
|
rejected=true
|
|
[[ -z "${ATTEMPT_DIR:-}" ]] || : > "$ATTEMPT_DIR/auth-rejected"
|
|
_emit '{"event":"auth_rejected","message":"Автоматический вход остановлен: форма отклонила или повторно запросила данные. Проверьте пароль и одноразовый код."}' 'Автоматический вход остановлен. Проверьте пароль и одноразовый код.'
|
|
fi
|
|
$rejected && continue
|
|
if [[ "$line" =~ Configured\ as\ ([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)($|[[:space:],]) ]] && _valid_ipv4 "${BASH_REMATCH[1]}"; then
|
|
# _valid_ipv4 also uses regex matching, so capture the IP independently.
|
|
vpn_ip="${line#*Configured as }"; vpn_ip="${vpn_ip%%[!0-9.]*}"
|
|
if ! $connected; then
|
|
connected=true
|
|
session_started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
[[ -z "${ATTEMPT_DIR:-}" ]] || : > "$ATTEMPT_DIR/connected"
|
|
_stop_connect_progress
|
|
_publish_connection connected
|
|
fi
|
|
elif [[ "$line" == *'Session authentication will expire at '* ]]; then
|
|
if parsed_ts="$(_parse_expiry "${line#*Session authentication will expire at }")"; then
|
|
expiry_ts=$parsed_ts
|
|
expiry_iso="$(date -u -r "$expiry_ts" +%Y-%m-%dT%H:%M:%SZ)"
|
|
if $connected; then _publish_connection session_info; fi
|
|
fi
|
|
fi
|
|
done
|
|
}
|
|
|
|
_recover_for_retry() {
|
|
local result=active attempt
|
|
for ((attempt=1; attempt<=30; attempt++)); do
|
|
if ! result="$(sudo -n "$DNS_CLEANUP" --recover-session "$SESSION_ID")"; then return 1; fi
|
|
case "$result" in
|
|
restored|unchanged|no_snapshot) return 0 ;;
|
|
cancelled) return 2 ;;
|
|
active) (( attempt >= 30 )) || sleep 1 ;;
|
|
*) return 1 ;;
|
|
esac
|
|
done
|
|
return 1
|
|
}
|
|
|
|
_run_connections() {
|
|
local failures=0 delay=0 exit_code recovery_code auth_attempt
|
|
local -a pipeline_status
|
|
while true; do
|
|
rm -f "$ATTEMPT_DIR/connected" "$ATTEMPT_DIR/auth-rejected"
|
|
auth_attempt="$(uuidgen | tr '[:upper:]' '[:lower:]')"
|
|
_emit '{"event":"connecting"}' 'Connecting to VPN (lemanapro)...'
|
|
_write_status "{\"pid\":$$,\"state\":\"connecting\"}"
|
|
_start_connect_progress
|
|
set +e
|
|
QTWEBENGINE_CHROMIUM_FLAGS="--disable-gpu" \
|
|
XDG_CONFIG_HOME="$OC_RUNTIME_XDG_CONFIG_HOME" \
|
|
LEMANA_VPN_AUTH_ATTEMPT="$auth_attempt" \
|
|
LEMANA_VPN_AUTOFILL_DEBUG="$autofill_debug" \
|
|
LEMANA_VPN_AUTOFILL_DISABLE="$autofill_disable" \
|
|
LEMANA_VPN_AUTOFILL_CLICK="$autofill_click" \
|
|
LEMANA_VPN_AUTOFILL_TOTP="$autofill_totp" \
|
|
"$OC_BIN" --browser-display-mode "$display_mode" $log_level -- --script "$dns_hook $auth_attempt" 2>&1 | _filter_output
|
|
pipeline_status=("${PIPESTATUS[@]}")
|
|
set -e
|
|
exit_code=${pipeline_status[0]}
|
|
_stop_connect_progress
|
|
if [[ -f "$ATTEMPT_DIR/auth-rejected" ]]; then return 77; fi
|
|
if [[ "${pipeline_status[1]}" != 0 ]]; then
|
|
_emit '{"event":"error","message":"Не удалось прочитать состояние VPN. Автоматический повтор остановлен."}' 'VPN output processing failed.'
|
|
return 1
|
|
fi
|
|
if [[ "$MODE" == manual-keychain ]] || { [[ ! -f "$ATTEMPT_DIR/connected" ]] && [[ "$exit_code" == 2 || "$exit_code" == 130 || "$exit_code" == 143 ]]; }; then
|
|
_emit '{"event":"disconnected","reason":"ended"}' 'VPN disconnected'
|
|
return "$exit_code"
|
|
fi
|
|
_emit '{"event":"reconnecting","attempt":1,"delay":0}' 'Проверяю завершение туннеля и DNS перед повтором…'
|
|
_write_status "{\"pid\":$$,\"state\":\"reconnecting\"}"
|
|
recovery_code=0
|
|
_recover_for_retry || recovery_code=$?
|
|
case "$recovery_code" in
|
|
2) _emit '{"event":"disconnected","reason":"user"}' 'VPN disconnected'; return 0 ;;
|
|
0) ;;
|
|
*) _emit '{"event":"error","message":"Повторное подключение остановлено: предыдущий туннель или DNS ещё не восстановлены."}' 'VPN/DNS recovery incomplete; reconnect stopped.'; return 1 ;;
|
|
esac
|
|
if [[ -f "$ATTEMPT_DIR/connected" ]]; then
|
|
failures=0; delay=5
|
|
else
|
|
if (( failures >= 5 )); then
|
|
_emit '{"event":"error","message":"Не удалось подключиться после пяти повторов. Проверьте сеть и настройки входа."}' 'Reconnect limit reached.'
|
|
return 1
|
|
fi
|
|
delay=$((5 << failures)); (( delay <= 60 )) || delay=60
|
|
failures=$((failures + 1))
|
|
fi
|
|
_emit "{\"event\":\"reconnecting\",\"attempt\":$((failures > 0 ? failures : 1)),\"delay\":$delay}" "Повторное подключение через $delay с…"
|
|
sleep "$delay"
|
|
done
|
|
}
|
|
|
|
if [[ -n "$RESTART_SESSION" ]]; then
|
|
_session_stop_supported && grep -qx '# LEMANA_RECONNECT_PROTOCOL=1' "$DNS_CLEANUP" || exit 1
|
|
[[ -n "$RESTART_ATTEMPT" ]] || exit 2
|
|
restart_result="$(sudo -n "$DNS_CLEANUP" --restart "$RESTART_SESSION" "$RESTART_ATTEMPT")"
|
|
case "$restart_result" in stopping|stale|cancelled) printf '%s\n' "$restart_result" ;; *) exit 1 ;; esac
|
|
exit 0
|
|
fi
|
|
|
|
if [[ -n "$STOP_SESSION" ]]; then
|
|
_stop_session
|
|
exit 0
|
|
fi
|
|
|
|
if $REPAIR_DNS_MODE; then
|
|
_repair_dns
|
|
exit 0
|
|
fi
|
|
|
|
if [[ -n "$SET_AUTH" ]]; then
|
|
_set_auth
|
|
exit 0
|
|
fi
|
|
|
|
if $STATUS_MODE; then
|
|
_check_status
|
|
exit 0
|
|
fi
|
|
|
|
if $IMPORT_YANDEX_KEY_MODE; then
|
|
_import_yandex_key
|
|
exit 0
|
|
fi
|
|
|
|
if $CONFIGURE_LOGIN_MODE; then
|
|
_ensure_username
|
|
_configure_login
|
|
exit 0
|
|
fi
|
|
|
|
if $CONFIGURE_KEYCHAIN_MODE; then
|
|
_ensure_username
|
|
_configure_keychain
|
|
exit 0
|
|
fi
|
|
|
|
if $PATCH_ONLY_MODE; then
|
|
_patch_oc
|
|
exit 0
|
|
fi
|
|
|
|
if [[ "$MODE" == keychain-auto ]]; then
|
|
if [[ -z "$KC_USERNAME" ]] || ! _keychain_has openconnect-lite "$KC_USERNAME"; then
|
|
_emit '{"event":"error","message":"Настройте логин и пароль: vpn --configure-login"}' "Настройте логин и пароль: vpn --configure-login"
|
|
exit 1
|
|
fi
|
|
if ! _keychain_has openconnect-lite "totp/$KC_USERNAME"; then
|
|
_emit '{"event":"error","message":"Импортируйте код: vpn --import-yandex-key"}' "Импортируйте код: vpn --import-yandex-key"
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
if ! _dns_recovery_supported || ! _session_stop_supported || ! grep -qx '# LEMANA_RECONNECT_PROTOCOL=1' "$DNS_CLEANUP"; then
|
|
_emit '{"event":"error","message":"Обновите helper перед подключением: sh install.sh"}' "Обновите helper перед подключением: sh install.sh"
|
|
exit 1
|
|
fi
|
|
[[ -n "$SESSION_ID" ]] || SESSION_ID="$(uuidgen | tr '[:upper:]' '[:lower:]')"
|
|
session_sudo=()
|
|
if $JSON_MODE || [[ ! -t 0 ]]; then session_sudo=(-n); fi
|
|
if [[ ! "$SESSION_ID" =~ ^[a-f0-9-]{36}$ ]] || ! begin_result="$(sudo "${session_sudo[@]}" "$DNS_CLEANUP" --begin "$SESSION_ID" "$$")" || [[ "$begin_result" != ready ]]; then
|
|
_emit '{"event":"error","message":"Не удалось зарегистрировать VPN-сессию. Проверьте установку и текущий статус."}' "Не удалось зарегистрировать VPN-сессию."
|
|
exit 1
|
|
fi
|
|
ATTEMPT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/lemana-vpn-attempt.XXXXXX")"
|
|
trap 'trap_exit_code=$?; rm -rf "$ATTEMPT_DIR"; _stop_connect_progress; _cleanup_manual_openconnect_config || true; _clear_status || true; _dns_cleanup "$trap_exit_code"' EXIT
|
|
trap 'exit 143' TERM
|
|
_emit '{"event":"session_ready"}' "VPN session registered"
|
|
|
|
if ! $JSON_MODE; then
|
|
_module_status_human
|
|
else
|
|
printf '{"event":"modules","modules":%s}\n' "$(_module_status_json)"
|
|
fi
|
|
|
|
case "$MODE" in
|
|
manual-keychain)
|
|
if $FULL_MANUAL_OVERRIDE; then
|
|
_emit '{"event":"mode","id":"manual-keychain","diagnostic":true,"message":"Diagnostic manual fallback skips saved credential checks"}' \
|
|
"Diagnostic manual fallback: open the visible browser without auto-fill."
|
|
else
|
|
_ensure_username
|
|
_sync_credentials
|
|
_ensure_keychain_login
|
|
fi
|
|
;;
|
|
bitwarden-auto)
|
|
_sync_credentials
|
|
_ensure_keychain_credentials
|
|
;;
|
|
esac
|
|
_patch_oc
|
|
|
|
_prepare_connection_log
|
|
_emit '{"event":"log","message":"openconnect-lite log is enabled"}' "openconnect-lite log: $CONNECT_LOG_FILE"
|
|
|
|
display_mode="shown"
|
|
log_level=""
|
|
autofill_debug="${LEMANA_VPN_AUTOFILL_DEBUG:-0}"
|
|
autofill_disable="${LEMANA_VPN_AUTOFILL_DISABLE:-1}"
|
|
autofill_click="${LEMANA_VPN_AUTOFILL_CLICK:-0}"
|
|
autofill_totp="${LEMANA_VPN_AUTOFILL_TOTP:-0}"
|
|
case "$MODE" in
|
|
manual-keychain)
|
|
autofill_disable="1"
|
|
autofill_click="0"
|
|
autofill_totp="0"
|
|
if $FULL_MANUAL_OVERRIDE; then
|
|
_prepare_manual_openconnect_config
|
|
_emit '{"event":"manual_sso","mode":"manual-keychain","diagnostic":true,"autofill":false,"totp":false,"submit":false}' "Manual fallback: browser is visible, auto-fill is disabled, submit is not pressed."
|
|
else
|
|
autofill_disable="0"
|
|
_emit '{"event":"manual_sso","mode":"manual-keychain","autofill":true,"totp":false,"submit":false}' "Manual mode: browser is visible, login/password are filled, one-time code and submit are manual."
|
|
fi
|
|
;;
|
|
keychain-auto|bitwarden-auto)
|
|
display_mode="hidden"
|
|
autofill_disable="0"
|
|
autofill_click="1"
|
|
autofill_totp="1"
|
|
_emit "{\"event\":\"auto_sso\",\"mode\":\"$MODE\",\"autofill\":true,\"totp\":true,\"submit\":true}" "Automatic mode: hidden browser, auto-fill and auto-submit."
|
|
;;
|
|
esac
|
|
if $DEBUG; then
|
|
display_mode="shown"
|
|
log_level="--log-level debug"
|
|
autofill_debug="1"
|
|
fi
|
|
|
|
# openconnect executes --script through a shell; quote the configured path as data.
|
|
dns_hook="$(python3 -c 'import shlex,sys; print(shlex.quote(sys.argv[1]) + " --vpnc " + shlex.quote(sys.argv[2]))' "$DNS_CLEANUP" "$SESSION_ID")"
|
|
_run_connections
|