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

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

View File

@@ -12,7 +12,7 @@ CLI-установка корпоративного VPN `vpn.lemanapro.ru` дл
- опциональный Touch ID helper для мастер-пароля Bitwarden;
- Swift Menu Bar app `LemanaVPN.app`;
- безопасный 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 |
| `/etc/sudoers.d/lemana-vpn-openconnect` | `NOPASSWD` только для `openconnect` |
| `/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
vpn # подключиться
vpn-auto # автоматический режим: скрытый браузер, auto-fill и submit
vpn-manual # ручной режим: видимый браузер, auto-fill без submit
vpn --manual # то же самое без alias
vpn --status # статус без нового подключения
vpn --status --json # статус в JSON
vpn-debug # видимый браузер и debug-логи
vpn --manual-sso --debug # видимый браузер без auto-fill/auto-submit Keycloak
vpn --manual --debug # ручной режим с debug-логами
vpn-fix-dns # сбросить корпоративные DNS после аварийного завершения
open ~/Applications/LemanaVPN.app # открыть Swift-приложение в menu bar
```
Режимы подключения:
- `auto` — режим по умолчанию. Браузер скрытый, LDAP-пароль и TOTP берутся из Bitwarden/Keychain, Keycloak форма заполняется и отправляется автоматически.
- `manual` — браузер видимый, LDAP-пароль и TOTP берутся из Bitwarden/Keychain и подставляются в поля, но кнопки входа не нажимаются. Нажимаешь сам после проверки формы.
- `--manual-sso` оставлен как совместимый alias для `--manual`.
Первый запуск с Bitwarden:
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 без событий |
| `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 |
| `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 сохраняет оригинальный файл:
@@ -415,19 +426,21 @@ curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh |
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`, запусти:
```sh
vpn-debug
```
Если нужно самому посмотреть форму Keycloak и исключить влияние автоматического заполнения:
Если нужно самому посмотреть форму Keycloak, но оставить подстановку LDAP/TOTP:
```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`.
@@ -437,7 +450,8 @@ CLI перед подключением патчит `openconnect-lite`:
- добавляет `input` и `change` events для Keycloak auto-fill, сохраняя старое прямое присваивание `value = ...`;
- оставляет auto-fill в старом `ApplicationWorld` и не добавляет stateful click guards/native setters;
- добавляет 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.
## Удаление

View File

@@ -92,10 +92,14 @@ struct VPNMenuView: View {
Label("VPN отключён", systemImage: "circle")
.disabled(true)
Divider()
Button("Подключить") {
vpnManager.connect()
Button("Подключить автоматически") {
vpnManager.connect(mode: .auto)
}
.keyboardShortcut("c")
Button("Подключить вручную") {
vpnManager.connect(mode: .manual)
}
.keyboardShortcut("m")
}
}

View File

@@ -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
class VPNManager: ObservableObject {
@Published var state: VPNState = .disconnected
@@ -131,6 +143,7 @@ class VPNManager: ObservableObject {
private var autoReconnectAttempts: Int = 0
private var reconnectTimer: Timer?
private var consecutiveHealthFailures: Int = 0
private var currentLaunchMode: VPNLaunchMode = .auto
private let healthCheckInterval: TimeInterval = 10
private let maxAutoReconnectAttempts: Int = 3
@@ -223,12 +236,13 @@ class VPNManager: ObservableObject {
}
}
func connect() {
func connect(mode: VPNLaunchMode = .auto) {
guard !isRunning else {
log("connect() called but process already running")
return
}
log("-- VPN connect requested --")
currentLaunchMode = mode
log("-- VPN connect requested (\(mode.rawValue)) --")
refreshStatus()
state = .connecting
lastError = nil
@@ -240,7 +254,7 @@ class VPNManager: ObservableObject {
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/bin/bash")
proc.arguments = ["-l", scriptPath, "--json"]
proc.arguments = ["-l", scriptPath, "--json", mode.cliArgument]
proc.environment = processEnvironment()
let stdoutPipe = Pipe()
@@ -430,6 +444,10 @@ class VPNManager: ObservableObject {
state = .disconnected
userInitiatedDisconnect = false
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) {
scheduleAutoReconnect(reason: "session ended (exit \(exitCode))")
} else {
@@ -462,7 +480,7 @@ class VPNManager: ObservableObject {
self.log("Auto-reconnect cancelled (state changed)")
return
}
self.connect()
self.connect(mode: self.currentLaunchMode)
}
}
}

View File

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

View File

@@ -720,7 +720,7 @@ install_shell_aliases() {
zshrc="$HOME/.zshrc"
tmp="$1"
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"
@@ -733,6 +733,8 @@ install_shell_aliases() {
cat > "$block" <<EOF
# >>> lemana-vpn
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-fix-dns() { sudo "$DNS_CLEANUP"; }
# <<< lemana-vpn

View File

@@ -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 '"event":"waiting"' "$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 '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_authenticator="$TMP_DIR/authenticator.py"
cat > "$fake_webengine" <<'PY'
import json
import sys
@@ -97,7 +106,27 @@ def get_selectors(rules, credentials):
return "\n".join(statements)
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_AUTHENTICATOR="$fake_authenticator" \
LEMANA_VPN_OC_PYTHON=python3 \
LEMANA_VPN_PATCH_BACKUP_DIR="$TMP_DIR/patch-backups" \
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 'script.setWorldId(QWebEngineScript.ScriptWorldId.ApplicationWorld)' "$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
echo "patched auto-fill should keep the original ApplicationWorld behavior" >&2
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
exit 1
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)"
printf '%s\n' "$status_text" | grep -q 'Modules:'