Add fully manual VPN mode

This commit is contained in:
2026-05-26 14:18:38 +03:00
parent c3d8e4b62f
commit f2d4f8e04b
10 changed files with 94 additions and 13 deletions

View File

@@ -12,7 +12,7 @@ This repo is a macOS VPN packaging layer around `openconnect`, `openconnect-lite
## System Map ## System Map
- `install.sh` installs/updates the whole package and restarts `LemanaVPN.app` only if it is already running. - `install.sh` installs/updates the whole package and restarts `LemanaVPN.app` only if it is already running.
- `bin/vpn-lemanapro.sh` is the runtime source for `vpn`, `vpn-auto`, `vpn-manual`, `vpn-debug`, `vpn-fix-dns`. - `bin/vpn-lemanapro.sh` is the runtime source for `vpn`, `vpn-auto`, `vpn-manual`, `vpn-manual-full`, `vpn-debug`, `vpn-fix-dns`.
- `app/Sources/LemanaVPN/VPNManager.swift` shells out to `~/bin/vpn-lemanapro.sh --json`; app state must stay compatible with CLI JSON events. - `app/Sources/LemanaVPN/VPNManager.swift` shells out to `~/bin/vpn-lemanapro.sh --json`; app state must stay compatible with CLI JSON events.
- `templates/openconnect-lite-config.toml` holds Keycloak selectors and the VPN profile. - `templates/openconnect-lite-config.toml` holds Keycloak selectors and the VPN profile.
- `uninstall.sh` must stop the running menu-bar app when removing the app, not only delete the bundle. - `uninstall.sh` must stop the running menu-bar app when removing the app, not only delete the bundle.
@@ -26,7 +26,7 @@ vpn --status
vpn --status --json vpn --status --json
``` ```
Do not start another `vpn`, `vpn-auto`, `vpn-debug`, `vpn --manual`, or app connect if status says connected/connecting/reconnecting or if the previous connect attempt is still active. Inspect logs instead: Do not start another `vpn`, `vpn-auto`, `vpn-debug`, `vpn --manual`, `vpn --manual-full`, or app connect if status says connected/connecting/reconnecting or if the previous connect attempt is still active. Inspect logs instead:
```sh ```sh
tail -f ~/Library/Logs/LemanaVPN-openconnect-lite.log tail -f ~/Library/Logs/LemanaVPN-openconnect-lite.log

View File

@@ -48,6 +48,7 @@ vpn-lemanapro.sh --patch-only
- `vpn` / `vpn-auto`: hidden browser, autofill, auto-submit. - `vpn` / `vpn-auto`: hidden browser, autofill, auto-submit.
- `vpn --manual` / `vpn-manual`: visible browser, autofill, no submit. - `vpn --manual` / `vpn-manual`: visible browser, autofill, no submit.
- `vpn --manual-full` / `vpn-manual-full`: visible browser, no autofill, no submit.
- `vpn-debug`: visible browser and raw logs. - `vpn-debug`: visible browser and raw logs.
When diagnosing SSO, use manual mode first. Do not repeatedly start automatic mode if a connection attempt is already in progress. When diagnosing SSO, use manual mode first. Do not repeatedly start automatic mode if a connection attempt is already in progress.
@@ -66,6 +67,7 @@ If live behavior must be checked:
```sh ```sh
vpn --status vpn --status
vpn --manual vpn --manual
vpn --manual-full
tail -f ~/Library/Logs/LemanaVPN-openconnect-lite.log tail -f ~/Library/Logs/LemanaVPN-openconnect-lite.log
``` ```

View File

@@ -61,6 +61,12 @@ When SSO/autofill changed, prefer:
vpn --manual vpn --manual
``` ```
Use the fully manual path when validating a no-autofill diagnosis path:
```sh
vpn --manual-full
```
Only use automatic mode after manual mode proves the form is filled correctly: Only use automatic mode after manual mode proves the form is filled correctly:
```sh ```sh

View File

@@ -30,14 +30,14 @@ vpn --status --json
Use `~/bin/vpn-lemanapro.sh` if aliases are not loaded. For repo-local code checks, `bin/vpn-lemanapro.sh --status` validates the source script, but it may not be the installed version used by the menu-bar app. Use `~/bin/vpn-lemanapro.sh` if aliases are not loaded. For repo-local code checks, `bin/vpn-lemanapro.sh --status` validates the source script, but it may not be the installed version used by the menu-bar app.
Do not run `vpn`, `vpn-auto`, `vpn-debug`, `vpn --manual`, or the Swift app connect action repeatedly when a connection is already connected, connecting, reconnecting, or when a live connect attempt is still running. Inspect status and logs instead: Do not run `vpn`, `vpn-auto`, `vpn-debug`, `vpn --manual`, `vpn --manual-full`, or the Swift app connect action repeatedly when a connection is already connected, connecting, reconnecting, or when a live connect attempt is still running. Inspect status and logs instead:
```sh ```sh
tail -f ~/Library/Logs/LemanaVPN-openconnect-lite.log tail -f ~/Library/Logs/LemanaVPN-openconnect-lite.log
tail -f ~/Library/Logs/LemanaVPN.log tail -f ~/Library/Logs/LemanaVPN.log
``` ```
Use `vpn --manual` before `vpn`/`vpn-auto` when debugging SSO/autofill, because manual mode shows the browser, fills fields, and does not press submit. Use `vpn-debug` only when raw logs and a visible browser are needed. Use `vpn --manual` before `vpn`/`vpn-auto` when debugging SSO/autofill, because manual mode shows the browser, fills fields, and does not press submit. Use `vpn --manual-full` when the form itself must be filled entirely by hand with no auto-fill. Use `vpn-debug` only when raw logs and a visible browser are needed.
`vpn-lemanapro.sh --patch-only` is safe for applying runtime patches without starting a VPN session. `vpn-lemanapro.sh --patch-only` is safe for applying runtime patches without starting a VPN session.

View File

@@ -14,7 +14,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-auto`, `vpn-manual`, `vpn-debug`, `vpn-fix-dns`. - алиасы `vpn`, `vpn-auto`, `vpn-manual`, `vpn-manual-full`, `vpn-debug`, `vpn-fix-dns`.
## Быстрая установка ## Быстрая установка
@@ -106,7 +106,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-auto`, `vpn-manual`, `vpn-debug`, `vpn-fix-dns` | | `~/.zshrc` | Идемпотентный блок алиасов `vpn`, `vpn-auto`, `vpn-manual`, `vpn-manual-full`, `vpn-debug`, `vpn-fix-dns` |
## Статус модулей ## Статус модулей
@@ -355,7 +355,9 @@ open ~/Applications/LemanaVPN.app
vpn # подключиться vpn # подключиться
vpn-auto # автоматический режим: скрытый браузер, auto-fill и submit vpn-auto # автоматический режим: скрытый браузер, auto-fill и submit
vpn-manual # ручной режим: видимый браузер, auto-fill без submit vpn-manual # ручной режим: видимый браузер, auto-fill без submit
vpn-manual-full # полностью ручной режим: видимый браузер без auto-fill и submit
vpn --manual # то же самое без alias vpn --manual # то же самое без alias
vpn --manual-full # то же самое без alias
vpn --status # статус без нового подключения vpn --status # статус без нового подключения
vpn --status --json # статус в JSON vpn --status --json # статус в JSON
vpn-debug # видимый браузер и debug-логи vpn-debug # видимый браузер и debug-логи
@@ -368,7 +370,9 @@ open ~/Applications/LemanaVPN.app # открыть Swift-приложение
- `auto` — режим по умолчанию. Браузер скрытый, LDAP-пароль и TOTP берутся из Bitwarden/Keychain, Keycloak форма заполняется и отправляется автоматически. - `auto` — режим по умолчанию. Браузер скрытый, LDAP-пароль и TOTP берутся из Bitwarden/Keychain, Keycloak форма заполняется и отправляется автоматически.
- `manual` — браузер видимый, LDAP-пароль и TOTP берутся из Bitwarden/Keychain и подставляются в поля, но кнопки входа не нажимаются. Нажимаешь сам после проверки формы. - `manual` — браузер видимый, LDAP-пароль и TOTP берутся из Bitwarden/Keychain и подставляются в поля, но кнопки входа не нажимаются. Нажимаешь сам после проверки формы.
- `manual-full` — браузер видимый, auto-fill отключён полностью: поля Keycloak заполняешь и отправляешь сам.
- `--manual-sso` оставлен как совместимый alias для `--manual`. - `--manual-sso` оставлен как совместимый alias для `--manual`.
- `--manual-no-autofill` оставлен как совместимый alias для `--manual-full`.
Первый запуск с Bitwarden: Первый запуск с Bitwarden:
@@ -443,7 +447,7 @@ curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh \
| `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 |
| `auth redirect` | Читает 302 с `vpn.lemanapro.ru` без автоматического follow-redirect | Python `requests` может падать на TLS reset при открытии `/` на `sslvpna/b`, хотя для SAML нужен только конечный host | | `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 submit gate` | Позволяет отключить только auto-click через `LEMANA_VPN_AUTOFILL_CLICK=0` | Ручной режим видит заполненную форму, но сам решает, когда нажать вход |
| `manual SSO disable` | Позволяет полностью отключить auto-fill через `LEMANA_VPN_AUTOFILL_DISABLE=1` | Нужен для низкоуровневой диагностики без подстановки полей | | `manual SSO disable` | Позволяет полностью отключить auto-fill через `vpn --manual-full` или `LEMANA_VPN_AUTOFILL_DISABLE=1` | Нужен для низкоуровневой диагностики без подстановки полей |
Перед первым изменением CLI сохраняет оригинальный файл: Перед первым изменением CLI сохраняет оригинальный файл:
@@ -500,7 +504,13 @@ vpn-debug
vpn --manual vpn --manual
``` ```
В этом режиме браузер видимый, `openconnect-lite` заполняет поля из Keychain/Bitwarden, но не нажимает submit. Для полной диагностики без подстановки можно отдельно выставить `LEMANA_VPN_AUTOFILL_DISABLE=1`. В этом режиме браузер видимый, `openconnect-lite` заполняет поля из Keychain/Bitwarden, но не нажимает submit.
Если нужно проверить SSO полностью вручную, без подстановки LDAP-пароля и TOTP:
```sh
vpn --manual-full
```
Если установка падает на строке `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`.

View File

@@ -100,6 +100,10 @@ struct VPNMenuView: View {
vpnManager.connect(mode: .manual) vpnManager.connect(mode: .manual)
} }
.keyboardShortcut("m") .keyboardShortcut("m")
Button("Подключить полностью вручную") {
vpnManager.connect(mode: .manualFull)
}
.keyboardShortcut("f")
} }
} }

View File

@@ -122,11 +122,13 @@ enum VPNState: Equatable {
enum VPNLaunchMode: String { enum VPNLaunchMode: String {
case auto case auto
case manual case manual
case manualFull
var cliArgument: String { var cliArgument: String {
switch self { switch self {
case .auto: return "--auto" case .auto: return "--auto"
case .manual: return "--manual" case .manual: return "--manual"
case .manualFull: return "--manual-full"
} }
} }
} }
@@ -459,7 +461,7 @@ class VPNManager: ObservableObject {
state = .disconnected state = .disconnected
userInitiatedDisconnect = false userInitiatedDisconnect = false
autoReconnectAttempts = 0 autoReconnectAttempts = 0
} else if currentLaunchMode == .manual { } else if currentLaunchMode != .auto {
log("Manual connection ended; auto-reconnect is disabled for manual mode") log("Manual connection ended; auto-reconnect is disabled for manual mode")
state = .disconnected state = .disconnected
autoReconnectAttempts = 0 autoReconnectAttempts = 0

View File

@@ -85,14 +85,17 @@ for arg in "$@"; do
--patch-only) PATCH_ONLY_MODE=true ;; --patch-only) PATCH_ONLY_MODE=true ;;
--auto|auto) CONNECT_MODE="auto" ;; --auto|auto) CONNECT_MODE="auto" ;;
--manual|manual|--manual-sso) CONNECT_MODE="manual" ;; --manual|manual|--manual-sso) CONNECT_MODE="manual" ;;
--manual-full|manual-full|--manual-no-autofill) CONNECT_MODE="manual-full" ;;
--help|-h) --help|-h)
cat <<'HELP' cat <<'HELP'
Usage: vpn-lemanapro.sh [--auto|--manual] [--debug] [--json] [--status] [--configure-keychain] [--patch-only] Usage: vpn-lemanapro.sh [--auto|--manual|--manual-full] [--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
--auto Hidden browser, auto-fill and auto-submit (default) --auto Hidden browser, auto-fill and auto-submit (default)
--manual Visible browser, auto-fill fields, do not press submit --manual Visible browser, auto-fill fields, do not press submit
--manual-full Visible browser, no auto-fill, do not press submit
--manual-no-autofill Compatibility alias for --manual-full
--manual-sso Compatibility alias for --manual --manual-sso Compatibility alias for --manual
--debug Passthrough debug logs; also shows browser in auto mode --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
@@ -105,9 +108,9 @@ HELP
done done
case "$CONNECT_MODE" in case "$CONNECT_MODE" in
auto|manual) ;; auto|manual|manual-full) ;;
*) *)
printf 'Unknown VPN mode: %s. Use --auto or --manual.\n' "$CONNECT_MODE" >&2 printf 'Unknown VPN mode: %s. Use --auto, --manual, or --manual-full.\n' "$CONNECT_MODE" >&2
exit 2 exit 2
;; ;;
esac esac
@@ -953,6 +956,11 @@ if [[ "$CONNECT_MODE" == "manual" ]]; then
autofill_disable="0" autofill_disable="0"
autofill_click="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." _emit '{"event":"manual_sso","autofill":true,"submit":false}' "Manual mode: browser is visible, fields are auto-filled, submit is not pressed."
elif [[ "$CONNECT_MODE" == "manual-full" ]]; then
display_mode="shown"
autofill_disable="1"
autofill_click="0"
_emit '{"event":"manual_sso","autofill":false,"submit":false}' "Full manual mode: browser is visible, auto-fill is disabled, submit is not pressed."
fi fi
if $DEBUG; then if $DEBUG; then
display_mode="shown" display_mode="shown"

View File

@@ -839,7 +839,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-auto, vpn-manual, vpn-debug и vpn-fix-dns добавляются идемпотентным блоком в ~/.zshrc." log_detail "Алиасы vpn, vpn-auto, vpn-manual, vpn-manual-full, vpn-debug и vpn-fix-dns добавляются идемпотентным блоком в ~/.zshrc."
[ -f "$zshrc" ] || run touch "$zshrc" [ -f "$zshrc" ] || run touch "$zshrc"
@@ -854,6 +854,7 @@ install_shell_aliases() {
vpn() { "$INSTALL_BIN_DIR/vpn-lemanapro.sh" "\$@"; } vpn() { "$INSTALL_BIN_DIR/vpn-lemanapro.sh" "\$@"; }
vpn-auto() { "$INSTALL_BIN_DIR/vpn-lemanapro.sh" --auto "\$@"; } vpn-auto() { "$INSTALL_BIN_DIR/vpn-lemanapro.sh" --auto "\$@"; }
vpn-manual() { "$INSTALL_BIN_DIR/vpn-lemanapro.sh" --manual "\$@"; } vpn-manual() { "$INSTALL_BIN_DIR/vpn-lemanapro.sh" --manual "\$@"; }
vpn-manual-full() { "$INSTALL_BIN_DIR/vpn-lemanapro.sh" --manual-full "\$@"; }
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

View File

@@ -66,13 +66,16 @@ 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 -- '--auto' "$ROOT/bin/vpn-lemanapro.sh"
grep -q -- '--manual' "$ROOT/bin/vpn-lemanapro.sh" grep -q -- '--manual' "$ROOT/bin/vpn-lemanapro.sh"
grep -q -- '--manual-full' "$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 'LEMANA_VPN_AUTOFILL_CLICK' "$ROOT/bin/vpn-lemanapro.sh"
grep -q 'vpn-auto' "$ROOT/install.sh" grep -q 'vpn-auto' "$ROOT/install.sh"
grep -q 'vpn-manual' "$ROOT/install.sh" grep -q 'vpn-manual' "$ROOT/install.sh"
grep -q 'vpn-manual-full' "$ROOT/install.sh"
grep -q 'connect(mode: .auto)' "$ROOT/app/Sources/LemanaVPN/LemanaVPNApp.swift" grep -q 'connect(mode: .auto)' "$ROOT/app/Sources/LemanaVPN/LemanaVPNApp.swift"
grep -q 'connect(mode: .manual)' "$ROOT/app/Sources/LemanaVPN/LemanaVPNApp.swift" grep -q 'connect(mode: .manual)' "$ROOT/app/Sources/LemanaVPN/LemanaVPNApp.swift"
grep -q 'connect(mode: .manualFull)' "$ROOT/app/Sources/LemanaVPN/LemanaVPNApp.swift"
grep -q 'enum VPNLaunchMode' "$ROOT/app/Sources/LemanaVPN/VPNManager.swift" grep -q 'enum VPNLaunchMode' "$ROOT/app/Sources/LemanaVPN/VPNManager.swift"
grep -q 'struct Credentials: Decodable' "$ROOT/app/Sources/LemanaVPN/VPNManager.swift" grep -q 'struct Credentials: Decodable' "$ROOT/app/Sources/LemanaVPN/VPNManager.swift"
grep -q 'credential_source' "$ROOT/app/Sources/LemanaVPN/VPNManager.swift" grep -q 'credential_source' "$ROOT/app/Sources/LemanaVPN/VPNManager.swift"
@@ -235,6 +238,51 @@ if printf '%s\n' "$manual_output" | grep -q 'Cleaning up VPN DNS'; then
exit 1 exit 1
fi fi
fake_path_bin="$TMP_DIR/fake-path-bin"
mkdir -p "$fake_path_bin"
cat > "$fake_path_bin/security" <<'SH'
#!/bin/sh
if [ "${1:-}" = "find-generic-password" ]; then
exit 0
fi
exit 1
SH
chmod +x "$fake_path_bin/security"
fake_oc_bin="$TMP_DIR/fake-openconnect-lite"
manual_full_capture="$TMP_DIR/manual-full-capture"
cat > "$fake_oc_bin" <<'SH'
#!/bin/sh
{
printf 'args=%s\n' "$*"
printf 'autofill_disable=%s\n' "${LEMANA_VPN_AUTOFILL_DISABLE:-}"
printf 'autofill_click=%s\n' "${LEMANA_VPN_AUTOFILL_CLICK:-}"
} > "$LEMANA_VPN_CAPTURE_LAUNCH"
exit 0
SH
chmod +x "$fake_oc_bin"
manual_full_output="$(
HOME="$HOME" \
PATH="$fake_path_bin:$PATH" \
LEMANA_VPN_USERNAME="lemana-manual-full-$$" \
LEMANA_VPN_CREDENTIAL_SOURCE=keychain \
LEMANA_VPN_OC_BIN="$fake_oc_bin" \
LEMANA_VPN_OC_PYTHON=python3 \
LEMANA_VPN_WEBENGINE_PROCESS="$fake_webengine" \
LEMANA_VPN_AUTHENTICATOR="$fake_authenticator" \
LEMANA_VPN_PATCH_BACKUP_DIR="$TMP_DIR/manual-full-patch-backups" \
LEMANA_VPN_DNS_CLEANUP="$TMP_DIR/no-dns-cleanup" \
LEMANA_VPN_CONNECT_LOG="$TMP_DIR/manual-full.log" \
LEMANA_VPN_CAPTURE_LAUNCH="$manual_full_capture" \
bash "$ROOT/bin/vpn-lemanapro.sh" --manual-full --json
)"
printf '%s\n' "$manual_full_output" | grep -q '"event":"manual_sso","autofill":false,"submit":false'
grep -q -- '--browser-display-mode shown' "$manual_full_capture"
grep -q '^autofill_disable=1$' "$manual_full_capture"
grep -q '^autofill_click=0$' "$manual_full_capture"
fake_oc_python="$TMP_DIR/fake-oc-python" fake_oc_python="$TMP_DIR/fake-oc-python"
captured_totp="$TMP_DIR/captured-totp" captured_totp="$TMP_DIR/captured-totp"
cat > "$fake_oc_python" <<'SH' cat > "$fake_oc_python" <<'SH'