Добавь ручной и автоматический режимы VPN
This commit is contained in:
30
README.md
30
README.md
@@ -12,7 +12,7 @@ CLI-установка корпоративного VPN `vpn.lemanapro.ru` дл
|
|||||||
- опциональный Touch ID helper для мастер-пароля Bitwarden;
|
- опциональный Touch ID helper для мастер-пароля Bitwarden;
|
||||||
- Swift Menu Bar app `LemanaVPN.app`;
|
- Swift Menu Bar app `LemanaVPN.app`;
|
||||||
- безопасный DNS cleanup через root-owned wrapper;
|
- безопасный DNS cleanup через root-owned wrapper;
|
||||||
- алиасы `vpn`, `vpn-debug`, `vpn-fix-dns`.
|
- алиасы `vpn`, `vpn-auto`, `vpn-manual`, `vpn-debug`, `vpn-fix-dns`.
|
||||||
|
|
||||||
## Быстрая установка
|
## Быстрая установка
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ curl -fsSL https://example.org/dokril/lemana-vpn/raw/branch/main/install.sh \
|
|||||||
| `/usr/local/sbin/lemana-vpn-dns-cleanup` | Root-owned wrapper для сброса только корпоративных DNS |
|
| `/usr/local/sbin/lemana-vpn-dns-cleanup` | Root-owned wrapper для сброса только корпоративных DNS |
|
||||||
| `/etc/sudoers.d/lemana-vpn-openconnect` | `NOPASSWD` только для `openconnect` |
|
| `/etc/sudoers.d/lemana-vpn-openconnect` | `NOPASSWD` только для `openconnect` |
|
||||||
| `/etc/sudoers.d/lemana-vpn-dns` | `NOPASSWD` только для DNS cleanup wrapper |
|
| `/etc/sudoers.d/lemana-vpn-dns` | `NOPASSWD` только для DNS cleanup wrapper |
|
||||||
| `~/.zshrc` | Идемпотентный блок алиасов `vpn`, `vpn-debug`, `vpn-fix-dns` |
|
| `~/.zshrc` | Идемпотентный блок алиасов `vpn`, `vpn-auto`, `vpn-manual`, `vpn-debug`, `vpn-fix-dns` |
|
||||||
|
|
||||||
## Статус модулей
|
## Статус модулей
|
||||||
|
|
||||||
@@ -304,14 +304,23 @@ open ~/Applications/LemanaVPN.app
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
vpn # подключиться
|
vpn # подключиться
|
||||||
|
vpn-auto # автоматический режим: скрытый браузер, auto-fill и submit
|
||||||
|
vpn-manual # ручной режим: видимый браузер, auto-fill без submit
|
||||||
|
vpn --manual # то же самое без alias
|
||||||
vpn --status # статус без нового подключения
|
vpn --status # статус без нового подключения
|
||||||
vpn --status --json # статус в JSON
|
vpn --status --json # статус в JSON
|
||||||
vpn-debug # видимый браузер и debug-логи
|
vpn-debug # видимый браузер и debug-логи
|
||||||
vpn --manual-sso --debug # видимый браузер без auto-fill/auto-submit Keycloak
|
vpn --manual --debug # ручной режим с debug-логами
|
||||||
vpn-fix-dns # сбросить корпоративные DNS после аварийного завершения
|
vpn-fix-dns # сбросить корпоративные DNS после аварийного завершения
|
||||||
open ~/Applications/LemanaVPN.app # открыть Swift-приложение в menu bar
|
open ~/Applications/LemanaVPN.app # открыть Swift-приложение в menu bar
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Режимы подключения:
|
||||||
|
|
||||||
|
- `auto` — режим по умолчанию. Браузер скрытый, LDAP-пароль и TOTP берутся из Bitwarden/Keychain, Keycloak форма заполняется и отправляется автоматически.
|
||||||
|
- `manual` — браузер видимый, LDAP-пароль и TOTP берутся из Bitwarden/Keychain и подставляются в поля, но кнопки входа не нажимаются. Нажимаешь сам после проверки формы.
|
||||||
|
- `--manual-sso` оставлен как совместимый alias для `--manual`.
|
||||||
|
|
||||||
Первый запуск с Bitwarden:
|
Первый запуск с Bitwarden:
|
||||||
|
|
||||||
1. CLI проверит `bw`.
|
1. CLI проверит `bw`.
|
||||||
@@ -372,7 +381,9 @@ curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh \
|
|||||||
| `input/change events` | Оставляет старое прямое `value = ...`, но после него отправляет DOM events | Keycloak не реагирует на прямую запись value без событий |
|
| `input/change events` | Оставляет старое прямое `value = ...`, но после него отправляет DOM events | Keycloak не реагирует на прямую запись value без событий |
|
||||||
| `legacy auto-fill` | Сохраняет старую рабочую схему `ApplicationWorld`, прямой `value = ...` и простой `click()` | Это ровно тот режим, на котором hidden SSO раньше стабильно проходил Keycloak |
|
| `legacy auto-fill` | Сохраняет старую рабочую схему `ApplicationWorld`, прямой `value = ...` и простой `click()` | Это ровно тот режим, на котором hidden SSO раньше стабильно проходил Keycloak |
|
||||||
| `URL guard` | Проверяет `location.href` через `new RegExp(...)` перед auto-fill | Qt игнорирует `@include`, без guard auto-fill может кликнуть Cisco ACS и сломать SAML |
|
| `URL guard` | Проверяет `location.href` через `new RegExp(...)` перед auto-fill | Qt игнорирует `@include`, без guard auto-fill может кликнуть Cisco ACS и сломать SAML |
|
||||||
| `manual SSO disable` | Позволяет отключить auto-fill через `LEMANA_VPN_AUTOFILL_DISABLE=1` | Нужен для ручной диагностики в видимом браузере |
|
| `auth redirect` | Читает 302 с `vpn.lemanapro.ru` без автоматического follow-redirect | Python `requests` может падать на TLS reset при открытии `/` на `sslvpna/b`, хотя для SAML нужен только конечный host |
|
||||||
|
| `manual submit gate` | Позволяет отключить только auto-click через `LEMANA_VPN_AUTOFILL_CLICK=0` | Ручной режим видит заполненную форму, но сам решает, когда нажать вход |
|
||||||
|
| `manual SSO disable` | Позволяет полностью отключить auto-fill через `LEMANA_VPN_AUTOFILL_DISABLE=1` | Нужен для низкоуровневой диагностики без подстановки полей |
|
||||||
|
|
||||||
Перед первым изменением CLI сохраняет оригинальный файл:
|
Перед первым изменением CLI сохраняет оригинальный файл:
|
||||||
|
|
||||||
@@ -415,19 +426,21 @@ curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh |
|
|||||||
vpn-lemanapro.sh --patch-only
|
vpn-lemanapro.sh --patch-only
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Если лог падает раньше браузера с `SSLEOFError` / `UNEXPECTED_EOF_WHILE_READING` на `sslvpna.lemanapro.ru` или `sslvpnb.lemanapro.ru`, это ломается этап определения конечного Cisco headend. Актуальный runtime-патч `auth redirect` не открывает `/` на `sslvpna/b`, а только берёт `Location` из 302 ответа `vpn.lemanapro.ru` и продолжает штатный SAML init через POST.
|
||||||
|
|
||||||
Если SSO ломается после обновления `openconnect-lite`, запусти:
|
Если SSO ломается после обновления `openconnect-lite`, запусти:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
vpn-debug
|
vpn-debug
|
||||||
```
|
```
|
||||||
|
|
||||||
Если нужно самому посмотреть форму Keycloak и исключить влияние автоматического заполнения:
|
Если нужно самому посмотреть форму Keycloak, но оставить подстановку LDAP/TOTP:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
vpn --manual-sso --debug
|
vpn --manual
|
||||||
```
|
```
|
||||||
|
|
||||||
В этом режиме браузер видимый, а `openconnect-lite` не запускает auto-fill/auto-submit. LDAP-пароль и TOTP seed всё ещё берутся из Keychain, но ввод на странице выполняется вручную.
|
В этом режиме браузер видимый, `openconnect-lite` заполняет поля из Keychain/Bitwarden, но не нажимает submit. Для полной диагностики без подстановки можно отдельно выставить `LEMANA_VPN_AUTOFILL_DISABLE=1`.
|
||||||
|
|
||||||
Если установка падает на строке `install: /usr/local/sbin/...: No such file or directory`, значит на машине не было `/usr/local/sbin`. Актуальный `install.sh` создаёт эту директорию сам; достаточно повторить установку свежей командой `curl`.
|
Если установка падает на строке `install: /usr/local/sbin/...: No such file or directory`, значит на машине не было `/usr/local/sbin`. Актуальный `install.sh` создаёт эту директорию сам; достаточно повторить установку свежей командой `curl`.
|
||||||
|
|
||||||
@@ -437,7 +450,8 @@ CLI перед подключением патчит `openconnect-lite`:
|
|||||||
- добавляет `input` и `change` events для Keycloak auto-fill, сохраняя старое прямое присваивание `value = ...`;
|
- добавляет `input` и `change` events для Keycloak auto-fill, сохраняя старое прямое присваивание `value = ...`;
|
||||||
- оставляет auto-fill в старом `ApplicationWorld` и не добавляет stateful click guards/native setters;
|
- оставляет auto-fill в старом `ApplicationWorld` и не добавляет stateful click guards/native setters;
|
||||||
- добавляет URL guard, чтобы auto-fill не кликал submit на Cisco ACS;
|
- добавляет URL guard, чтобы auto-fill не кликал submit на Cisco ACS;
|
||||||
- добавляет manual SSO disable для видимой ручной диагностики без auto-fill.
|
- добавляет auth redirect patch, чтобы Python не падал на TLS reset при follow-redirect к `sslvpna/b`;
|
||||||
|
- добавляет manual submit gate для видимой ручной диагностики с auto-fill, но без auto-submit.
|
||||||
|
|
||||||
## Удаление
|
## Удаление
|
||||||
|
|
||||||
|
|||||||
@@ -92,10 +92,14 @@ struct VPNMenuView: View {
|
|||||||
Label("VPN отключён", systemImage: "circle")
|
Label("VPN отключён", systemImage: "circle")
|
||||||
.disabled(true)
|
.disabled(true)
|
||||||
Divider()
|
Divider()
|
||||||
Button("Подключить") {
|
Button("Подключить автоматически") {
|
||||||
vpnManager.connect()
|
vpnManager.connect(mode: .auto)
|
||||||
}
|
}
|
||||||
.keyboardShortcut("c")
|
.keyboardShortcut("c")
|
||||||
|
Button("Подключить вручную") {
|
||||||
|
vpnManager.connect(mode: .manual)
|
||||||
|
}
|
||||||
|
.keyboardShortcut("m")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,18 @@ enum VPNState: Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum VPNLaunchMode: String {
|
||||||
|
case auto
|
||||||
|
case manual
|
||||||
|
|
||||||
|
var cliArgument: String {
|
||||||
|
switch self {
|
||||||
|
case .auto: return "--auto"
|
||||||
|
case .manual: return "--manual"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
class VPNManager: ObservableObject {
|
class VPNManager: ObservableObject {
|
||||||
@Published var state: VPNState = .disconnected
|
@Published var state: VPNState = .disconnected
|
||||||
@@ -131,6 +143,7 @@ class VPNManager: ObservableObject {
|
|||||||
private var autoReconnectAttempts: Int = 0
|
private var autoReconnectAttempts: Int = 0
|
||||||
private var reconnectTimer: Timer?
|
private var reconnectTimer: Timer?
|
||||||
private var consecutiveHealthFailures: Int = 0
|
private var consecutiveHealthFailures: Int = 0
|
||||||
|
private var currentLaunchMode: VPNLaunchMode = .auto
|
||||||
|
|
||||||
private let healthCheckInterval: TimeInterval = 10
|
private let healthCheckInterval: TimeInterval = 10
|
||||||
private let maxAutoReconnectAttempts: Int = 3
|
private let maxAutoReconnectAttempts: Int = 3
|
||||||
@@ -223,12 +236,13 @@ class VPNManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func connect() {
|
func connect(mode: VPNLaunchMode = .auto) {
|
||||||
guard !isRunning else {
|
guard !isRunning else {
|
||||||
log("connect() called but process already running")
|
log("connect() called but process already running")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log("-- VPN connect requested --")
|
currentLaunchMode = mode
|
||||||
|
log("-- VPN connect requested (\(mode.rawValue)) --")
|
||||||
refreshStatus()
|
refreshStatus()
|
||||||
state = .connecting
|
state = .connecting
|
||||||
lastError = nil
|
lastError = nil
|
||||||
@@ -240,7 +254,7 @@ class VPNManager: ObservableObject {
|
|||||||
|
|
||||||
let proc = Process()
|
let proc = Process()
|
||||||
proc.executableURL = URL(fileURLWithPath: "/bin/bash")
|
proc.executableURL = URL(fileURLWithPath: "/bin/bash")
|
||||||
proc.arguments = ["-l", scriptPath, "--json"]
|
proc.arguments = ["-l", scriptPath, "--json", mode.cliArgument]
|
||||||
proc.environment = processEnvironment()
|
proc.environment = processEnvironment()
|
||||||
|
|
||||||
let stdoutPipe = Pipe()
|
let stdoutPipe = Pipe()
|
||||||
@@ -430,6 +444,10 @@ class VPNManager: ObservableObject {
|
|||||||
state = .disconnected
|
state = .disconnected
|
||||||
userInitiatedDisconnect = false
|
userInitiatedDisconnect = false
|
||||||
autoReconnectAttempts = 0
|
autoReconnectAttempts = 0
|
||||||
|
} else if currentLaunchMode == .manual {
|
||||||
|
log("Manual connection ended; auto-reconnect is disabled for manual mode")
|
||||||
|
state = .disconnected
|
||||||
|
autoReconnectAttempts = 0
|
||||||
} else if [0, 2, 130, 143].contains(exitCode) {
|
} else if [0, 2, 130, 143].contains(exitCode) {
|
||||||
scheduleAutoReconnect(reason: "session ended (exit \(exitCode))")
|
scheduleAutoReconnect(reason: "session ended (exit \(exitCode))")
|
||||||
} else {
|
} else {
|
||||||
@@ -462,7 +480,7 @@ class VPNManager: ObservableObject {
|
|||||||
self.log("Auto-reconnect cancelled (state changed)")
|
self.log("Auto-reconnect cancelled (state changed)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.connect()
|
self.connect(mode: self.currentLaunchMode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ JSON_MODE=false
|
|||||||
STATUS_MODE=false
|
STATUS_MODE=false
|
||||||
CONFIGURE_KEYCHAIN_MODE=false
|
CONFIGURE_KEYCHAIN_MODE=false
|
||||||
PATCH_ONLY_MODE=false
|
PATCH_ONLY_MODE=false
|
||||||
MANUAL_SSO_MODE=false
|
CONNECT_MODE="${LEMANA_VPN_MODE:-auto}"
|
||||||
|
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
@@ -57,24 +57,35 @@ for arg in "$@"; do
|
|||||||
--status) STATUS_MODE=true ;;
|
--status) STATUS_MODE=true ;;
|
||||||
--configure-keychain) CONFIGURE_KEYCHAIN_MODE=true ;;
|
--configure-keychain) CONFIGURE_KEYCHAIN_MODE=true ;;
|
||||||
--patch-only) PATCH_ONLY_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)
|
--help|-h)
|
||||||
cat <<'HELP'
|
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 Show current VPN status without connecting
|
||||||
--status --json Show current VPN status as JSON
|
--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
|
--json Emit JSON Lines events for UI wrappers
|
||||||
--configure-keychain Prompt for LDAP password and TOTP secret, then save them to Keychain
|
--configure-keychain Prompt for LDAP password and TOTP secret, then save them to Keychain
|
||||||
--patch-only Apply openconnect-lite runtime patches and exit
|
--patch-only Apply openconnect-lite runtime patches and exit
|
||||||
--manual-sso Show browser and disable Keycloak auto-fill/auto-submit
|
|
||||||
HELP
|
HELP
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
|
case "$CONNECT_MODE" in
|
||||||
|
auto|manual) ;;
|
||||||
|
*)
|
||||||
|
printf 'Unknown VPN mode: %s. Use --auto or --manual.\n' "$CONNECT_MODE" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
_emit() {
|
_emit() {
|
||||||
local json="$1" human="$2"
|
local json="$1" human="$2"
|
||||||
if $JSON_MODE; then
|
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 "$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() {
|
_module_bool() {
|
||||||
if "$@" >/dev/null 2>&1; then
|
if "$@" >/dev/null 2>&1; then
|
||||||
printf true
|
printf true
|
||||||
@@ -154,14 +173,19 @@ _module_bool() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_patches_active() {
|
_patches_active() {
|
||||||
local wep
|
local wep authp
|
||||||
wep="$(_find_webengine_process)"
|
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 '"offscreen"' "$wep" \
|
||||||
&& grep -q 'new Event("input", {{bubbles: true}})' "$wep" \
|
&& grep -q 'new Event("input", {{bubbles: true}})' "$wep" \
|
||||||
&& grep -q 'new RegExp' "$wep" \
|
&& grep -q 'new RegExp' "$wep" \
|
||||||
&& grep -q 'LEMANA_VPN_AUTOFILL_DISABLE' "$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() {
|
_keychain_has() {
|
||||||
@@ -317,20 +341,27 @@ _check_status() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_patch_oc() {
|
_patch_oc() {
|
||||||
local wep
|
local wep authp
|
||||||
wep="$(_find_webengine_process)"
|
wep="$(_find_webengine_process)"
|
||||||
if [[ -z "$wep" || ! -f "$wep" ]]; then
|
if [[ -z "$wep" || ! -f "$wep" ]]; then
|
||||||
printf 'webengine_process.py not found. Run: pipx install openconnect-lite\n' >&2
|
printf 'webengine_process.py not found. Run: pipx install openconnect-lite\n' >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
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
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
path = Path(sys.argv[1])
|
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"
|
backup_file = backup_dir / "webengine_process.py.before-lemana-vpn"
|
||||||
|
auth_backup_file = backup_dir / "authenticator.py.before-lemana-vpn"
|
||||||
src = path.read_text()
|
src = path.read_text()
|
||||||
before = src
|
before = src
|
||||||
messages = []
|
messages = []
|
||||||
@@ -473,9 +504,10 @@ canonical_selectors = '''def get_selectors(rules, credentials):
|
|||||||
possibilities=dir(credentials),
|
possibilities=dir(credentials),
|
||||||
)
|
)
|
||||||
elif rule.action == "click":
|
elif rule.action == "click":
|
||||||
statements.append(
|
if os.environ.get("LEMANA_VPN_AUTOFILL_CLICK", "1") != "0":
|
||||||
f"""var elem = document.querySelector({selector}); if (elem) {{ elem.dispatchEvent(new Event("focus")); elem.click(); }}"""
|
statements.append(
|
||||||
)
|
f"""var elem = document.querySelector({selector}); if (elem) {{ elem.dispatchEvent(new Event("focus")); elem.click(); }}"""
|
||||||
|
)
|
||||||
return "\\n".join(statements)
|
return "\\n".join(statements)
|
||||||
'''
|
'''
|
||||||
if selectors_marker not in src:
|
if selectors_marker not in src:
|
||||||
@@ -485,11 +517,53 @@ if src[selectors_start:] != canonical_selectors:
|
|||||||
src = src[:selectors_start] + canonical_selectors
|
src = src[:selectors_start] + canonical_selectors
|
||||||
messages.append("input/change events")
|
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:
|
if src != before:
|
||||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||||
if not backup_file.exists():
|
if not backup_file.exists():
|
||||||
backup_file.write_text(before)
|
backup_file.write_text(before)
|
||||||
path.write_text(src)
|
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:
|
for message in messages:
|
||||||
print(f"Patch applied: {message}")
|
print(f"Patch applied: {message}")
|
||||||
PY
|
PY
|
||||||
@@ -797,10 +871,12 @@ display_mode="hidden"
|
|||||||
log_level=""
|
log_level=""
|
||||||
autofill_debug="${LEMANA_VPN_AUTOFILL_DEBUG:-0}"
|
autofill_debug="${LEMANA_VPN_AUTOFILL_DEBUG:-0}"
|
||||||
autofill_disable="${LEMANA_VPN_AUTOFILL_DISABLE:-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"
|
display_mode="shown"
|
||||||
autofill_disable="1"
|
autofill_disable="0"
|
||||||
_emit '{"event":"manual_sso","autofill":false}' "Manual SSO mode: browser is visible, Keycloak auto-fill is disabled."
|
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
|
fi
|
||||||
if $DEBUG; then
|
if $DEBUG; then
|
||||||
display_mode="shown"
|
display_mode="shown"
|
||||||
@@ -814,6 +890,7 @@ while true; do
|
|||||||
QTWEBENGINE_CHROMIUM_FLAGS="--disable-gpu" \
|
QTWEBENGINE_CHROMIUM_FLAGS="--disable-gpu" \
|
||||||
LEMANA_VPN_AUTOFILL_DEBUG="$autofill_debug" \
|
LEMANA_VPN_AUTOFILL_DEBUG="$autofill_debug" \
|
||||||
LEMANA_VPN_AUTOFILL_DISABLE="$autofill_disable" \
|
LEMANA_VPN_AUTOFILL_DISABLE="$autofill_disable" \
|
||||||
|
LEMANA_VPN_AUTOFILL_CLICK="$autofill_click" \
|
||||||
"$OC_BIN" --browser-display-mode "$display_mode" $log_level 2>&1 \
|
"$OC_BIN" --browser-display-mode "$display_mode" $log_level 2>&1 \
|
||||||
| _filter_output
|
| _filter_output
|
||||||
exit_code=${PIPESTATUS[0]}
|
exit_code=${PIPESTATUS[0]}
|
||||||
|
|||||||
@@ -720,7 +720,7 @@ install_shell_aliases() {
|
|||||||
zshrc="$HOME/.zshrc"
|
zshrc="$HOME/.zshrc"
|
||||||
tmp="$1"
|
tmp="$1"
|
||||||
log_step "Обновляю shell aliases"
|
log_step "Обновляю shell aliases"
|
||||||
log_detail "Алиасы vpn, vpn-debug и vpn-fix-dns добавляются идемпотентным блоком в ~/.zshrc."
|
log_detail "Алиасы vpn, vpn-auto, vpn-manual, vpn-debug и vpn-fix-dns добавляются идемпотентным блоком в ~/.zshrc."
|
||||||
|
|
||||||
[ -f "$zshrc" ] || run touch "$zshrc"
|
[ -f "$zshrc" ] || run touch "$zshrc"
|
||||||
|
|
||||||
@@ -733,6 +733,8 @@ install_shell_aliases() {
|
|||||||
cat > "$block" <<EOF
|
cat > "$block" <<EOF
|
||||||
# >>> lemana-vpn
|
# >>> lemana-vpn
|
||||||
vpn() { "$INSTALL_BIN_DIR/vpn-lemanapro.sh" "\$@"; }
|
vpn() { "$INSTALL_BIN_DIR/vpn-lemanapro.sh" "\$@"; }
|
||||||
|
vpn-auto() { "$INSTALL_BIN_DIR/vpn-lemanapro.sh" --auto "\$@"; }
|
||||||
|
vpn-manual() { "$INSTALL_BIN_DIR/vpn-lemanapro.sh" --manual "\$@"; }
|
||||||
vpn-debug() { "$INSTALL_BIN_DIR/vpn-lemanapro.sh" --debug "\$@"; }
|
vpn-debug() { "$INSTALL_BIN_DIR/vpn-lemanapro.sh" --debug "\$@"; }
|
||||||
vpn-fix-dns() { sudo "$DNS_CLEANUP"; }
|
vpn-fix-dns() { sudo "$DNS_CLEANUP"; }
|
||||||
# <<< lemana-vpn
|
# <<< lemana-vpn
|
||||||
|
|||||||
@@ -35,10 +35,19 @@ printf '%s\n' "$status_json" | grep -q '"app":'
|
|||||||
grep -q 'LemanaVPN-openconnect-lite.log' "$ROOT/bin/vpn-lemanapro.sh"
|
grep -q 'LemanaVPN-openconnect-lite.log' "$ROOT/bin/vpn-lemanapro.sh"
|
||||||
grep -q '"event":"waiting"' "$ROOT/bin/vpn-lemanapro.sh"
|
grep -q '"event":"waiting"' "$ROOT/bin/vpn-lemanapro.sh"
|
||||||
grep -q -- '--patch-only' "$ROOT/bin/vpn-lemanapro.sh"
|
grep -q -- '--patch-only' "$ROOT/bin/vpn-lemanapro.sh"
|
||||||
|
grep -q -- '--auto' "$ROOT/bin/vpn-lemanapro.sh"
|
||||||
|
grep -q -- '--manual' "$ROOT/bin/vpn-lemanapro.sh"
|
||||||
grep -q -- '--manual-sso' "$ROOT/bin/vpn-lemanapro.sh"
|
grep -q -- '--manual-sso' "$ROOT/bin/vpn-lemanapro.sh"
|
||||||
grep -q 'LEMANA_VPN_AUTOFILL_DISABLE' "$ROOT/bin/vpn-lemanapro.sh"
|
grep -q 'LEMANA_VPN_AUTOFILL_DISABLE' "$ROOT/bin/vpn-lemanapro.sh"
|
||||||
|
grep -q 'LEMANA_VPN_AUTOFILL_CLICK' "$ROOT/bin/vpn-lemanapro.sh"
|
||||||
|
grep -q 'vpn-auto' "$ROOT/install.sh"
|
||||||
|
grep -q 'vpn-manual' "$ROOT/install.sh"
|
||||||
|
grep -q 'connect(mode: .auto)' "$ROOT/app/Sources/LemanaVPN/LemanaVPNApp.swift"
|
||||||
|
grep -q 'connect(mode: .manual)' "$ROOT/app/Sources/LemanaVPN/LemanaVPNApp.swift"
|
||||||
|
grep -q 'enum VPNLaunchMode' "$ROOT/app/Sources/LemanaVPN/VPNManager.swift"
|
||||||
|
|
||||||
fake_webengine="$TMP_DIR/webengine_process.py"
|
fake_webengine="$TMP_DIR/webengine_process.py"
|
||||||
|
fake_authenticator="$TMP_DIR/authenticator.py"
|
||||||
cat > "$fake_webengine" <<'PY'
|
cat > "$fake_webengine" <<'PY'
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
@@ -97,7 +106,27 @@ def get_selectors(rules, credentials):
|
|||||||
return "\n".join(statements)
|
return "\n".join(statements)
|
||||||
PY
|
PY
|
||||||
|
|
||||||
|
cat > "$fake_authenticator" <<'PY'
|
||||||
|
import requests
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
|
class Authenticator:
|
||||||
|
def _detect_authentication_target_url(self):
|
||||||
|
# Follow possible redirects in a GET request
|
||||||
|
# Authentication will occur using a POST request on the final URL
|
||||||
|
response = requests.get(self.host.vpn_url)
|
||||||
|
response.raise_for_status()
|
||||||
|
self.host.address = response.url
|
||||||
|
logger.debug("Auth target url", url=self.host.vpn_url)
|
||||||
|
|
||||||
|
def _start_authentication(self):
|
||||||
|
pass
|
||||||
|
PY
|
||||||
|
|
||||||
LEMANA_VPN_WEBENGINE_PROCESS="$fake_webengine" \
|
LEMANA_VPN_WEBENGINE_PROCESS="$fake_webengine" \
|
||||||
|
LEMANA_VPN_AUTHENTICATOR="$fake_authenticator" \
|
||||||
LEMANA_VPN_OC_PYTHON=python3 \
|
LEMANA_VPN_OC_PYTHON=python3 \
|
||||||
LEMANA_VPN_PATCH_BACKUP_DIR="$TMP_DIR/patch-backups" \
|
LEMANA_VPN_PATCH_BACKUP_DIR="$TMP_DIR/patch-backups" \
|
||||||
bash "$ROOT/bin/vpn-lemanapro.sh" --patch-only >/dev/null
|
bash "$ROOT/bin/vpn-lemanapro.sh" --patch-only >/dev/null
|
||||||
@@ -107,6 +136,8 @@ grep -q 'LEMANA_VPN_AUTOFILL_DISABLE' "$fake_webengine"
|
|||||||
grep -q 'new RegExp' "$fake_webengine"
|
grep -q 'new RegExp' "$fake_webengine"
|
||||||
grep -q 'script.setWorldId(QWebEngineScript.ScriptWorldId.ApplicationWorld)' "$fake_webengine"
|
grep -q 'script.setWorldId(QWebEngineScript.ScriptWorldId.ApplicationWorld)' "$fake_webengine"
|
||||||
grep -q 'new Event("input", {{bubbles: true}})' "$fake_webengine"
|
grep -q 'new Event("input", {{bubbles: true}})' "$fake_webengine"
|
||||||
|
grep -q 'LEMANA_VPN_AUTOFILL_CLICK' "$fake_webengine"
|
||||||
|
grep -q 'os.environ.get("LEMANA_VPN_AUTOFILL_CLICK", "1") != "0"' "$fake_webengine"
|
||||||
if grep -q 'ScriptWorldId.MainWorld' "$fake_webengine"; then
|
if grep -q 'ScriptWorldId.MainWorld' "$fake_webengine"; then
|
||||||
echo "patched auto-fill should keep the original ApplicationWorld behavior" >&2
|
echo "patched auto-fill should keep the original ApplicationWorld behavior" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -119,6 +150,13 @@ if grep -q 'valueSetter' "$fake_webengine"; then
|
|||||||
echo "patched auto-fill should use the original direct value assignment with input/change events" >&2
|
echo "patched auto-fill should use the original direct value assignment with input/change events" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
grep -q 'from urllib.parse import urljoin' "$fake_authenticator"
|
||||||
|
grep -q 'self.session.get(self.host.vpn_url, allow_redirects=False)' "$fake_authenticator"
|
||||||
|
grep -q 'response.headers.get("Location")' "$fake_authenticator"
|
||||||
|
if grep -q 'requests.get(self.host.vpn_url)' "$fake_authenticator"; then
|
||||||
|
echo "auth target detection must not follow redirects with bare requests.get" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
status_text="$(bash "$ROOT/bin/vpn-lemanapro.sh" --status)"
|
status_text="$(bash "$ROOT/bin/vpn-lemanapro.sh" --status)"
|
||||||
printf '%s\n' "$status_text" | grep -q 'Modules:'
|
printf '%s\n' "$status_text" | grep -q 'Modules:'
|
||||||
|
|||||||
Reference in New Issue
Block a user